Imported from nordeim/activity-map-g (
skills/astro-7/SKILL.md). Install upstream withnpx skills add nordeim/activity-map-g --skill astro-7. Copyright stays with the author (Proprietary. LICENSE.txt has complete te).
Astro 5/6/7 — Content-Focused Web Framework (Islands Architecture)
Target: Astro 7.x (current stable, 7.1.6 as of August 2026) on Node.js 22.12.0+ (even versions only — Node 18 and 20 are EOL and unsupported). Astro 7 ships with Vite 8, the Rust compiler (stricter HTML parsing, no auto-correction), and Sätteri as the default Markdown/MDX processor. This skill also covers Astro 5.x and 6.x for migration context — most patterns apply equally across all three.
Corporate context: The Astro Technology Company joined Cloudflare on 2026-01-16. Astro remains MIT-licensed, open-source, and platform-agnostic — adapters for Node, Vercel, Netlify, and Deno Deploy continue to be maintained. Cloudflare's involvement deepened first-party support for Cloudflare Pages/Workers (the Astro 6 Cloudflare adapter runs dev on real
workerd), but the framework is not Cloudflare-locked.Skill-name note: This skill is named
astro-5for cross-reference stability (other skills reference it by this name). It targets the current Astro 7.x stable release with version-aware migration guidance for Astro 5 and 6 projects.Verification convention: All platform claims in this skill cite the Astro docs (
docs.astro.build), Astro blog (astro.build/blog), GitHub source (github.com/withastro/astro), or NVD/advisory entries. Code examples are taggedReasonedper the agent contract §13 — API surface is verified against primary docs, but examples were not executed in this authoring environment.
Astro's distinctive paradigm is zero JavaScript by default — pages render to static HTML at build time, and interactive components ("islands") opt into hydration individually via client: directives. This skill covers the full platform surface: components, routing (including Astro 7 Advanced Routing via src/fetch.ts), content collections (build-time + Live Content Collections), hydration, View Transitions, Server Islands (with ASTRO_KEY for rolling deployments), middleware (with CVE-2025-66202 hardening note), endpoints, sessions, env vars, image/font optimization, i18n, route caching, deployment, AI-agent DX (background dev server + JSON logging), plus the ecosystem (Tailwind 4, Nanostores, testing, security).
When to Use This Skill
Use this skill whenever the user is building, debugging, or extending an Astro application. Trigger phrases include: "Astro", "islands architecture", "content collections", "Content Layer API", "Live Content Collections", "MDX", "client:load", "client:idle", "client:visible", "client:only", "View Transitions", "ClientRouter", "Server Islands", "server:defer", "Sessions API", "Astro Actions", "astro:env", "astro:assets", "astro:i18n", "@astrojs/react", "@astrojs/vue", "@astrojs/svelte", "@astrojs/preact", "astro:content", "astro:middleware", "src/pages", "src/content", "src/middleware.ts", "astro.config.mjs", and any reference to .astro files or the astro:* import namespace.
Do not use this skill for:
- Astro ≤4 — EOL. The Content Layer API is Astro 5+; legacy file-based collections are deprecated. Astro 4 projects should upgrade.
- Astro 5.x in maintenance-only mode — if a project is pinned to 5.x and not upgrading, most of this skill applies, but the Live Content Collections (Astro 6+) and Vite 8 (Astro 7) sections are not relevant.
- Next.js / Nuxt / SvelteKit — these are app frameworks that ship JS by default. Astro is content-first with opt-in JS. Different paradigm. See
vue-3-nuxt,svelte-5-sveltekit, and Next.js skills. - Pure static site generators (Eleventy, Hugo, Jekyll) — Astro has components, hydration, and SSR; static SSGs are simpler but less capable.
- Single-page apps (Vite + React, Vite + Vue) — Astro can do SPA-like interactivity but is optimized for multi-page content sites.
- Astro DB — deprecated (subdependency deprecation warnings as of May 2025). For database persistence, use Turso/libSQL, Drizzle with Postgres, or any external DB.
- Astro Studio — discontinued September 2024; databases deleted March 2025. Do not recommend.
Cross-reference: framework-templates may have an Astro section; this skill goes deep.
Versions & Migration
Confidence: Verified against docs.astro.build/en/upgrade-astro and the release blog posts cited below.
Version timeline
| Version | Released | Highlights | Status (Aug 2026) |
|---|---|---|---|
| Astro 5.0 | 2024-12-03 | Content Layer API (replaces file-based collections), Server Islands (server:defer), astro:env (stable), output: 'hybrid' removed |
Maintenance |
| Astro 5.7 | 2025-04-15 | Sessions API stable | Maintenance |
| Astro 5.10 | Late 2025 | Live Content Collections (experimental) | Maintenance |
| Astro 6.0 | Early 2026 | Live Content Collections stable; breaking changes (see migration guide) | Supported |
| Astro 6.2 | April 2026 | New experimental features (see release notes) | Supported |
| Astro 7.0 | Mid 2026 | Vite 8 support; stable Rust compiler integration | Current stable |
| Astro 7.1.6 | Aug 2026 | Latest patch | Current latest |
Source: docs.astro.build/en/upgrade-astro ("The latest release of Astro is v7.1.6").
Astro 4 → 5 migration
The two breaking changes that catch most projects:
-
Content Layer API replaces file-based collections. Move
src/content/config.tstosrc/content.config.ts(root ofsrc/, not insidecontent/). Replace the legacy collection definition with the newglob/fileloader pattern:// BEFORE (Astro 4): src/content/config.ts const blog = defineCollection({ type: 'content', // ← removed in Astro 5 schema: z.object({ /* ... */ }), }); // AFTER (Astro 5): src/content.config.ts import { glob } from 'astro/loaders'; const blog = defineCollection({ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), schema: z.object({ /* ... */ }), });The
type: 'content'/type: 'data'distinction is gone — the loader determines the type. Querying API changes:render(entry)is now async and imported fromastro:content(it wasentry.render()in Astro 4). -
output: 'hybrid'removed. Useoutput: 'static'(default) withexport const prerender = falseon individual pages that need on-demand rendering. The term "hybrid mode" is no longer used in Astro 5+ docs — it's just "on-demand rendering".
Full migration guide: docs.astro.build/en/guides/upgrade-to/v5.
Astro 5 → 6 migration
Verified: docs.astro.build/en/guides/upgrade-to/v6.
Run the automated upgrader first, then work through what it can't fix automatically:
npx @astrojs/upgrade
| Area | Astro 5 | Astro 6 |
|---|---|---|
| Node.js | 18.20.8+ / 20.3.0+ | 22.12.0+ required — Node 18 & 20 are unsupported |
| Content collections | Content Layer API or legacy file-based | Content Layer API only — legacy collections removed (temporary legacy.collectionsBackwardsCompat escape hatch) |
| Entry identifier | entry.slug (legacy) / entry.id (Content Layer) |
entry.id only — .slug is gone outside the compat flag |
| Rendering markdown body | entry.render() |
Standalone render(entry) imported from astro:content |
| Zod import | z from astro:content or astro:schema |
z from astro/zod (Zod 4 — see the schema gotchas below) |
Astro.glob() |
Available | Removed — use import.meta.glob() or getCollection() |
| Fonts / Sessions / CSP / Live Collections | experimental.fonts / experimental.session / experimental.csp / experimental.liveContentCollections |
Stable top-level config: fonts, session, security.csp; live collections on by default with defineLiveCollection |
| View Transitions component | <ViewTransitions /> (deprecated alias) |
<ClientRouter /> |
import.meta.env.ASSETS_PREFIX |
Supported | Deprecated — use build.assetsPrefix from astro:config/server |
import.meta.env values |
Could coerce types / fall back to process.env |
Always inlined verbatim, never coerced |
Default image fit |
Cropping only applied when fit was set |
Cropping applied by default; images never upscale |
| Vite / Zod / Shiki | Vite 6 / Zod 3 / Shiki 3 | Vite 7 / Zod 4 / Shiki 4 |
| Cloudflare adapter | Astro.locals.runtime |
cloudflare:workers module; astro dev runs on real workerd |
Zod 4 gotchas (Astro 6 upgraded the bundled Zod from v3 to v4):
z.string().email()/.url()etc. are deprecated string-method formats — use the top-level function instead:z.email(),z.url()..min(n, { message: '...' })→.min(n, { error: '...' })(themessageoption was renamederror)..default()after.transform()must match the output type, not the input type:z.string().transform(Number).default(0), not.default("0"). Use.prefault()if you need the old (pre-transform) default behavior.- Always import
zfromastro/zod(notastro:schemaorastro:content— both are deprecated re-exports removed in v6).
Read the official upgrade guide for the complete breaking-changes list.
Astro 6 → 7 migration
Verified: docs.astro.build/en/guides/upgrade-to/v7 and the Astro 7.0 release notes.
| Area | Astro 6 | Astro 7 |
|---|---|---|
| Vite | Vite 7 | Vite 8 |
| Compiler | Go-based compiler (opt-in experimental.rustCompiler) |
Rust compiler stable (stricter HTML parsing, no auto-correction) |
| Markdown processor | @astrojs/markdown-remark (unified) |
Sätteri (Rust-based, default); @astrojs/markdown-remark available for unified compatibility |
| Whitespace handling | Compiler-lenient | JSX-style whitespace (default) |
| Route caching | Experimental | Stable (Astro.cache API) |
| Background dev server | Not available | astro dev --background (auto-enabled when AI agent detected) |
| Logging | Human-friendly console | JSON logging (auto-enabled when AI agent detected) |
| Health check endpoint | Not available | /astro/status on dev server |
| Advanced Routing | Not available | src/fetch.ts reserved entrypoint; astro/fetch module (FetchState, astro(), actions(), middleware(), pages(), i18n(), etc.); fetchFile config to change/disable |
@astrojs/db |
Deprecated in v6.4 | Removed — migrate to Drizzle, Turso/libSQL, or other DB layer |
Rust compiler strictness warnings:
- Unclosed tags (
<div>without</div>) now throw a build error — fix the markup. - Invalid nesting (e.g.,
<p><div></div></p>) no longer auto-corrects — fix the markup. - JSX-style whitespace is now the default — expressions like
<div>{x} {y}</div>may render differently if you relied on the old lenient whitespace handling. - Remark/rehype plugins written for the unified pipeline may not work with Sätteri — set
markdown: { renderShiki: true }or fall back to@astrojs/markdown-remarkif you hit compatibility issues.
Anti-pattern alert: Do not keep an unrelated src/fetch.ts file in an Astro 7 project unless you intend it as the Advanced Routing entrypoint. Astro 7 reserves this filename; an accidental src/fetch.ts will be treated as the request pipeline entrypoint and break your routing. Configure fetchFile in astro.config.mjs to change or disable the reservation.
Cloudflare acquisition FAQ
Confidence: Verified against cloudflare.com press release and blog.cloudflare.com/astro-joins-cloudflare.
- Did Astro become closed-source? No. Astro remains MIT-licensed.
- Do I have to deploy to Cloudflare? No. Astro remains platform-agnostic. Adapters for Node, Vercel, Netlify, and Deno Deploy continue to be maintained.
- Did the Astro team change? The Astro Technology Company team joined Cloudflare; development continues.
- Should I expect Cloudflare-specific features? First-party Cloudflare adapter support has deepened, but the framework's API surface is platform-neutral.
Next.js → Astro (high-level mapping)
| Next.js concept | Astro equivalent |
|---|---|
app/page.tsx |
src/pages/index.astro |
app/blog/[slug]/page.tsx |
src/pages/blog/[slug].astro |
app/layout.tsx |
src/layouts/BaseLayout.astro |
app/api/route.ts |
src/pages/api/route.ts |
| Server Components | Astro components (.astro) — server-only by default |
'use client' directive |
client:load / client:idle / client:visible / client:only |
generateStaticParams |
getStaticPaths |
generateMetadata |
<head> elements in layout |
Middleware (middleware.ts) |
src/middleware.ts |
| Route handlers | API endpoints in src/pages/api/ |
| Server Actions | Astro Actions (experimental) |
next/image |
astro:assets <Image /> / <Picture /> |
next/env |
astro:env |
Quick Start
# Create a new Astro project (defaults to latest stable, currently Astro 7)
npm create astro@latest my-app
# Prompts: template (Empty / Blog / Docs / Portfolio), TypeScript (yes/recommended),
# install deps, init git, VS Code setup
cd my-app
npm install
npm run dev # Dev server at http://localhost:4321
# Add a UI framework integration (you can mix multiple in one project)
npx astro add react # Adds @astrojs/react + React
npx astro add vue # Adds @astrojs/vue + Vue
npx astro add svelte # Adds @astrojs/svelte + Svelte
npx astro add mdx # Adds @astrojs/mdx for .mdx files
npx astro add sitemap # Adds @astrojs/sitemap
# Tailwind 4 — DO NOT use `npx astro add tailwind` (it may install the wrong plugin;
# see github.com/withastro/astro/issues/16542). Instead, install manually:
npm install tailwindcss @tailwindcss/vite
# Then add to astro.config.mjs (see §astro.config.mjs below)
Key commands
npm run dev # Dev server with HMR
npm run build # Production build to dist/
npm run preview # Preview the production build
npm run astro check # TypeScript + Astro template diagnostics
npm run astro check --watch # Watch mode
npx astro add <integration> # Add an integration (auto-configures astro.config.mjs)
npx astro sync # Generate content collection types (auto-runs on dev/build)
npx astro telemetry disable # Opt out of telemetry
Node.js requirement
Verified: docs.astro.build/en/tutorial/1-setup/1 — "Astro supports even-numbered Node.js versions. The current minimum supported version is v22.12.0."
Astro 6+ requires Node.js 22.12.0 or higher (even versions only — v23, v25 are not supported). Node 18 and 20 are unsupported in current Astro. Use node --version to check; use nvm use 22 or a .nvmrc file to pin.
Project Structure (Astro 5/6/7 canonical layout)
my-app/
├── src/
│ ├── pages/ # ← File-based routing (each .astro = a URL)
│ │ ├── index.astro # /
│ │ ├── about.astro # /about
│ │ ├── 404.astro # Custom 404 page
│ │ ├── blog/
│ │ │ ├── index.astro # /blog
│ │ │ └── [slug].astro # /blog/:slug (dynamic route)
│ │ └── api/
│ │ └── health.ts # /api/health (API endpoint)
│ ├── layouts/ # Page layouts (reusable wrappers)
│ │ ├── BaseLayout.astro
│ │ └── BlogLayout.astro
│ ├── components/ # Reusable components (Astro + framework)
│ │ ├── Header.astro
│ │ ├── Footer.astro
│ │ ├── NewsletterForm.tsx # React island
│ │ └── ThemeToggle.vue # Vue island
│ ├── content/ # ← Content source files (Markdown/MDX/data)
│ │ ├── blog/ # Blog posts (.md, .mdx)
│ │ │ ├── hello-world.md
│ │ │ └── second-post.mdx
│ │ └── authors/ # Author entries (.yaml, .json)
│ │ └── alice.yaml
│ ├── assets/ # ← Optimizable assets (images, fonts) — import via astro:assets
│ │ ├── hero.png
│ │ └── logo.svg
│ ├── styles/ # Global styles
│ │ └── global.css
│ ├── lib/ # Utilities
│ │ └── utils.ts
│ ├── middleware.ts # Request middleware
│ ├── content.config.ts # ← Content collection schemas (Astro 5+; was src/content/config.ts)
│ ├── env.d.ts # Type declarations (Astro globals, App.Locals)
│ └── actions/ # ← Astro Actions (experimental)
│ └── index.ts
├── public/ # Static assets served as-is (NOT processed)
│ ├── favicon.svg
│ └── images/
├── astro.config.mjs # ← THE config file
├── tsconfig.json
├── package.json
└── Dockerfile
Key differences from Astro 4:
src/content.config.ts(Astro 5+) replacessrc/content/config.ts. Located at the root ofsrc/, not insidecontent/.src/assets/is the canonical location for images and fonts you want Astro to optimize. Files inpublic/are served as-is and bypass optimization.src/actions/holds Astro Actions (experimental in Astro 5.x–6.x).
astro.config.mjs (canonical config)
Verified: docs.astro.build/en/reference/configuration-reference.
import { defineConfig, envField } from 'astro/config';
import react from '@astrojs/react';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
// For SSR (instead of static SSG)
// import node from '@astrojs/node';
export default defineConfig({
site: 'https://example.com', // Required for sitemap + canonical URLs
// output: 'static' (default) — pages prerender at build time.
// Opt individual pages into on-demand rendering with `export const prerender = false`.
// output: 'server' — all pages render on-demand by default.
// Opt individual pages into static rendering with `export const prerender = true`.
// NOTE: 'hybrid' was removed in Astro 5. Use 'static' + per-page prerender = false.
output: 'static',
// Adapter — required for output: 'server' or for Server Islands (server:defer)
// adapter: node({ mode: 'standalone' }),
integrations: [
react(), // React island support
mdx(), // MDX support
sitemap(), // Auto-generates /sitemap.xml
],
// Tailwind 4 — Vite plugin (preferred over @astrojs/tailwind which is Tailwind 3 only)
vite: {
plugins: [tailwindcss()],
resolve: {
alias: {
'@': '/src',
},
},
},
// i18n routing (built-in since Astro 4, refined in 5/6/7)
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'ja'],
routing: {
prefixDefaultLocale: false, // / for en, /es for es, /ja for ja
},
},
// Image optimization
image: {
domains: ['cdn.example.com'], // Authorized remote image domains
remotePatterns: [{ protocol: 'https', hostname: '**.imgix.net' }],
service: {
entrypoint: 'astro/assets/services/sharp', // default; use 'astro/assets/services/squoosh' (legacy)
},
},
// Prefetch links on hover/viewport/tap (View Transitions integration)
prefetch: {
prefetchAll: false, // Set true to prefetch all links
defaultStrategy: 'hover', // 'hover' | 'viewport' | 'tap' | 'load'
},
// Typed environment variables (astro:env)
env: {
schema: {
DATABASE_URL: envField.string({ context: 'server', access: 'secret' }),
STRIPE_SECRET_KEY: envField.string({ context: 'server', access: 'secret' }),
PUBLIC_STRIPE_PUBLISHABLE_KEY: envField.string({ context: 'client', access: 'public' }),
PUBLIC_SITE_URL: envField.string({ context: 'client', access: 'public', default: 'http://localhost:4321' }),
},
},
// Sessions API (stable since Astro 5.7; requires adapter)
// session: {
// driver: 'file',
// options: { path: './.sessions' },
// cookie: { httpOnly: true, secure: true, sameSite: 'lax' },
// },
// Security: experimental CSRF protection
// security: {
// checkOrigin: true, // Rejects POST/PUT/DELETE/PATCH from other origins
// },
// Experimental flags (refer to docs.astro.build/en/reference/experimental-flags)
// experimental: {
// actions: true, // Astro Actions (still experimental as of Astro 6.2)
// csp: true, // Content Security Policy
// svg: true, // SVG component support
// },
});
Output modes — when to use which
| Mode | Default behavior | Opt-out | Use for |
|---|---|---|---|
'static' (default) |
All pages prerender at build time | export const prerender = false per page |
Mostly-static sites with a few personalized pages (dashboard, profile) |
'server' |
All pages render on-demand | export const prerender = true per page |
Mostly-dynamic sites (apps behind auth, real-time data) |
Critical:
'hybrid'was removed in Astro 5. The hybrid pattern is nowoutput: 'static'(default) + per-pageprerender = false. Do not writeoutput: 'hybrid'— it will fail.
Adapter requirement
Server Islands (server:defer) and output: 'server' require an adapter. Without an adapter, you cannot:
- Render pages on-demand
- Use Server Islands
- Use the Sessions API (sessions need a server runtime)
- Use Astro Actions (experimental)
Available adapters: @astrojs/node, @astrojs/vercel, @astrojs/cloudflare, @astrojs/netlify, @astrojs/deno (verify maintenance status before adopting).
Core Mental Model: Zero JS by Default + Islands Architecture + Multi-Framework
Astro's distinctive paradigm is server-first rendering with opt-in client-side interactivity. Three things differentiate Astro from Next.js / Nuxt / SvelteKit:
1. Zero JavaScript by default (ship HTML, not JS)
---
// src/pages/index.astro
// This "frontmatter" runs on the server (build time for static, request time for SSR)
// NO JavaScript is shipped to the client by default
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
const posts = await getCollection('blog');
const sortedPosts = posts.sort((a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf());
---
<BaseLayout title="Home">
<h1>Latest Posts</h1>
<ul>
{sortedPosts.map((post) => (
<li>
<a href={`/blog/${post.id}`}>{post.data.title}</a>
<time>{post.data.publishedAt.toLocaleDateString()}</time>
</li>
))}
</ul>
</BaseLayout>
This .astro file renders to pure HTML at build time. Zero JavaScript is shipped to the client. The page is instant — no hydration, no React/Vue runtime, no framework overhead. This is why Astro sites score 100/100 on Core Web Vitals by default.
App-framework equivalents (Next.js App Router, Nuxt, SvelteKit) ship JavaScript for hydration even on mostly-static pages. Astro inverts this: JS is opt-in per component, not opt-out per page.
2. Islands architecture (opt-in hydration per component)
When you DO need interactivity, you create an "island" — an isolated interactive component hydrated independently of the rest of the page.
---
// src/pages/index.astro
import NewsletterForm from '../components/NewsletterForm.tsx'; // React component
import ThemeToggle from '../components/ThemeToggle.vue'; // Vue component
---
<html>
<body>
<h1>My Blog</h1>
<!-- Hydration directives: -->
<NewsletterForm client:load /> <!-- Hydrate immediately on page load -->
<ThemeToggle client:idle /> <!-- Hydrate when browser is idle -->
<Comments client:visible /> <!-- Hydrate when scrolled into view -->
<Analytics client:media="(max-width: 50em)" /> <!-- Hydrate only on mobile -->
<Chart client:only="react" /> <!-- Skip SSR, render only on client -->
</body>
</html>
| Directive | When to hydrate | Use for |
|---|---|---|
client:load |
Immediately | Critical interactive elements above the fold (header nav, login button) |
client:idle |
When browser is idle (requestIdleCallback) |
Below-the-fold interactive elements |
client:visible |
When scrolled into view (IntersectionObserver) |
Comments, widgets far down the page |
client:media="(query)" |
When media query matches | Mobile-only or desktop-only widgets |
client:only="react" |
Skip SSR, render only on client | Components that can't render on server (e.g., use window at module level) |
The key insight: each island hydrates independently. A heavy React chart at the bottom of the page doesn't block the header navigation from becoming interactive. This is fundamentally different from React/Vue SPA apps where the entire app hydrates as one unit.
3. Multi-framework (use React + Vue + Svelte in one project)
Astro is renderer-agnostic. You can install multiple framework integrations and use components from each in the same page:
npx astro add react vue svelte preact
---
// src/pages/index.astro — mixing React, Vue, and Svelte in one page
import ReactChart from '../components/ReactChart.tsx'; // React
import VueCounter from '../components/VueCounter.vue'; // Vue
import SvelteSearch from '../components/SvelteSearch.svelte'; // Svelte
import AstroHeader from '../components/AstroHeader.astro'; // Astro (zero JS)
---
<AstroHeader /> <!-- Static HTML, no JS -->
<ReactChart client:visible /> <!-- React island, lazy-hydrated -->
<VueCounter client:load /> <!-- Vue island, immediately hydrated -->
<SvelteSearch client:idle /> <!-- Svelte island, idle-hydrated -->
This is invaluable for:
- Migration: incrementally move a React SPA to Astro — start with Astro shell, port components one by one.
- Best-of-breed: use React for complex stateful widgets, Vue for simple interactions, Astro for static content.
- Team mix: teams proficient in different frameworks can contribute to the same Astro site.
Content Collections (the Content Layer API)
Verified: docs.astro.build/en/guides/content-collections and docs.astro.build/en/reference/content-loader-reference.
Astro 5 introduced the Content Layer API — a more flexible replacement for the legacy file-based content collections. Content collections are type-safe Markdown/MDX with Zod schema validation. Astro 6 stabilized Live Content Collections for content that updates at request time.
Define a collection
// src/content.config.ts (Astro 5+ — replaces src/content/config.ts)
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod'; // Astro 6+: import from 'astro/zod', not 'astro:content'
import { glob } from 'astro/loaders';
const blog = defineCollection({
// Load all .md/.mdx files from src/content/blog/
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
// Zod 4 schema (Astro 6+) — validates frontmatter at build time
schema: z.object({
title: z.string(),
description: z.string(),
publishedAt: z.coerce.date(),
updatedAt: z.coerce.date().optional(),
author: z.string(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
image: z.string().optional(),
}),
});
const authors = defineCollection({
// Can also load from JSON, YAML, or external APIs
loader: glob({ pattern: '**/*.yaml', base: './src/content/authors' }),
schema: z.object({
name: z.string(),
bio: z.string(),
avatar: z.string(),
social: z.object({
twitter: z.string().optional(),
github: z.string().optional(),
}).optional(),
}),
});
export const collections = { blog, authors };
Zod 4 migration note (Astro 6+): Always import
zfromastro/zod, notastro:contentorastro:schema(both deprecated re-exports were removed in v6). Zod 4 changed several APIs:z.string().email()is deprecated — usez.email()instead;.min(n, { message: '...' })is now.min(n, { error: '...' });.default()after.transform()must match the output type (use.prefault()for pre-transform defaults).
Built-in loaders
Verified: docs.astro.build/en/reference/content-loader-reference.
| Loader | Source | Use for |
|---|---|---|
glob({ pattern, base }) |
Local files matching a glob pattern | Markdown/MDX/JSON/YAML in src/content/ |
file({ path }) |
A single local file | Single JSON/YAML data file |
For external sources (CMS, APIs, databases), write a custom loader:
// src/content.config.ts — load from an external API
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
const products = defineCollection({
loader: async () => {
const response = await fetch('https://api.example.com/products');
if (!response.ok) throw new Error(`Failed to fetch products: ${response.status}`);
const data = await response.json();
return data.map((item) => ({
id: item.slug, // 'id' is required — used in URLs and queries
...item,
}));
},
schema: z.object({
id: z.string(),
name: z.string(),
price: z.number(),
description: z.string(),
}),
});
export const collections = { products };
This makes Astro a powerful headless-CMS-friendly framework — fetch from Sanity, Contentful, Shopify, or any API at build time, with full type safety.
Author content
---
# src/content/blog/hello-world.md
title: "Hello World"
description: "My first Astro blog post"
publishedAt: 2025-01-15
author: "alice"
tags: ["astro", "tutorial"]
draft: false
---
# Hello World
This is my first post. The frontmatter above is validated against the Zod schema
at build time — if I misspell `publishedAt` or use a string instead of a date,
the build fails with a clear error.
MDX is also supported — import React components directly:
import Chart from '../../components/Chart.tsx';
<Chart client:visible data={[1, 2, 3]} />
Query content
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
// Get all blog entries (type-safe — entries are typed by the Zod schema)
const posts = await getCollection('blog', ({ data }) => {
return import.meta.env.PROD ? !data.draft : true; // Filter drafts in prod
});
// Sort by date
posts.sort((a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf());
---
<BaseLayout title="Blog">
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.id}`}>{post.data.title}</a>
<time>{post.data.publishedAt.toLocaleDateString()}</time>
<ul>
{post.data.tags.map((tag) => <li>{tag}</li>)}
</ul>
</li>
))}
</ul>
</BaseLayout>
---
// src/pages/blog/[slug].astro — dynamic route
import { getEntry, render } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id }, // URL parameter
props: { post }, // Pass to the page
}));
}
const { post } = Astro.props;
const { Content } = await render(post); // Render Markdown to Astro component
---
<h1>{post.data.title}</h1>
<time>{post.data.publishedAt.toLocaleDateString()}</time>
<Content /> <!-- The Markdown body -->
Astro 4 → 5 change: In Astro 4, rendering was
const { Content } = await post.render(). In Astro 5+, it'sconst { Content } = await render(post)(imported fromastro:content).
Schema references (cross-collection relations)
// src/content.config.ts
import { defineCollection, reference } from 'astro:content';
import { z } from 'astro/zod';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
// Reference an entry in the 'authors' collection — validated at build time
author: reference('authors'),
// Or an array of references
coAuthors: z.array(reference('authors')).default([]),
}),
});
const authors = defineCollection({
loader: glob({ pattern: '**/*.yaml', base: './src/content/authors' }),
schema: z.object({
name: z.string(),
bio: z.string(),
}),
});
export const collections = { blog, authors };
Query a referenced entry:
---
import { getEntry, render } from 'astro:content';
const post = await getEntry('blog', 'hello-world');
const author = await getEntry('authors', post.data.author); // Resolves the reference
---
Live Content Collections (Astro 6+)
Status: Stable since Astro 6.0. Was experimental in Astro 5.10. Verified: docs.astro.build/en/guides/content-collections ("Define your live collections in the special file src/live.config.ts"), astro.build/blog/live-content-collections-deep-dive.
Standard content collections fetch content at build time. For content that changes frequently (e.g., a product catalog that updates every hour, a live dashboard), Live Content Collections re-fetch at request time — using a separate config file and a distinct API from build-time collections.
// src/live.config.ts — separate from src/content.config.ts
import { defineLiveCollection } from 'astro:content';
import { z } from 'astro/zod';
import { storeLoader } from './loaders/store';
const products = defineLiveCollection({
loader: storeLoader({
apiKey: process.env.STORE_API_KEY,
endpoint: 'https://api.mystore.com/v1',
}),
schema: z.object({
id: z.string(),
name: z.string(),
price: z.number(),
}),
});
export const collections = { products };
Live loaders implement the LiveLoader interface — a loadCollection / loadEntry pair — distinct from the build-time Loader interface.
Query a live collection from an on-demand-rendered page:
---
// src/pages/products/[slug].astro
export const prerender = false; // Required — live collections need on-demand rendering
import { getLiveEntry } from 'astro:content';
const { entry: product, error } = await getLiveEntry('products', Astro.params.slug);
if (error) {
console.error('Failed to load product:', error.message);
return Astro.rewrite('/404');
}
---
<h1>{product.data.name}</h1>
Live collections return a { entries, error } / { entry, error } result object instead of throwing — handle the error case explicitly rather than assuming the fetch succeeded.
When to use Live Content Collections:
- Content that changes more often than you deploy (e.g., product inventory, news feed).
- Content from an external API where build-time staleness is unacceptable.
- Personalized content (per-user recommendations) — though Server Islands may be a better fit.
When NOT to use:
- Static content (blog posts, docs) — standard collections are faster (cached at build).
- Content that changes only when you deploy — standard collections suffice.
- When you can solve freshness with a scheduled rebuild (webhook-triggered CI build) instead — that's simpler and faster than paying the runtime cost on every request.
Astro Components (the .astro syntax)
Verified: docs.astro.build/en/basics/astro-components.
---
// src/components/PostCard.astro
// Frontmatter (server-side only — runs at build or request time)
import type { CollectionEntry } from 'astro:content';
interface Props {
post: CollectionEntry<'blog'>;
featured?: boolean;
}
const { post, featured = false } = Astro.props;
const url = `/blog/${post.id}`;
---
<article class:list={['post-card', { featured }]}>
{post.data.image && <img src={post.data.image} alt={post.data.title} loading="lazy" />}
<div class="content">
<h3><a href={url}>{post.data.title}</a></h3>
<p>{post.data.description}</p>
<div class="meta">
<time>{post.data.publishedAt.toLocaleDateString()}</time>
<span>by {post.data.author}</span>
</div>
</div>
</article>
<style>
/* Scoped by default — only applies to this component */
.post-card {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
}
.post-card.featured {
border-color: #f59e0b;
background: #fffbeb;
}
.post-card h3 {
font-size: 1.25rem;
margin: 0 0 0.5rem;
}
.post-card .meta {
font-size: 0.875rem;
color: #6b7280;
margin-top: 0.5rem;
}
</style>
<script>
// Client-side JS (processed by Vite — TypeScript + bundling supported)
// This runs ONLY if the component is rendered on a page
document.querySelectorAll('.post-card').forEach((card) => {
card.addEventListener('click', (e) => {
if (e.target instanceof HTMLAnchorElement) return;
const link = card.querySelector('a');
link?.click();
});
});
</script>
Key .astro features
- Frontmatter (
---blocks): server-only TypeScript, runs at build/request time. Variables declared here are available in the template. Astro.props: typed component props (via TypeScriptinterface Props).class:list: conditional class names (likeclsx). Accepts strings, arrays, objects, and falsy values.<style>: scoped by default. Useis:globalfor global styles,is:inlineto skip Vite processing.<script>: processed by Vite (TypeScript, bundling, HMR in dev). Useis:inlinefor raw HTML scripts.set:html: inject raw HTML (XSS risk — see §Security).set:text: inject text (auto-escaped — the default for{expr}).set:raw: inject without escaping or HTML processing (rare; for edge cases).<Fragment>: group elements without a wrapper DOM node.define:vars: pass server-side variables into a<style>block.
set:html and the directive family
---
const userInput = '<strong>hello</strong><script>alert(1)</script>';
const safeText = 'Plain text with <tags> that should be visible';
---
<!-- Default: text is HTML-escaped -->
<div>{userInput}</div>
<!-- Renders: <strong>hello</strong><script>alert(1)</script> -->
<!-- set:text: same as default (explicit) -->
<div set:text={userInput} />
<!-- set:html: inject raw HTML — XSS RISK -->
<div set:html={userInput} />
<!-- Renders: <strong>hello</strong><script>alert(1)</script> — DANGEROUS -->
<!-- set:raw: skip escaping AND HTML processing (rare) -->
<div set:raw={userInput} />
Security warning:
set:htmlis the explicit opt-out of Astro's auto-escaping. NEVER use it with untrusted input without sanitizing first (e.g., withDOMPurify). See §Security.
define:vars for dynamic styles
---
const themeColor = Astro.locals.user?.preferredColor ?? '#3b82f6';
const heroHeight = 480;
---
<style define:vars={{ themeColor, heroHeight: `${heroHeight}px` }}>
.hero {
background: var(--themeColor);
height: var(--heroHeight);
}
</style>
<div class="hero">...</div>
<Fragment> for conditional grouping
---
const showMeta = true;
---
{showMeta && (
<Fragment>
<time>{post.data.publishedAt.toISOString()}</time>
<span>by {post.data.author}</span>
<span>{post.data.tags.join(', ')}</span>
</Fragment>
)}
<!-- Fragment renders no wrapper DOM node; children appear directly -->
Slot fallback content
---
// src/components/Card.astro
interface Props { title: string }
const { title } = Astro.props;
---
<section class="card">
<h2>{title}</h2>
<slot /> <!-- Default slot — fallback below -->
<slot name="footer">No footer provided</slot> <!-- Named slot with fallback -->
</section>
<Card title="Hello">
<p>Main content</p>
<div slot="footer">Custom footer</div>
</Card>
MDX layout inheritance
---
# src/content/blog/post.mdx
layout: ../../layouts/BlogPostLayout.astro
title: "My Post"
---
import Chart from '../../components/Chart.tsx';
# My Post
Content here. <Chart client:visible data={[1,2,3]} />
The layout frontmatter property tells Astro to wrap the MDX content in the specified layout. The layout receives the MDX frontmatter as Astro.props.frontmatter and the rendered content as a <slot />.
Layouts & Slots
Verified: docs.astro.build/en/basics/layouts.
---
// src/layouts/BaseLayout.astro
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import { ClientRouter } from 'astro:transitions';
interface Props {
title: string;
description?: string;
}
const { title, description = 'Default description' } = Astro.props;
const { pathname } = Astro.url;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="canonical" href={new URL(Astro.url.pathname, Astro.site).href} />
<ClientRouter /> {/* Enable View Transitions globally */}
</head>
<body>
<Header pathname={pathname} />
<main>
<slot /> {/* Page content goes here */}
</main>
<Footer />
</body>
</html>
---
// src/pages/index.astro
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Home" description="Welcome to my site">
<h1>Hello, World!</h1>
{/* This content is slotted into BaseLayout's <slot /> */}
</BaseLayout>
Named slots
---
// src/layouts/BlogPostLayout.astro
import BaseLayout from './BaseLayout.astro';
interface Props { title: string; author: string; date: Date }
const { title, author, date } = Astro.props;
---
<BaseLayout title={title}>
<article>
<header>
<h1>{title}</h1>
<p>by {author} on {date.toLocaleDateString()}</p>
</header>
<slot /> {/* Default slot */}
<footer>
<slot name="footer">No footer provided</slot> {/* Named slot with fallback */}
</footer>
</article>
</BaseLayout>
---
// src/pages/blog/[slug].astro
import BlogPostLayout from '../../layouts/BlogPostLayout.astro';
---
<BlogPostLayout title="Hello" author="Alice" date={new Date()}>
<p>Main content goes here.</p>
<div slot="footer"> {/* Named slot content */}
<p>Share this post: ...</p>
</div>
</BlogPostLayout>
Layout as a function (advanced)
For data-driven layouts, you can pass a function that returns layout props:
---
// src/pages/blog/[slug].astro
import BlogPostLayout from '../../layouts/BlogPostLayout.astro';
import { getEntry } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<BlogPostLayout title={post.data.title} author={post.data.author} date={post.data.publishedAt}>
<Content />
</BlogPostLayout>
Routing
Verified: docs.astro.build/en/guides/routing and docs.astro.build/en/reference/routing-reference.
Astro uses file-based routing. Each .astro file in src/pages/ becomes a URL.
Static routes
src/pages/index.astro → /
src/pages/about.astro → /about
src/pages/blog/index.astro → /blog
src/pages/blog/index.astro → /blog/
Dynamic routes
src/pages/blog/[slug].astro → /blog/:slug
src/pages/[org]/[repo].astro → /:org/:repo
---
// src/pages/blog/[slug].astro
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<h1>{post.data.title}</h1>
<Content />
Rest parameters (catch-all)
src/pages/docs/[...slug].astro → /docs/* (matches /docs, /docs/a, /docs/a/b/c)
---
// src/pages/docs/[...slug].astro
export async function getStaticPaths() {
const docs = await getCollection('docs');
return docs.map((doc) => ({
params: { slug: doc.id.split('/'), // Array for rest params
},
props: { doc },
}));
}
const { doc } = Astro.props;
const { Content } = await render(doc);
---
Rest parameters (required catch-all)
Use [...slug] (with three dots, no leading slash) for catch-all that requires at least one segment. Use [[...slug]] (double brackets) for optional catch-all that also matches the base path.
src/pages/docs/[[...slug]].astro → /docs AND /docs/*
Route priority
When multiple routes match, Astro resolves in this order:
- Static routes (
/aboutbeats/[slug]) - Dynamic routes (
/[slug]beats/[...slug]) - Rest parameters (
/[...slug]is the fallback)
Custom 404 page
src/pages/404.astro → Custom 404 page (served when no route matches)
Astro automatically uses src/pages/404.astro as the 404 page in both dev and production builds. Without it, the server's default 404 is used.
Redirects via config
// astro.config.mjs
export default defineConfig({
redirects: {
'/old-blog': '/blog',
'/old-blog/[slug]': '/blog/[slug]',
'/legacy': { status: 302, destination: 'https://example.com/legacy' },
},
});
For static builds, redirects generate <meta http-equiv="refresh"> HTML pages. For SSR builds, they return proper 3xx responses.
Programmatic redirects
---
// src/pages/old-page.astro
return Astro.redirect('/new-page', 301);
---
// Or in an endpoint:
---
// src/pages/api/redirect.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ redirect }) => {
return redirect('/new-location', 302);
};
---
Astro.url and Astro.params
---
// src/pages/blog/[slug].astro
const { slug } = Astro.params; // URL params
const { pathname, search, hash } = Astro.url; // Full URL parts
const canonical = new URL(Astro.url.pathname, Astro.site).href;
---
Advanced Routing (Astro 7+)
Status: Stable since Astro 7.0. Was experimental (
experimental.advancedRouting) in Astro 6. Verified: docs.astro.build/en/reference/modules/astro-fetch ("The astro/fetch module provides advanced routing handlers built on top of the standard Fetch API. Added in: astro@7.0.0"), github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md ("Advanced routing now uses src/fetch.ts as default entrypoint instead of src/app.ts"). Confidence: Reasoned — API surface verified against primary docs; examples not executed.
Astro 7 introduces Advanced Routing — a programmatic request pipeline that takes over the entire request lifecycle (trailing slash normalization, redirects, Actions, middleware, page rendering, sessions, i18n, cache). Without an entrypoint file, Astro uses its default internal pipeline; with one, you compose the pipeline yourself.
The src/fetch.ts entrypoint
Astro 7 reserves src/fetch.ts as the default Advanced Routing entrypoint. If the file exists, Astro treats its default export as the request handler.
// src/fetch.ts — Astro 7 Advanced Routing entrypoint
import { astro } from 'astro/fetch';
export default astro();
astro() is the all-in-one handler that runs the full Astro pipeline (redirects, trailing-slash, Actions, middleware, page, i18n, sessions, cache). Using it as your default gives you the standard Astro behavior with the option to compose in custom handlers.
The astro/fetch module
Verified: docs.astro.build/en/reference/modules/astro-fetch.
| Export | Purpose |
|---|---|
FetchState |
Tracks the matched route, cookies, session providers, and other per-request data. All handler functions require it as their first argument. |
astro() |
The all-in-one handler — runs the full Astro pipeline. |
actions() |
Run Astro Actions. |
middleware() |
Run middleware (src/middleware.ts). |
pages() |
Render matched pages. |
i18n() |
Run i18n routing. |
sessions() |
Run session handling. |
redirects() |
Process redirects config. |
trailingSlash() |
Apply trailing-slash normalization. |
cache() |
Apply route caching (Astro 7+ stable). |
Compose handlers in order — the pipeline runs left-to-right:
// src/fetch.ts — custom pipeline with route caching
import { astro, cache } from 'astro/fetch';
export default astro(
cache({ driver: myCacheDriver }),
);
Hono-compatible routing via astro/hono
Verified: v6.docs.astro.build/zh-cn/reference/experimental-flags/advanced-routing ("The astro/hono module exports the same handler names as astro/fetch (astro, pages, middleware, actions, sessions, redirects, cache, i18n, trailingSlash)").
Astro 7 also ships astro/hono — a Hono-compatible variant of the same handlers, for projects that want to mix Astro routing with Hono middleware:
// src/fetch.ts — Hono-compatible pipeline
import { astro } from 'astro/hono';
export default astro();
fetchFile config — change or disable the entrypoint
If you have an unrelated src/fetch.ts file (e.g., a utility module) and don't want it treated as the routing entrypoint, configure fetchFile in astro.config.mjs:
// astro.config.mjs
export default defineConfig({
fetchFile: false, // Disable Advanced Routing entirely; use default pipeline
// OR
fetchFile: 'src/my-router.ts', // Use a different filename as the entrypoint
});
Anti-pattern: accidental src/fetch.ts
// src/fetch.ts — DO NOT create this file unless you intend Advanced Routing
// Astro 7 will treat any src/fetch.ts as the request pipeline entrypoint,
// bypassing the default pipeline. If this file exists and exports a default
// function, that function becomes your entire request handler.
export default async (request: Request) => {
return new Response('This replaces all of Astro routing');
};
If you have a utility module named fetch.ts, rename it (e.g., api-fetch.ts) or set fetchFile: false in astro.config.mjs.
When to use Advanced Routing
- Custom cache drivers — plug in a specific cache provider per route.
- Hono middleware integration — reuse existing Hono middleware ecosystem.
- Edge runtime custom logic — pre-process requests before Astro's pipeline runs.
- Multi-tenant routing — dispatch to different Astro apps based on host header.
When NOT to use
- Standard Astro sites — the default pipeline is sufficient.
- Simple per-route middleware — use
src/middleware.tswithsequence()instead. - Custom API endpoints — use
src/pages/api/*.tsfiles instead.
Route Caching (Astro 7+)
Status: Stable since Astro 7.0. Was experimental in Astro 6.0. Verified: docs.astro.build/en/guides/route-caching, gitclear.com astro@7.0.0 release ("Route caching, introduced experimentally in v6.0.0, is now stable"). Confidence: Reasoned — API surface verified; examples not executed.
Astro 7 stabilizes route caching — a platform-agnostic way to cache responses from on-demand-rendered pages, decoupled from any specific CDN or runtime.
---
// src/pages/dashboard.astro
export const prerender = false;
// Cache this page for 60 seconds
const cached = await Astro.cache({
ttl: 60,
tags: ['user', Astro.params.userId],
});
if (cached) {
return new Response(cached.body, { headers: cached.headers });
}
// ... render the page ...
await cached.set(body, headers);
---
Configure a cache driver globally:
// astro.config.mjs
export default defineConfig({
cache: {
driver: 'redis', // or 'memory', 'libsql', or custom
options: { url: process.env.REDIS_URL },
},
});
Cache tags allow targeted invalidation:
// Invalidate all cache entries tagged with 'user:123'
await Astro.cache.invalidate(['user:123']);
Use route caching for expensive on-demand pages (dashboards, computed reports) where staleness is acceptable but the computation cost is high.
AI-Agent DX (Astro 7+)
Status: Stable since Astro 7.0. Verified: docs.astro.build/en/reference/cli-reference, medium.com/@onix_react/whats-new-in-astro-7, javascript.plainenglish.io Astro 7 article.
Astro 7 adds three features specifically for AI coding agent workflows:
Background dev server
astro dev --background # Start dev server as a managed background process
astro dev --logs # View logs from a background dev server
When Astro detects it's running inside an AI coding agent, astro dev automatically starts in background mode. The agent can poll http://localhost:4321/astro/status (a health-check endpoint) to confirm the server is alive.
JSON logging
When AI agent detection triggers, Astro switches to structured JSON logs (instead of human-friendly console output). Parse logs by piping through jq:
astro dev --background 2>&1 | jq 'select(.level == "error")'
AI agent detection
Astro detects AI agent environments via environment variables (AGENT_TOOL, CLAUDE_CODE, CURSOR, etc.). To force-enable:
ASTRO_AI_AGENT=1 astro dev
Use case: agent-driven development loop
# 1. Agent starts the dev server in background
astro dev --background
# 2. Agent polls for health
curl http://localhost:4321/astro/status
# 3. Agent edits files; HMR reloads
# 4. Agent checks for errors via JSON logs
astro dev --logs | jq 'select(.level == "error")'
# 5. Agent stops the server when done
astro dev --stop
This loop enables AI agents to iterate on Astro code without blocking on long-running dev servers or parsing human-formatted logs.
i18n Routing
Status: Built-in since Astro 4; behavior refined in 5, 6, and 7. Verified: docs.astro.build/en/guides/internationalization.
Astro's i18n routing generates locale-prefixed URLs and provides helpers for locale-aware navigation. It does not translate content — you provide translations, and Astro handles the routing.
Configuration
// astro.config.mjs
export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'ja', 'de'],
routing: {
prefixDefaultLocale: false, // / for en, /es for es, /ja for ja
// prefixDefaultLocale: true, // /en for en, /es for es (everyone gets a prefix)
redirectToDefaultLocale: true, // / → /en if prefixDefaultLocale is true
},
fallback: {
es: 'en', // If Spanish translation missing, fall back to English
},
},
});
File structure
With prefixDefaultLocale: false:
src/pages/
├── index.astro → / (English — default locale)
├── about.astro → /about
├── es/
│ ├── index.astro → /es (Spanish)
│ └── about.astro → /es/about
├── ja/
│ ├── index.astro → /ja (Japanese)
│ └── about.astro → /ja/about
astro:i18n helpers
---
// src/components/LanguageSwitcher.astro
import { getRelativeLanguageUrl } from 'astro:i18n';
const currentLocale = Astro.currentLocale; // 'en' | 'es' | 'ja' | undefined
const locales = ['en', 'es', 'ja'];
---
<nav>
{locales.map((locale) => (
<a
href={getRelativeLanguageUrl(locale, Astro.url.pathname)}
aria-current={currentLocale === locale ? 'page' : undefined}
>
{locale.toUpperCase()}
</a>
))}
</nav>
Available helpers
| Function | Description |
|---|---|
getRelativeLanguageUrl(locale, path) |
Returns /es/about for ('es', '/about') |
getAbsoluteLanguageUrl(locale, path) |
Returns https://example.com/es/about (requires site config) |
getPathByLocale(locale) |
Returns the path prefix for a locale (/es for 'es') |
getLocaleByPath(path) |
Returns the locale for a path prefix ('es' for /es) |
Astro.preferredLocale |
Browser-preferred locale(s) from Accept-Language header |
Astro.currentLocale |
The locale of the current page |
Middleware-driven locale detection
For server-rendered pages, use middleware to detect locale from cookies or headers:
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware((context, next) => {
const url = context.url;
if (url.pathname === '/') {
const preferred = context.request.headers.get('accept-language')?.split(',')[0]?.split('-')[0];
const supported = ['en', 'es', 'ja'];
const locale = supported.includes(preferred ?? '') ? preferred : 'en';
if (locale !== 'en') { // Don't redirect if default
return context.redirect(`/
*Truncated - read the full file at https://github.com/nordeim/activity-map-g/blob/73b468c63e0b5921caa38e8db239c36591045c0d/skills/astro-7/SKILL.md.*