Astro Patterns
Quick Guide: Astro renders pages to static HTML with zero client JavaScript by default.
.astrocomponents carry static content; framework components become interactive islands only when given aclient:*directive. Content collections give structured Markdown type-safe frontmatter through Zod schemas. Rendering mode is per page: static unlessexport const prerender = false. Astro 6 replaced<ViewTransitions />with<ClientRouter />, replacedAstro.glob()withimport.meta.glob(), movedzfromastro:contenttoastro/zod(now Zod 4), requires stringgetStaticPaths()params, and requires Node 22.12.0+.
Detailed Resources:
- examples/core.md — typed props, expressions, nested layouts, scoped vs global styles, script handling
- examples/islands.md — directive selection,
client:only, multi-framework islands, server islands, cross-island state - examples/content.md — collection schemas, querying, rendering, references, custom loaders, live collections
- examples/routing.md — static and dynamic routes, rest params, pagination, on-demand routes, endpoints, 404
- examples/integrations.md — framework integrations, View Transitions, persistence, animations, Starlight
- reference.md — project layout, route priority, collection API, adapters, rendering checklist
Which path applies
- Every page is static — the default. No
outputsetting and no adapter; dynamic routes needgetStaticPaths(). Follow examples/routing.md. - A few pages need request-time data — keep the default, add
export const prerender = falseto those pages, and install a server adapter. Same file, plus the adapter table in reference.md. - Most pages need request-time data — set
output: "server"and opt individual pages back to static withexport const prerender = true.
Before writing Astro code
Leave pages static and add export const prerender = false only where the page reads request-time
data. Static is the default and is where the framework's speed comes from.
Install a server adapter before any page opts out of prerendering. Without one, a build containing
prerender = false fails.
Give a framework component a client:* directive when it needs to be interactive. Without a
directive it renders to static HTML and its event handlers never run.
Define collections in src/content.config.ts with a Zod schema. The schema validates frontmatter at
build time and generates the types the queries return.
Use getStaticPaths() for dynamic routes in static mode. On-demand routes read Astro.params
directly and need no such export.
Auto-detection: Astro, .astro files, astro.config, islands architecture, client:load, client:visible, client:idle, client:only, client:media, server:defer, content collections, defineCollection, defineLiveCollection, getCollection, getLiveCollection, getEntry, getLiveEntry, render, astro:content, astro:transitions, ClientRouter, getStaticPaths, Astro.props, Astro.params, Astro.cookies, Astro.redirect, prerender, astro add, @astrojs/react, @astrojs/vue, @astrojs/svelte, Starlight
Applies to:
.astrocomponent syntax — frontmatter, template expressions, slots, layouts, scoped styles- Islands: client directives, hydration timing, server islands, mixing UI frameworks on one page
- Content collections: schemas, loaders, querying, rendering, cross-collection references
- File-based routing: static, dynamic, rest parameters, pagination, API endpoints
- Rendering mode: static, on-demand, and per-page
prerendercontrol - View Transitions:
<ClientRouter />, transition directives, persisted elements
Handled elsewhere:
- What an island renders internally — a hydrated component follows the conventions of whichever UI library wrote it, and Astro settles only the boundary.
- Which CSS approach fills a
<style>block — the scoping rules here are Astro's; the styling system is not. - Which store library fills the cross-island seam — that islands share no tree, and that a module-scoped store is the way across, is settled in examples/islands.md; which store you reach for is not.
- Fully interactive applications where every route is user-specific — that shape wants a framework whose default is on-demand rendering rather than one whose default is static output.
Astro is a content-first framework that ships zero JavaScript by default. Most of a page is static HTML; small interactive "islands" hydrate independently, each paying only for itself.
Four consequences follow, and they explain most of the API:
- A component is static until told otherwise —
client:*is the opt-in, so the cost of interactivity is always visible at the call site. - The UI library is a detail — React, Vue, Svelte, Solid and Preact components are all just island contents, and one page can carry several.
- Content is typed data — collections put a Zod schema between Markdown frontmatter and the code that reads it, so a typo in a post fails the build rather than the page.
- Rendering mode is per page — static and on-demand pages coexist in one project, which is why
prerenderis an export rather than a global setting.
Static or on-demand? A page needs on-demand rendering when it reads cookies, headers or user-specific data at request time, or when its data changes faster than you are willing to rebuild. Everything else is static — including data that changes hourly, which a rebuild handles more cheaply than an adapter does.
.astro or a framework component? No client-side interactivity means .astro — zero JavaScript.
Simple interactivity (a toggle, a show/hide) is .astro plus a <script> tag, which is lighter than a
framework. Reach for a framework component when the interaction needs that framework's own state model.
Which client directive? The table in Pattern 4 maps each one to what it is for. client:only is
the last resort, for a component that cannot server-render at all.
Build-time or live collection? defineCollection for anything that is the same on every request —
posts, docs, changelogs, author bios. defineLiveCollection only when the data must be fresh per
request (inventory, pricing), which also forces that page into on-demand rendering.
Core patterns
Pattern 1: Astro component structure
An .astro file is a server-only script between --- fences followed by an HTML template. The script
never reaches the browser, so data fetching belongs there.
---
interface Props { title: string; description?: string }
const { title, description = "Default" } = Astro.props;
const posts = await getCollection("blog");
---
<h1>{title}</h1>
{posts.map((post) => <a href={`/blog/${post.id}`}>{post.data.title}</a>)}
<style>h1 { color: navy; }</style>
Styles in a <style> block are scoped to the component unless marked is:global.
Full code: examples/core.md
Pattern 2: Slots for composition
A <slot /> receives content from the parent. Named slots take a matching slot="name" attribute, and
anything inside the <slot> element is fallback content when nothing is passed.
<article>
<header><slot name="header"><h2>{title}</h2></slot></header>
<slot />
<footer><slot name="footer" /></footer>
</article>
Full code: examples/core.md
Pattern 3: Layouts
A layout is an ordinary component that wraps page content through its default slot, and layouts nest — a blog layout wrapping a base layout wrapping the page.
---
const { title } = Astro.props;
---
<html lang="en">
<head><title>{title}</title></head>
<body><main><slot /></main></body>
</html>
Full code: examples/core.md
Pattern 4: Islands and client directives
Framework components render to static HTML until a client:* directive hydrates them. The directive
chooses when hydration happens.
| Directive | Hydrates | Use for |
|---|---|---|
client:load |
Immediately on page load | Critical interactive UI |
client:idle |
When the browser is idle | Lower-priority interactivity |
client:visible |
When it scrolls into the viewport | Below-the-fold content |
client:media |
When a media query matches | Responsive interactivity |
client:only |
Client only, skipping the server render | Browser-dependent components |
<Header /> <!-- static, zero JS -->
<SearchBar client:load />
<Comments client:visible />
<Analytics client:media="(max-width: 768px)" />
Full code: examples/islands.md
Pattern 5: Server islands
server:defer renders a component per request while the rest of the page stays cacheable. The
fallback slot holds its place until it arrives.
<ProductInfo product={product} />
<UserReviews server:defer>
<div slot="fallback">Loading reviews...</div>
</UserReviews>
Full code: examples/islands.md
Pattern 6: Content collections
A collection pairs a loader with a Zod schema. getCollection queries it with an optional filter, and
render() turns an entry's Markdown into a component.
// src/content.config.ts
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
const posts = await getCollection("blog", ({ data }) => !data.draft);
const { Content, headings } = await render(post);
Live collections are a separate mechanism: defineLiveCollection in src/live.config.ts (not
content.config.ts), queried with getLiveCollection() / getLiveEntry(), and only from on-demand
pages.
Full code: examples/content.md
Pattern 7: File-based routing
src/pages/ maps to URLs. [param] is a single dynamic segment, [...rest] matches any depth, and a
leading underscore excludes a file from routing.
src/pages/
├── index.astro → /
├── blog/[id].astro → /blog/:id
├── docs/[...slug].astro → /docs and /docs/*
└── _helpers.ts → not a route
In static mode each dynamic route exports getStaticPaths() returning { params, props } pairs;
paginate() inside it generates numbered pages.
Full code: examples/routing.md
Pattern 8: On-demand rendering and endpoints
export const prerender = false moves a page to request time, where Astro.cookies, Astro.redirect()
and a Response return value all become available. API endpoints are .ts files exporting one handler
per HTTP method, and they default to static like everything else.
export const prerender = false;
export const GET: APIRoute = async ({ url }) => {
const query = url.searchParams.get("q");
if (!query)
return new Response(JSON.stringify({ error: "Missing query" }), {
status: 400,
});
return new Response(JSON.stringify(await searchDatabase(query)));
};
Full code: examples/routing.md
Pattern 9: View Transitions
<ClientRouter /> in the document head enables cross-page transitions. transition:name pairs elements
so they morph, and transition:persist keeps an element alive across a navigation.
<head><ClientRouter /></head>
<img src={post.data.heroImage} transition:name={`hero-${post.id}`} transition:animate="slide" />
<audio controls transition:persist><source src="/music.mp3" type="audio/mp3" /></audio>
Full code: examples/integrations.md
Red flags
Breaks at runtime:
- A dynamic route in static mode with no
getStaticPaths()— the build fails with "getStaticPaths() is required". prerender = falsewith no server adapter installed — the build fails.<ViewTransitions />— removed in Astro 6; import<ClientRouter />fromastro:transitions.Astro.glob()— removed in Astro 6; useimport.meta.glob().import { z } from "astro:content"— removed in Astro 6; importzfromastro/zod, which is Zod 4, soz.string().email()is nowz.email().- Numeric
getStaticPaths()params — Astro 6 requires strings; wrap withString(id). astro.config.cjs— Astro 6 requires ESM (.mjsor.ts).- Functions, class instances or symbols passed as props to a hydrated component — island props must be serializable.
Astro.propsread outside the frontmatter fence, or an.astrocomponent imported into a framework component — neither is available there.- Astro 6 on Node below 22.12.0 — unsupported.
Surprising behaviour:
client:loadon everything defeats the architecture; a component with no interactivity wants no directive at all.client:onlyskips the server render, so nothing is in the HTML for crawlers — preferclient:loadwherever the component can render on the server.- Data fetched in a
<script>tag creates a client waterfall; frontmatter runs server-side and has no such cost. - A collection with no schema loses both build-time validation and generated types.
output: "server"on a mostly-static site gives up the default's caching for pages that did not need it.- Unfiltered
getCollectionreturns drafts alongside published entries. <style>is scoped by default — reach for<style is:global>or the:global()selector deliberately.<script>is bundled and deduped;is:inlineopts out, which is what makes a script re-run on every View Transitions navigation.client:visiblewaits on IntersectionObserver, so a component that is never on screen never hydrates.Astro.redirect()works only on on-demand pages; a static page cannot redirect at request time.- Islands share no state — cross-island communication needs a store or events you bring.
transition:persistneeds the sametransition:nameon both pages, or the element is recreated.- Collection entry ids come from filenames unless frontmatter overrides them.
- Live collections cannot render MDX, and
import.meta.envis inlined at build time — read runtime secrets fromprocess.env. - A
[...slug]route matches its own base path when a params entry passesundefined.