cheat sheet
Astro
Astro is a multi-page, content-first web framework with an islands architecture — server-render Markdown, MDX, and component pages to static HTML, then hydrate interactive widgets selectively with React, Vue, Svelte, or Solid.
Astro — Content-First Web Framework
What it is
Astro is a multi-page, content-first web framework that builds your site to static HTML by default and lets you hydrate interactive components selectively — the "islands architecture". You write pages as .astro files (a HTML-plus-frontmatter component format), drop in components from React, Vue, Svelte, Solid, or Preact when you need interactivity, and Astro ships only the JS needed for those islands. This very site is built with Astro. Reach for Astro when you want a Markdown- or MDX-driven content site (docs, blogs, marketing pages) with the speed of static HTML and the ergonomics of components. The main alternatives are Next.js (App Router, more JS by default) and SvelteKit (Svelte-first, more app-like).
Install
Astro requires Node.js 18.20+ or 20.3+ (or Bun / Deno for some workflows). The official scaffold runs through npm create astro@latest (or the equivalent for pnpm/yarn/bun) — it asks a few questions and writes a complete starter.
# Recommended — interactive scaffolder
npm create astro@latest my-site
# Non-interactive with template + options
npm create astro@latest my-site -- --template blog --typescript strict --install --git
# Use pnpm / yarn / bun instead of npm
pnpm create astro@latest my-site
bun create astro@latest my-site
# Add Astro to an existing folder
cd my-existing-project
npm install astro
Output:
astro Launch sequence initiated.
dir Where should we create your new project?
./my-site
tmpl How would you like to start your new project?
A basic, helpful starter project
ts Do you plan to write TypeScript?
Yes
use How strict should TypeScript be?
Strict
deps Install dependencies? (recommended)
Yes
git Initialize a new git repository? (optional)
Yes
Verify the install — every project ships an astro CLI under node_modules/.bin:
npx astro --version
Output:
5.3.0
Syntax
The Astro CLI is invoked as astro <command> (or npx astro …, bun astro …). Most projects only ever use four commands: dev, build, preview, and check.
astro <command> [args] [--flags]
Output: (none — exits 0 on success)
Essential commands
| Command | Purpose |
|---|---|
astro dev | Start the dev server with HMR (default port 4321). |
astro build | Build the production output to dist/. |
astro preview | Serve the built dist/ locally. |
astro check | Type-check .astro, .ts, and component files. |
astro sync | Regenerate .astro/ types from content collections. |
astro add <integration> | Install + wire up an integration (e.g. @astrojs/tailwind). |
astro info | Print Astro version, OS, package manager — useful for bug reports. |
astro telemetry disable | Opt out of anonymous usage telemetry. |
Project layout
A fresh Astro project follows a strict convention — src/pages/ is the only place that becomes routes, src/components/ is shared UI, and src/content/ holds typed content collections. Honour the layout and most things "just work".
my-site/
├── astro.config.mjs — adapter, integrations, site URL, vite tweaks
├── tsconfig.json
├── package.json
├── public/ — static files served as-is (favicon, robots.txt)
├── src/
│ ├── pages/ — every .astro / .md / .mdx here becomes a route
│ │ ├── index.astro → /
│ │ ├── about.astro → /about
│ │ └── blog/
│ │ ├── index.astro → /blog
│ │ └── [slug].astro → /blog/:slug (dynamic)
│ ├── components/ — reusable UI (Astro, React, Vue, Svelte, …)
│ ├── layouts/ — shared <html>+<body> wrappers
│ ├── content/ — typed content collections (Markdown/MDX/YAML/JSON)
│ │ └── config.ts — collection schemas
│ ├── styles/ — global CSS
│ └── env.d.ts
└── dist/ — build output (created by `astro build`)
Output: (none — exits 0 on success)
The .astro component model
.astro is Astro's component format — a frontmatter script block (any JavaScript or TypeScript, runs at build time on the server) followed by an HTML-like template and an optional scoped <style> block. No state, no lifecycle, no hooks — just data on top, markup below.
---
// src/components/Greeting.astro
// Frontmatter runs on the server (Node/Bun/Deno) at build or request time.
import { format } from "date-fns";
interface Props {
name: string;
joinedAt?: Date;
}
const { name, joinedAt = new Date() } = Astro.props;
const since = format(joinedAt, "yyyy-MM-dd");
---
<section class="greeting">
<h2>Hello, {name}!</h2>
<p>Member since <time datetime={since}>{since}</time></p>
</section>
<style>
/* This <style> is automatically scoped to this component */
.greeting {
padding: 1rem;
border: 1px solid var(--accent);
}
</style>
Use it from a page or another component:
---
// src/pages/index.astro
import Greeting from "../components/Greeting.astro";
---
<Greeting name="Alice Dev" joinedAt={new Date("2026-01-15")} />
npm run dev
Output:
astro v5.3.0 ready in 287 ms
┃ Local http://localhost:4321/
┃ Network use --host to expose
Slots — composing components
<slot /> is Astro's content-projection mechanism (the Web Components / Vue convention, not React's children). Default slot, plus optional named slots for header/footer/etc.
---
// src/components/Card.astro
---
<article class="card">
<header><slot name="title" /></header>
<div class="body"><slot /></div>
<footer><slot name="actions" /></footer>
</article>
---
// src/pages/post.astro
import Card from "../components/Card.astro";
---
<Card>
<h2 slot="title">A blog post</h2>
<p>The body of the card lives in the default slot.</p>
<button slot="actions">Like</button>
</Card>
npm run build
Output:
14:02:01 ▶ src/pages/post.astro
14:02:01 └─ /post/index.html (+12ms)
Routing
Routing is file-based. Every .astro, .md, or .mdx under src/pages/ becomes a route at the matching URL. Dynamic segments use [bracket] filenames, and rest segments use [...slug].
Static routes
| File | URL |
|---|---|
src/pages/index.astro | / |
src/pages/about.astro | /about |
src/pages/blog/index.astro | /blog |
src/pages/blog/post-1.md | /blog/post-1 |
Dynamic routes with getStaticPaths
A bracketed filename declares a dynamic segment. In static (SSG) mode, you must export getStaticPaths() listing every value to pre-render. The function runs at build time.
---
// src/pages/blog/[slug].astro
import { getCollection } from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
npm run build
Output:
14:02:01 ▶ src/pages/blog/[slug].astro
14:02:01 └─ /blog/hello-world/index.html (+18ms)
14:02:01 └─ /blog/another-post/index.html (+11ms)
Rest parameters and pagination
[...slug].astro matches any number of path segments. Pair with paginate() for paged listings.
---
// src/pages/blog/[...page].astro
import { getCollection } from "astro:content";
import type { GetStaticPaths } from "astro";
export const getStaticPaths = (async ({ paginate }) => {
const posts = await getCollection("blog");
return paginate(posts, { pageSize: 10 });
}) satisfies GetStaticPaths;
const { page } = Astro.props;
---
<h1>Blog — page {page.currentPage} of {page.lastPage}</h1>
<ul>
{page.data.map((post) => <li>{post.data.title}</li>)}
</ul>
npm run build
Output:
14:02:01 ▶ src/pages/blog/[...page].astro
14:02:01 └─ /blog/index.html (+9ms)
14:02:01 └─ /blog/2/index.html (+8ms)
14:02:01 └─ /blog/3/index.html (+7ms)
Content collections
Content collections are Astro's typed, validated content layer — point a collection at a folder of .md / .mdx (or load from any source), declare a Zod schema for the frontmatter, and Astro generates TypeScript types and runtime validation. Missing or malformed frontmatter fails the build with a precise file/line error.
// src/content/config.ts
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content", // markdown/mdx
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
const authors = defineCollection({
type: "data", // YAML/JSON, no body content
schema: z.object({
name: z.string(),
email: z.string().email(),
bio: z.string().optional(),
}),
});
export const collections = { blog, authors };
A blog post under src/content/blog/:
---
title: "Hello, Astro"
description: "First post — testing content collections"
pubDate: 2026-05-25
tags: [astro, intro]
---
Welcome to Alice Dev's blog!
Query the collection in any .astro file:
---
// src/pages/index.astro
import { getCollection, getEntry } from "astro:content";
const posts = await getCollection("blog", ({ data }) => !data.draft);
posts.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
const alice = await getEntry("authors", "alice-dev");
---
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.slug}`}>{post.data.title}</a>
<small>{post.data.pubDate.toISOString().slice(0, 10)}</small>
</li>
))}
</ul>
npm run dev
Output:
astro v5.3.0 ready in 287 ms
┃ Local http://localhost:4321/
Regenerate the collection types whenever you edit config.ts or the schema:
npx astro sync
Output:
13:42:01 [content] Types generated 14ms
13:42:01 [astro] Watching for changes in src/content
Islands architecture (client:* directives)
Every component is server-rendered to static HTML by default — zero JavaScript ships unless you opt in. To hydrate a single component into a "client island", import a framework component (React, Vue, Svelte, Solid, Preact) and add a client:* directive. Each island ships only its own framework runtime + code, not the whole app.
---
// src/pages/index.astro
import StaticBanner from "../components/StaticBanner.astro"; // 0 KB JS
import LikeButton from "../components/LikeButton.tsx"; // React island
import Search from "../components/Search.svelte"; // Svelte island
---
<StaticBanner />
<LikeButton initial={42} client:load />
<Search client:visible />
<details>
<summary>Comments</summary>
<Comments client:idle />
</details>
npm run build
Output:
14:02:01 ▶ src/pages/index.astro
14:02:01 └─ /index.html (+34ms)
[build] page weight: 18.4 KB (gzip) — 3 islands
client:* directive reference
| Directive | When the component hydrates |
|---|---|
client:load | Immediately on page load (highest priority). |
client:idle | When the browser is idle (requestIdleCallback). |
client:visible | When the component scrolls into view (IntersectionObserver). |
client:visible={{ rootMargin: "200px" }} | Visible, with custom intersection threshold. |
client:media="(max-width: 768px)" | Only on matching media query. |
client:only="react" | Skip server render entirely; render client-side only (rare — for components that require window). |
Adding framework integrations
astro add <integration> installs the package, updates astro.config.mjs, and bumps any peer dependencies. No manual wiring.
astro add react
astro add svelte
astro add tailwind mdx sitemap
Output (astro add react):
✔ Resolving packages...
Astro will run the following command:
If you skip this step, you can always run it yourself later
╭─────────────────────────────────────╮
│ npm install @astrojs/react@^4.0.0 │
│ react@^19.0.0 │
│ react-dom@^19.0.0 │
╰─────────────────────────────────────╯
✔ Continue? … yes
✔ Astro will make the following changes to your config file:
+ import react from "@astrojs/react";
+ export default defineConfig({ integrations: [react()] });
Successfully installed react integration.
astro.config.mjs
The root config file declares the site URL, output mode (static vs server vs hybrid), integrations, the deployment adapter, and Vite overrides. The default config is intentionally minimal.
// astro.config.mjs
import { defineConfig } from "astro/config";
import tailwind from "@astrojs/tailwind";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import cloudflare from "@astrojs/cloudflare";
export default defineConfig({
site: "https://alicedev.example.com",
output: "static", // 'static' | 'server' | 'hybrid'
adapter: cloudflare(), // only needed for 'server' / 'hybrid'
integrations: [
tailwind({ applyBaseStyles: true }),
mdx(),
sitemap({ filter: (url) => !url.includes("/drafts/") }),
],
build: {
inlineStylesheets: "auto",
},
vite: {
optimizeDeps: { include: ["my-fast-dep"] },
},
});
npm run build
Output:
14:02:01 [content] Types generated 14ms
14:02:01 [build] 12 page(s) built in 1.42s
14:02:01 [build] Complete!
Output modes
output | What gets built |
|---|---|
static (default) | Pure SSG — every page pre-rendered to HTML. Deploys to any static host. |
server | Every page rendered on-demand. Requires an adapter (Cloudflare, Vercel, Node, …). |
hybrid | SSG by default; opt individual pages into SSR by exporting prerender = false. |
Environment variables
Astro pipes Vite's env handling: .env, .env.local, .env.<mode> files plus the import.meta.env API. The PUBLIC_ prefix exposes a variable to client JS; anything else is server-only.
# .env
DATABASE_URL=postgres://localhost/myapp
SECRET_KEY=hunter2
# .env.local — git-ignored
PUBLIC_GA_ID=G-XYZ12345
PUBLIC_API_URL=https://api.alicedev.example.com
---
// src/pages/index.astro — server-side, can read any var
const dbUrl = import.meta.env.DATABASE_URL;
const isProd = import.meta.env.PROD; // built-in: true on `astro build`
const isDev = import.meta.env.DEV; // built-in: true on `astro dev`
---
<script>
// Client-side — only PUBLIC_* vars are inlined into the bundle
console.log(import.meta.env.PUBLIC_GA_ID);
</script>
Typed env (astro:env)
Astro 5 added a typed env API with build-time validation — declare each variable's context and access type and you get errors before deploy if a value is missing.
// astro.config.mjs
import { envField } from "astro/config";
export default defineConfig({
env: {
schema: {
DATABASE_URL: envField.string({ context: "server", access: "secret" }),
PUBLIC_GA_ID: envField.string({ context: "client", access: "public" }),
PORT: envField.number({ context: "server", access: "public", default: 4321 }),
},
},
});
// any .astro or .ts file
import { DATABASE_URL, PORT } from "astro:env/server";
import { PUBLIC_GA_ID } from "astro:env/client";
console.log(DATABASE_URL, PORT, PUBLIC_GA_ID);
npm run build
Output (when a required var is missing):
[astro:env]
! DATABASE_URL is required to be set as an environment variable.
! Make sure it is set before running this build.
Deployment adapters
In SSG mode (output: "static"), Astro builds to plain HTML you can host on anything — S3, Netlify, GitHub Pages, Cloudflare Pages. In server or hybrid mode, install the adapter for your target platform and Astro emits the right server entry point.
# Cloudflare Workers / Pages
astro add cloudflare
# Vercel (Edge or Node runtimes)
astro add vercel
# Netlify
astro add netlify
# Standalone Node.js
astro add node
Output (astro add cloudflare):
✔ Astro will make the following changes to your config:
+ import cloudflare from "@astrojs/cloudflare";
+ adapter: cloudflare(),
Successfully installed cloudflare adapter.
This site deploys to Cloudflare Pages via wrangler — see the wrangler page for the matching CLI:
npm run build
npx wrangler pages deploy ./dist --project-name alice-site
Output:
✨ Compiled Worker successfully
✨ Success! Uploaded 142 files (2.04 sec)
✨ Deployment complete! Take a peek over at https://7c3b1a92.alice-site.pages.dev
Server endpoints (API routes)
Files in src/pages/ ending in .ts or .js become API endpoints when they export named HTTP-method functions (GET, POST, etc.). In static mode they pre-render to a JSON file; in server mode they run on each request.
// src/pages/api/hello.ts
import type { APIRoute } from "astro";
export const GET: APIRoute = ({ request }) => {
const name = new URL(request.url).searchParams.get("name") ?? "world";
return Response.json({ greeting: `Hello, ${name}!` });
};
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
return Response.json({ received: body }, { status: 201 });
};
curl http://localhost:4321/api/hello?name=Alice
Output:
{"greeting":"Hello, Alice!"}
Dynamic endpoints for things like RSS:
// src/pages/rss.xml.ts
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
export const GET = async (context) => {
const posts = await getCollection("blog");
return rss({
title: "Alice Dev's Blog",
description: "Notes from a working dev",
site: context.site!,
items: posts.map((p) => ({
title: p.data.title,
pubDate: p.data.pubDate,
link: `/blog/${p.slug}/`,
description: p.data.description,
})),
});
};
npm run build && curl http://localhost:4321/rss.xml | head -8
Output:
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Alice Dev's Blog</title>
<description>Notes from a working dev</description>
<link>https://alicedev.example.com/</link>
<atom:link href="https://alicedev.example.com/rss.xml" rel="self" type="application/rss+xml"/>
<item>
View transitions
Drop <ClientRouter /> into a layout and Astro upgrades navigation between pages to the View Transitions API — smooth fades and shared-element animations without a SPA framework. Falls back to plain navigation in browsers that don't support it.
---
// src/layouts/Base.astro
import { ClientRouter } from "astro:transitions";
---
<html>
<head>
<ClientRouter />
<title>{Astro.props.title}</title>
</head>
<body>
<slot />
</body>
</html>
Persist state across navigations (e.g. an audio player):
<audio transition:persist src="/podcast.mp3" controls />
npm run dev
Output:
astro v5.3.0 ready in 287 ms
┃ Local http://localhost:4321/
Common pitfalls
- Confusing
.astrofrontmatter with React'suseEffect— Astro's---block runs on the server (at build or request time), not in the browser. You can't referencewindow,document, oruseStatethere. Astro.propsis read-only — there's no setState. For state, hand off to a client island (React/Svelte/etc.) withclient:load.- Forgetting
getStaticPaths()on a dynamic route —[slug].astrowithoutgetStaticPathsin static mode is a build error. In server mode, the function is unnecessary. - Importing a Node-only module into a page that ships to the client — Astro will inline parts of your frontmatter into the page. Modules with side effects (DB drivers,
fs) must stay inside the frontmatter, never inside a<script>tag. PUBLIC_prefix isn't optional — non-prefixed env vars are stripped at build time for security.console.log(import.meta.env.SECRET_KEY)in a client<script>becomesconsole.log(undefined).- Mixing islands frameworks without alias setup — if you use both React and Preact, configure
@astrojs/react'sexperimentalReactChildrenor move to one framework. Two JSX runtimes on the same page double the JS payload. astro checkis slow on first run — it type-checks every.astroand.tsfile. Subsequent runs use a cache. Wire it into CI but don't run it on every save in dev.output: "hybrid"defaults flipped in v5 — pages are SSG by default and needexport const prerender = false;to become SSR. In v4, the default was the opposite. Re-check after upgrading.- Content collection schema changes don't auto-reload — after editing
src/content/config.ts, you may need to stop the dev server and runastro syncbefore types catch up.
Real-world recipes
Markdown-driven docs page with custom layout
The same pattern this site (jay's cheat sheets) uses: a content collection of Markdown, a typed schema, and a dynamic route that renders each file with a shared layout.
// src/content/config.ts
import { defineCollection, z } from "astro:content";
const docs = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
section: z.enum(["intro", "guides", "reference"]),
updated: z.date(),
}),
});
export const collections = { docs };
---
// src/pages/docs/[...slug].astro
import { getCollection } from "astro:content";
import DocsLayout from "../../layouts/DocsLayout.astro";
export async function getStaticPaths() {
const pages = await getCollection("docs");
return pages.map((page) => ({
params: { slug: page.slug },
props: { page },
}));
}
const { page } = Astro.props;
const { Content, headings } = await page.render();
---
<DocsLayout title={page.data.title} headings={headings}>
<Content />
</DocsLayout>
npm run build
Output:
14:02:01 ▶ src/pages/docs/[...slug].astro
14:02:01 └─ /docs/intro/index.html (+18ms)
14:02:01 └─ /docs/guides/markdown/index.html (+11ms)
14:02:01 └─ /docs/reference/api/index.html (+9ms)
[build] 3 page(s) built in 1.42s
Tiny React island inside a static page
A like button that's interactive without turning the rest of the page into a SPA. The button ships ~16 KB of React; the surrounding article is pure HTML.
// src/components/LikeButton.tsx
import { useState } from "react";
export default function LikeButton({ initial = 0 }: { initial?: number }) {
const [count, setCount] = useState(initial);
return (
<button onClick={() => setCount(count + 1)}>
❤ {count}
</button>
);
}
---
// src/pages/post.astro
import Layout from "../layouts/Base.astro";
import LikeButton from "../components/LikeButton.tsx";
---
<Layout title="Astro Island Demo">
<article>
<h1>A post</h1>
<p>Most of this page is static HTML…</p>
<LikeButton initial={42} client:visible />
</article>
</Layout>
npm run build
Output:
14:02:01 ▶ src/pages/post.astro
14:02:01 └─ /post/index.html (+22ms)
[build] island weight: 14.8 KB (gzip) — 1 React island
Search with Pagefind (post-build static index)
Pair astro build with Pagefind, which scans dist/ after the build and generates a tiny static search index — no server, no Algolia subscription.
npm install -D pagefind
Output: (none — exits 0 on success)
// package.json
{
"scripts": {
"build": "astro build && pagefind --site dist"
}
}
---
// src/pages/search.astro
---
<input id="q" type="search" placeholder="Search…" />
<div id="results"></div>
<script>
// @ts-expect-error pagefind is built into dist/_pagefind by the build step
const pagefind = await import("/_pagefind/pagefind.js");
await pagefind.init();
document.querySelector("#q")!.addEventListener("input", async (e) => {
const search = await pagefind.search((e.target as HTMLInputElement).value);
const fragments = await Promise.all(search.results.slice(0, 5).map((r) => r.data()));
document.querySelector("#results")!.innerHTML = fragments
.map((f) => `<a href="${f.url}">${f.meta.title}</a>`)
.join("");
});
</script>
npm run build
Output:
14:02:03 [build] Complete!
14:02:03 [pagefind] Indexed 87 pages
14:02:03 [pagefind] Indexed 4253 words
14:02:03 [pagefind] Files written to ./dist/_pagefind
Deploy to Cloudflare Pages with wrangler
Static output, one wrangler command — no GitHub integration required. Pairs with the wrangler page on this site.
npm run build
npx wrangler pages deploy ./dist --project-name alice-site --branch main
Output:
✨ Compiled Worker successfully
✨ Success! Uploaded 142 files (2.04 sec)
✨ Deployment complete! Take a peek over at https://alice-site.pages.dev
Hybrid SSR — most pages static, one server-rendered
Set output: "hybrid" and opt one page into per-request rendering with export const prerender = false;. Useful for personalised pages (auth, user dashboards) that can't be statically built.
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "hybrid",
adapter: node({ mode: "standalone" }),
});
---
// src/pages/profile.astro — server-rendered per request
export const prerender = false;
const userId = Astro.cookies.get("uid")?.value;
const user = userId ? await db.users.get(userId) : null;
---
{user
? <p>Welcome back, {user.name}!</p>
: <a href="/login">Log in</a>
}
npm run build && node ./dist/server/entry.mjs
Output:
14:02:01 [build] 11 page(s) built in 1.42s
14:02:01 [build] 1 page(s) reserved for SSR
Server listening on http://localhost:4321