Astro Build Log
A complete crash course: how Astro thinks, every core API, moving a vanilla site or a React app over, and a full boilerplate you can copy file by file.
/blog/hello-astro
Header.astrostatic HTML
Hero.astrostatic HTML
Search.tsxclient:load
Article body (Markdown)static HTML
TOC.astrostatic HTML
Comments.tsxclient:visible
Footer.astrostatic HTML
The page is the ocean. Interactive parts are islands.
Every component renders to HTML on the server. Only the ones you mark with a client:* directive send JavaScript, and each one loads on its own schedule.
5 components · 0 KB JS
2 islands · JS for these only
01Mental model
Astro is a content-first framework. It renders HTML on the server, either once at build time or on each request, and ships zero client JavaScript unless a component asks for it. Treat it as HTML templates with superpowers, not as a React app that happens to output HTML.
Three ideas carry the whole framework. Every page is a real route in a multi-page app, so navigation is a normal page load. Components run once on the server and become static HTML. When a piece of UI needs interactivity, you mark it as an island and only its JavaScript reaches the browser. React, Vue, Svelte, Solid and Preact components can all be islands, even on the same page.
| Good fit | Weak fit |
| Blogs, docs, marketing sites, portfolios, storefronts, dashboards with mostly static chrome | Apps where nearly every pixel is stateful (an editor, a mail client). They can live inside Astro as one large island, but most of the benefit disappears. |
Version notes
High confidence, verified against the Astro 6 release post: Astro 6 is the current stable major, with a redesigned dev server, stable live content collections, a stable CSP API, a stable Fonts API, and Zod imported from astro/zod.
From memory, worth checking in the docs before pinning: the exact latest 6.x minor, the Node.js minimum (a third-party tutorial says 22.x), the live collections config file name, and the exact shape of the fonts config option.
02Setup and project structure
One command scaffolds a project. Pick the minimal or blog template when it asks.
dev starts the dev server on port 4321. build writes the production site to dist/, and preview serves that build locally. astro add installs an integration and edits astro.config.mjs for you; it is the safest way to add React, MDX, a sitemap or a deployment adapter. astro check type-checks your .astro files.
| Path | What lives there |
public/ | Copied as-is to the site root: favicons, robots.txt, files you do not want processed |
src/pages/ | The only folder Astro enforces. Every file here becomes a route. |
src/components/ | Reusable .astro and framework components (convention) |
src/layouts/ | Page shells with html, head, nav and footer (convention) |
src/assets/ | Images you want optimized and hashed at build |
src/content.config.ts | Content collection definitions |
src/middleware.ts | Code that runs before requests (optional) |
src/actions/ | Type-safe server functions (optional) |
astro.config.mjs | Integrations, adapter, output mode, site URL |
03Anatomy of a .astro component
A .astro file has two parts. The frontmatter between the --- fences runs on the server only. The template below it looks like HTML with JSX-style expressions.
FrontmatterPlain TypeScript that runs once per render on the server. Top-level await works, secrets stay safe, and nothing here reaches the browser. interface Props types the props and Astro.props reads them. You will also use Astro.cookies, Astro.request, Astro.url, Astro.params and Astro.redirect() a lot.
Template{} holds expressions. Conditionals use && or ternaries, lists use .map(), as in JSX. The differences: write class instead of className, attributes are plain HTML, class:list builds class strings from arrays and objects, and there are no hooks or state because the template runs once.
Slots<slot /> is Astro's version of children. A named slot is filled by a child carrying slot="footer". Content inside the slot tag is the fallback.
<style>Scoped to this component automatically. Use <style is:global> or :global(.x) to escape the scope.
<script>Client JavaScript. Astro bundles it, compiles TypeScript and imports, loads it as a module, and includes it once even if the component appears ten times. Add is:inline to ship it exactly as written instead.
Passing server data to a client script
Render the data into the DOM, then read it in the script. A custom element is the cleanest version of this pattern.
The server renders a custom element with a data-api attribute. The bundled script defines the element once, and the browser upgrades every copy on the page, including copies that arrive later through view transitions. For one-off values you can also use define:vars={{ value }} on a script, which makes it inline.
04Routing
Routes are files in src/pages/. Pages can be .astro, .md, .mdx (with the integration) or .html. .ts and .js files become endpoints.
| File | URL |
src/pages/index.astro | / |
src/pages/about.astro | /about |
src/pages/blog/index.astro | /blog |
src/pages/blog/[slug].astro | /blog/hello-world |
src/pages/docs/[...path].astro | /docs/a/b/c (rest parameter) |
src/pages/api/users.ts | /api/users (endpoint) |
src/pages/404.astro | Custom not-found page |
src/pages/_draft.astro | Ignored because of the underscore |
Static dynamic routes
In static mode, the default, a dynamic route lists every path at build time with getStaticPaths.
getStaticPaths runs once at build and returns one object per page. params fills the bracketed segment; props goes straight to the page so you never refetch. render(post) turns a Markdown entry into a <Content /> component plus its heading list, which you can use for a table of contents.
Pagination is built in: return paginate(posts, { pageSize: 10 }) from getStaticPaths in a [page].astro file, then read Astro.props.page for data, url.prev, url.next and currentPage.
On-demand routes
With an adapter installed (section 08), prerender = false renders the page per request. There is no getStaticPaths; you read Astro.params directly and can return a redirect or a Response from the frontmatter.
Endpoints
Each exported HTTP method returns a standard Response. The context gives you params, request, url, cookies, locals and redirect. Leave out prerender = false and the endpoint runs once at build and writes a file, which is how rss.xml and JSON feeds are generated. Permanent redirects belong in config: redirects: { '/old': '/new' }.
05Layouts and styling
A layout is an ordinary component that renders the whole HTML document and exposes a slot for the page.
Importing global.css in the layout applies it to every page that uses the layout. Astro.site comes from the site config option and is needed for canonical URLs, sitemaps and RSS. <ClientRouter /> is optional and turns on view transitions. The named head slot lets a page add its own meta tags:
| Approach | How | Use for |
| Scoped styles | <style> in any .astro file | The default for component styles |
| Global CSS | import '../styles/global.css' in a layout | Resets, tokens, typography |
| Props into CSS | <style define:vars={{ color }}> | Server values as CSS variables |
| Tailwind | npx astro add tailwind | Utility-first styling. I believe it now wires up Tailwind v4 through its Vite plugin; confirm after install. |
| Sass or Less | Install the preprocessor, then <style lang="scss"> | Existing Sass codebases |
| CSS Modules | import s from './x.module.css' | Mostly inside React islands |
GotchaScoped styles do not reach into child components. Style the child in its own file, pass a class prop through, or use :global() in the parent.
06Islands and client directives
A framework component with no directive renders to static HTML and ships nothing. Add a client:* directive and it becomes an island: Astro sends its HTML plus the code to hydrate it, and each island hydrates on its own.
| Directive | Hydrates when | Use for |
| none | Never | Anything that only displays data |
client:load | Right away | Above-the-fold UI that must work at once |
client:idle | Browser is idle | Lower-priority widgets |
client:visible | Scrolled into view | Comments, charts, footers |
client:media="…" | A media query matches | Mobile-only menus |
client:only="react" | Client only, no server render | Code that touches window or browser-only libraries |
Props must serializeStrings, numbers, booleans, arrays, plain objects, Date, Map, Set, URL and RegExp cross the boundary. Functions do not, so an .astro parent cannot pass an onClick. Keep event handling inside the island.
ChildrenChildren passed to an island are rendered on the server and arrive as static HTML (children in React). Static content can sit inside an interactive shell.
No shared contextEach island is its own React root, so context providers do not span islands. Share state with a small store such as Nano Stores, custom DOM events, or the URL.
src/components/CartBadge.tsx
The store exports one atom any island can import. useStore subscribes the badge, so when another island calls cartCount.set(3) the badge updates, even though the two islands are separate React roots. A vanilla <script> can import the same atom and call .subscribe().
07Content collections
Collections give you typed, validated content from Markdown, MDX, JSON, YAML or any remote source. Define them once with a loader and a Zod schema.
globReads a folder of files; each file path becomes the entry id.
fileReads one JSON or YAML file holding an array or a keyed object.
inline loaderAny async function returning objects with an id. It runs at build, so remote data becomes static content.
schemaValidates at build and generates types. image() validates and optimizes local images; reference() links to entries in another collection. In Astro 6, import z from astro/zod.
getCollection returns every entry, with an optional filter; here drafts show in dev and disappear in production. getEntry resolves a reference to the full entry. The body is rendered with render(entry), shown in section 04.
Live collectionsStable in Astro 6. They fetch at request time instead of build time, for content that must change without a rebuild. Define them with defineLiveCollection (I believe in src/live.config.ts) and read them with getLiveCollection and getLiveEntry, which return { entry, error }. They need on-demand routes. Prefer build-time collections unless freshness matters.
08Server features
Every route is prerendered by default. To render routes per request, add an adapter for your host and opt those routes out of prerendering.
| Setup | Behaviour |
output: 'static' + adapter | Everything prerendered; pages with prerender = false render on demand |
output: 'server' + adapter | Everything on demand; pages with prerender = true are prerendered |
| No adapter | Pure static site; prerender = false is an error |
Official adapters: @astrojs/node, @astrojs/vercel, @astrojs/netlify, @astrojs/cloudflare. Install with npx astro add <name>.
Server islands
server:defer lets a static, cacheable page hand one personalised piece to the server. The page loads at once with the fallback, then UserMenu is rendered separately (it can read cookies and query a database) and swapped in. Needs an adapter.
Actions
src/pages/subscribe.astro
Every key on server becomes an action. accept: 'form' parses FormData and validates it before the handler runs; context holds cookies, locals and the request. The page posts the form straight to the action with no client JavaScript and reads the result on re-render. From a React island you call await actions.subscribe(formData) and get { data, error }.
Middleware
auth reads the session cookie, stores the user on locals (read it as Astro.locals.user in any page), and redirects anonymous visitors away from /admin. timing wraps next() to add a header after the page renders. sequence runs them in order. Type locals by declaring App.Locals in src/env.d.ts. In Astro 6, middleware also runs during the build for prerendered pages, where there is no real request, so guard cookie and header reads.
Environment variables
Any .env value is available on the server as import.meta.env.NAME; only names starting with PUBLIC_ reach client code. The schema above adds validation and types: import secrets from astro:env/server and public values from astro:env/client.
09Vanilla HTML/CSS/JS to Astro
Any valid HTML is valid Astro. Rename index.html to src/pages/index.astro and it works. Everything after that is refactoring for reuse.
| Vanilla site | Astro equivalent |
about.html, contact.html | src/pages/about.astro, src/pages/contact.astro |
| Header and footer pasted into every file | src/layouts/BaseLayout.astro with a slot |
| Repeated card markup | src/components/Card.astro with props |
<link rel="stylesheet" href="style.css"> | Import in the layout, or scoped <style> |
<script src="main.js"> | <script> in the component that needs it |
images/ | src/assets/ to optimize, public/ to leave untouched |
| Hand-maintained list of posts | A content collection and getCollection |
| jQuery-style DOM wiring | <script> plus a custom element |
Move static files
Put favicon.ico, robots.txt and anything referenced by absolute URL into public/. Paths like /img/logo.png keep working.
Extract the shell
Move <html>, <head> and the nav into BaseLayout.astro (section 05). Each page shrinks to its own content.
After · src/pages/about.astro
Links change from /about.html to /about.
Convert scripts per component
A global main.js still works if the layout imports it inside a <script>. Better, move each behaviour next to its markup so it ships only on pages that use it.
The frontmatter takes a typed list and the template maps it to native <details> elements. The script is bundled once per page, even with several accordions, and closes the other panels when one opens.
Plan for view transitions
If you add <ClientRouter /> later, scripts that run once on load do not re-run after client navigation. Wrap setup in document.addEventListener('astro:page-load', () => { … }), or use custom elements, which upgrade automatically.
Turn repetition into content
Ten near-identical project pages become one collection and one [slug].astro route.
10React (Vite or CRA) to Astro
The shift: in a React SPA everything is client-side by default and you fetch in effects. In Astro everything is server-rendered by default, data loads in frontmatter, and React stays only where there is real interaction.
| React SPA | Astro |
React Router <Routes> | Files in src/pages/ |
useParams() | Astro.params or getStaticPaths |
useEffect + fetch for page data | await fetch() in frontmatter |
index.html + <App /> shell | A layout component |
| react-helmet for titles and meta | Props on the layout, or a named head slot |
<Link to="/x"> | <a href="/x">, plus <ClientRouter /> for SPA-like transitions |
| Context provider at the root | Nano Stores, or keep the provider inside one island |
import.meta.env.VITE_X / REACT_APP_X | import.meta.env.PUBLIC_X or astro:env/client |
| Static presentational components | Rewrite as .astro, or render as React with no directive |
| Stateful widgets | Keep as .tsx with client:* |
Path A: incremental, keep the SPA running
Install React with npx astro add react, copy src/ components across, and mount the whole app as one client-only island behind a catch-all route. Then peel off pages into real Astro routes one at a time.
The rest parameter sends every /app/* URL to this one page, and client:only skips server rendering so browser-only code inside the app keeps working. Set React Router's basename to /app. This needs an adapter because the paths are unknown at build. On a pure static host, use a single app.astro page and a host rewrite of /app/* to it instead.
Path B: convert a page properly
After · src/pages/posts/index.astro
The loading state, the two pieces of state and the effect all disappear: the list is fetched on the server and arrives as finished HTML. Link becomes a plain anchor. LikeButton is untouched React code that now hydrates only when scrolled into view, so the rest of the list ships no JavaScript. API_URL has no PUBLIC_ prefix because it is only read on the server.
Converting a presentational component
className becomes class (here class:list), children becomes <slot />, and the types move into interface Props. Only convert components with no state or handlers; anything with useState stays React.
React gotchasHooks only run inside islands. Code that touches window at import time breaks server rendering; move it into an effect or use client:only. Global CSS imported inside a React component still works and is bundled by Vite.
11Full boilerplate
A complete starter: layout with SEO, a typed blog collection, RSS, sitemap, a vanilla theme toggle, one React island, a 404 page and a health endpoint. Pick a file to read it. Create the project with npm create astro@latest (minimal template), then run npx astro add react mdx sitemap and npm i @astrojs/rss so the package versions come from your install.
12Essentials
Images
Importing from src/assets/ gives Astro the image's real size, so it can resize, convert to WebP and set width and height to stop layout shift. <Picture> outputs several formats. Remote images need explicit dimensions, and their domains must be listed in image.domains in config to be optimized. I am fairly sure priority is the current prop for eager-loading a hero image; check it if your version complains. Files in public/ are never optimized.
Fonts
The Fonts API downloads and self-hosts the font at build, generates fallbacks and exposes it as a CSS variable. Both imports are confirmed by the Astro 6 post; the exact option names inside each entry are from memory, so compare with the Fonts guide.
View transitions
<ClientRouter /> (formerly <ViewTransitions />) intercepts link clicks and animates between pages. transition:name pairs elements across pages for morph animations; transition:persist keeps an element alive, such as a playing audio player. Use astro:page-load for setup code. Without the router, modern browsers can still do cross-document transitions with the CSS @view-transition { navigation: auto; } rule.
Prefetch, Markdown and SEO
| Need | How |
| Faster navigation | prefetch: true in config; per link data-astro-prefetch="viewport" |
| Components in Markdown | npx astro add mdx, then import components inside .mdx |
| Code highlighting | Shiki is built in; set markdown.shikiConfig.theme |
| Sitemap | @astrojs/sitemap with site set |
| RSS | @astrojs/rss in an endpoint (boilerplate) |
| Glob-import files | import.meta.glob('./posts/*.md', { eager: true }); Astro.glob is gone |
Deployment
| Host | Static site | With on-demand routes |
| Vercel | Import the repo; build npm run build, output dist | npx astro add vercel |
| Netlify | Same build settings | npx astro add netlify |
| Cloudflare | Pages or Workers static assets | npx astro add cloudflare |
| VPS, Docker, Raspberry Pi | Serve dist/ with nginx or Caddy | @astrojs/node standalone, then node dist/server/entry.mjs |
| GitHub Pages | Official withastro/action workflow; set base for project pages | Not supported |
13Cheat sheet and gotchas
Virtual modules
| Import from | What you get |
astro:content | defineCollection, getCollection, getEntry, render, reference |
astro/loaders | glob, file |
astro/zod | z |
astro:assets | Image, Picture, getImage, Font |
astro:transitions | ClientRouter, navigate |
astro:actions | defineAction, actions, ActionError |
astro:middleware | defineMiddleware, sequence |
astro:env/server, astro:env/client | Typed environment variables |
The Astro global
| Property | Meaning |
Astro.props | Props passed to the component |
Astro.params | Dynamic route segments |
Astro.url, Astro.request | Current URL and request |
Astro.cookies | get, set, has, delete (on-demand pages) |
Astro.locals | Data set by middleware |
Astro.redirect(url, status) | Return a redirect from frontmatter |
Astro.site | The site config value as a URL |
Astro.slots.has('name') | Whether a named slot was filled |
Template directives
| Directive | Effect |
client:* | Hydrate a framework component (section 06) |
server:defer | Render a component later on the server |
class:list | Build a class string from arrays and objects |
set:html | Insert raw HTML (trusted content only) |
set:text | Insert escaped text |
is:inline | Ship a script or style unprocessed |
is:global | Unscoped style block |
define:vars | Pass server values into a style or script |
transition:name, transition:persist | View transition pairing and persistence |
Gotchas
| Symptom | Cause | Fix |
| React button does nothing | No client:* directive | Add client:load or client:visible |
window is not defined | Browser code runs during server render | Move it into an effect, or use client:only |
| Function prop is undefined in an island | Functions do not serialize | Handle events inside the island |
| Script stops working after navigation | ClientRouter swaps the page without reloading | Use astro:page-load or custom elements |
| Styles do not reach a child | Scoping | Pass class, or use :global() |
getStaticPaths required error | Dynamic route in static mode | Return paths, or set prerender = false with an adapter |
| Cookies empty or warnings at build | Reading request data in a prerendered page | Make that route on-demand |
| Secret shows up in client bundle | Read in a client script or PUBLIC_ prefix | Read it in frontmatter, use astro:env/server |
| Zod import error after upgrading | Astro 6 moved it | Import z from astro/zod |
| Wrong canonical or sitemap URLs | site missing | Set site in config |