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.

bash
# 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:

text
 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:

bash
npx astro --version

Output:

text
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.

bash
astro <command> [args] [--flags]

Output: (none — exits 0 on success)

Essential commands

CommandPurpose
astro devStart the dev server with HMR (default port 4321).
astro buildBuild the production output to dist/.
astro previewServe the built dist/ locally.
astro checkType-check .astro, .ts, and component files.
astro syncRegenerate .astro/ types from content collections.
astro add <integration>Install + wire up an integration (e.g. @astrojs/tailwind).
astro infoPrint Astro version, OS, package manager — useful for bug reports.
astro telemetry disableOpt 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".

text
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.

astro
---
// 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:

astro
---
// src/pages/index.astro
import Greeting from "../components/Greeting.astro";
---

<Greeting name="Alice Dev" joinedAt={new Date("2026-01-15")} />
bash
npm run dev

Output:

text
 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.

astro
---
// src/components/Card.astro
---

<article class="card">
  <header><slot name="title" /></header>
  <div class="body"><slot /></div>
  <footer><slot name="actions" /></footer>
</article>
astro
---
// 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>
bash
npm run build

Output:

text
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

FileURL
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.

astro
---
// 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>
bash
npm run build

Output:

text
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.

astro
---
// 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>
bash
npm run build

Output:

text
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.

typescript
// 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/:

markdown
---
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:

astro
---
// 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>
bash
npm run dev

Output:

text
 astro  v5.3.0 ready in 287 ms

  ┃ Local    http://localhost:4321/

Regenerate the collection types whenever you edit config.ts or the schema:

bash
npx astro sync

Output:

text
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.

astro
---
// 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>
bash
npm run build

Output:

text
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

DirectiveWhen the component hydrates
client:loadImmediately on page load (highest priority).
client:idleWhen the browser is idle (requestIdleCallback).
client:visibleWhen 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.

bash
astro add react
astro add svelte
astro add tailwind mdx sitemap

Output (astro add react):

text
✔ 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.

javascript
// 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"] },
  },
});
bash
npm run build

Output:

text
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

outputWhat gets built
static (default)Pure SSG — every page pre-rendered to HTML. Deploys to any static host.
serverEvery page rendered on-demand. Requires an adapter (Cloudflare, Vercel, Node, …).
hybridSSG 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.

text
# .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
astro
---
// 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.

javascript
// 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 }),
    },
  },
});
typescript
// 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);
bash
npm run build

Output (when a required var is missing):

text
[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.

bash
# 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):

text
✔ 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:

bash
npm run build
npx wrangler pages deploy ./dist --project-name alice-site

Output:

text
✨ 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.

typescript
// 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 });
};
bash
curl http://localhost:4321/api/hello?name=Alice

Output:

text
{"greeting":"Hello, Alice!"}

Dynamic endpoints for things like RSS:

typescript
// 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,
    })),
  });
};
bash
npm run build && curl http://localhost:4321/rss.xml | head -8

Output:

text
<?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.

astro
---
// 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):

astro
<audio transition:persist src="/podcast.mp3" controls />
bash
npm run dev

Output:

text
 astro  v5.3.0 ready in 287 ms

  ┃ Local    http://localhost:4321/

Common pitfalls

  1. Confusing .astro frontmatter with React's useEffect — Astro's --- block runs on the server (at build or request time), not in the browser. You can't reference window, document, or useState there.
  2. Astro.props is read-only — there's no setState. For state, hand off to a client island (React/Svelte/etc.) with client:load.
  3. Forgetting getStaticPaths() on a dynamic route[slug].astro without getStaticPaths in static mode is a build error. In server mode, the function is unnecessary.
  4. 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.
  5. 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> becomes console.log(undefined).
  6. Mixing islands frameworks without alias setup — if you use both React and Preact, configure @astrojs/react's experimentalReactChildren or move to one framework. Two JSX runtimes on the same page double the JS payload.
  7. astro check is slow on first run — it type-checks every .astro and .ts file. Subsequent runs use a cache. Wire it into CI but don't run it on every save in dev.
  8. output: "hybrid" defaults flipped in v5 — pages are SSG by default and need export const prerender = false; to become SSR. In v4, the default was the opposite. Re-check after upgrading.
  9. Content collection schema changes don't auto-reload — after editing src/content/config.ts, you may need to stop the dev server and run astro sync before 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.

typescript
// 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 };
astro
---
// 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>
bash
npm run build

Output:

text
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.

tsx
// 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>
  );
}
astro
---
// 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>
bash
npm run build

Output:

text
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.

bash
npm install -D pagefind

Output: (none — exits 0 on success)

json
// package.json
{
  "scripts": {
    "build": "astro build && pagefind --site dist"
  }
}
astro
---
// 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>
bash
npm run build

Output:

text
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.

bash
npm run build
npx wrangler pages deploy ./dist --project-name alice-site --branch main

Output:

text
✨ 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.

javascript
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  output: "hybrid",
  adapter: node({ mode: "standalone" }),
});
astro
---
// 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>
}
bash
npm run build && node ./dist/server/entry.mjs

Output:

text
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