| name | description | author | version | date |
|---|---|---|---|---|
astro-docs |
Scaffold and develop Astro projects with correct v5 patterns. Use when:
(1) creating new Astro projects (blogs, docs/Starlight, ecommerce, portfolios, landing pages),
(2) scaffolding individual Astro components, pages, layouts, or islands,
(3) setting up content collections with the Content Layer API,
(4) configuring integrations, routing, or deployment,
(5) working with .astro files or astro.config.* detected in the project.
Auto-activates when .astro files or Astro project structure is detected.
For edge cases or unfamiliar APIs, fetch live docs from https://docs.astro.build/en/.
|
Claude Code |
1.1.0 |
2026-01-23 |
- User asks to create a new Astro project or site
- User asks to scaffold components, pages, layouts, or islands
- User asks about Astro-specific patterns (content collections, view transitions, islands, etc.)
- Working directory contains
.astrofiles,astro.config.*, orsrc/pages/ - User mentions "Astro", "Starlight", or "astro components"
- Node.js: v18.20.8+, v20.3.0+, or v22.0.0+ (v19/v21 not supported)
- Package manager: npm, pnpm, or yarn
# Standard project
npm create astro@latest
# With template
npm create astro@latest -- --template <template-name>
# Starlight docs site
npm create astro@latest -- --template starlight
# With integrations
npm create astro@latest -- --add react --add tailwindproject-root/
public/ # Static assets (copied as-is to build output)
robots.txt
favicon.svg
src/
components/ # Reusable .astro or framework components
content/ # (optional) Content collection data
images/ # Images processed by Astro's image optimization
layouts/ # Page layout components
pages/ # File-based routing (each file = a route)
index.astro
about.astro
blog/
[slug].astro # Dynamic route
styles/ # Global CSS/Sass
content.config.ts # Content collection definitions
astro.config.mjs # Astro configuration
tsconfig.json # TypeScript config (extends astro/tsconfigs/base)
package.json
---
// Component Script (server-side only, never sent to browser)
import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';
// Props with TypeScript
interface Props {
title: string;
description?: string;
}
const { title, description = "Default description" } = Astro.props;
// Fetch data, access environment variables, etc.
const data = await fetch('https://api.example.com/data').then(r => r.json());
---
<!-- Component Template (HTML + JS Expressions) -->
<Layout title={title}>
<h1>{title}</h1>
<p>{description}</p>
{/* Conditional rendering */}
{data && <Card title={data.name} />}
{/* List rendering */}
<ul>
{data.items.map(item => <li>{item.name}</li>)}
</ul>
{/* Named slot */}
<slot name="sidebar" />
{/* Default slot */}
<slot />
</Layout>
<style>
/* Scoped by default */
h1 { color: navy; }
</style>
<script>
// Client-side JavaScript (bundled, deduped)
console.log('This runs in the browser');
</script>import { defineCollection, reference } from 'astro:content';
import { glob, file } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
draft: z.boolean().default(false),
author: reference('authors'), // Reference another collection
tags: z.array(z.string()).default([]),
})
});
const authors = defineCollection({
loader: glob({ pattern: "**/*.json", base: "./src/content/authors" }),
schema: z.object({
name: z.string(),
email: z.string().email(),
avatar: z.string().url().optional(),
})
});
// Remote data collection (inline loader)
const products = defineCollection({
loader: async () => {
const response = await fetch("https://api.example.com/products");
const data = await response.json();
return data.map((item: any) => ({ id: item.sku, ...item }));
},
schema: z.object({
name: z.string(),
price: z.number(),
inStock: z.boolean(),
})
});
export const collections = { blog, authors, products };---
import { getCollection, getEntry, render } from 'astro:content';
// Get all entries
const posts = (await getCollection('blog'))
.filter(post => !post.data.draft)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
// Get single entry
const featured = await getEntry('blog', 'my-first-post');
// Render markdown content
const { Content, headings } = await render(featured);
------
// 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);
---
<article>
<h1>{post.data.title}</h1>
<time>{post.data.pubDate.toLocaleDateString()}</time>
<Content />
</article>---
// src/layouts/BaseLayout.astro
interface Props {
title: string;
description?: string;
}
const { title, description } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<title>{title}</title>
<slot name="head" />
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/blog">Blog</a>
</nav>
</header>
<main>
<slot />
</main>
<footer>
<slot name="footer">
<p>© {new Date().getFullYear()}</p>
</slot>
</footer>
</body>
</html>---
import ReactCounter from '../components/Counter.jsx';
import VueWidget from '../components/Widget.vue';
import SvelteToggle from '../components/Toggle.svelte';
---
<!-- No JS sent to client (static HTML only) -->
<ReactCounter />
<!-- Hydrate on page load -->
<ReactCounter client:load />
<!-- Hydrate when visible in viewport -->
<VueWidget client:visible />
<!-- Hydrate when idle -->
<SvelteToggle client:idle />
<!-- Hydrate on specific media query -->
<ReactCounter client:media="(max-width: 768px)" />
<!-- Only render on client (skip SSR) -->
<ReactCounter client:only="react" />---
import UserGreeting from '../components/UserGreeting.astro';
---
<!-- Defer rendering, show fallback while loading -->
<UserGreeting server:defer>
<div slot="fallback">Loading user data...</div>
</UserGreeting>import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import vercel from '@astrojs/vercel';
export default defineConfig({
site: 'https://example.com',
base: '/docs', // If deployed to subpath
trailingSlash: 'always',
integrations: [
react(),
tailwind(),
mdx(),
sitemap(),
],
// SSR adapter (for on-demand rendering)
adapter: vercel(),
output: 'server', // 'static' (default) | 'server' | 'hybrid'
// Vite config passthrough
vite: {
css: { preprocessorOptions: { scss: { api: 'modern-compiler' } } },
},
// Image optimization
image: {
domains: ['cdn.example.com'],
},
// i18n routing
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
routing: { prefixDefaultLocale: false },
},
});npm create astro@latest -- --template starlight// astro.config.mjs
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
export default defineConfig({
integrations: [
starlight({
title: 'My Docs',
social: [
{ icon: 'github', label: 'GitHub', href: 'https://github.com/...' },
],
sidebar: [
{ label: 'Guides', items: [
{ label: 'Getting Started', slug: 'guides/getting-started' },
{ label: 'Configuration', slug: 'guides/configuration' },
]},
{ label: 'Reference', autogenerate: { directory: 'reference' } },
],
defaultLocale: 'en',
locales: { en: { label: 'English' }, es: { label: 'Español' } },
}),
],
});src/content/docs/
index.mdx # Home page
guides/
getting-started.md
configuration.md
reference/
api.md
cli.md
---
title: Getting Started
description: Learn how to get started with our project.
sidebar:
order: 1
badge:
text: New
variant: tip
---linkanditemsare mutually exclusive: A sidebar group cannot have both alinkproperty AND anitemsarray — this causes a build error. To make a category clickable, create anindex.mdinside the directory and include it as the first item:
sidebar: [
{
label: 'Guides',
// WRONG: Cannot combine link + items
// link: '/guides/',
// items: [...]
// CORRECT: Use index page as first item
items: [
{ label: 'Overview', slug: 'guides' }, // points to guides/index.md
{ label: 'Getting Started', slug: 'guides/getting-started' },
],
},
]-
autogeneratedoesn't create index pages: If you useautogenerate: { directory: 'guides' }, it will pick up files in that directory but won't generate an index/overview page. Create one manually. -
Mixing
autogeneratewith manual items: You can combine both in a group usingitemsarray with some manual entries and anautogenerateentry, but ordering may be unexpected. Usesidebar.orderin page frontmatter to control order within autogenerated groups.
---
title: My Page
---
import { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';
{/* Card = display-only, no link capability */}
<Card title="Feature One">
Description of this feature goes here as children.
</Card>
{/* LinkCard = clickable, takes href + description as PROPS (not children) */}
<LinkCard
title="Getting Started"
description="Learn how to set up your project"
href="/guides/getting-started/"
/>
{/* CardGrid for side-by-side layout */}
<CardGrid>
<LinkCard title="Guide A" href="/a/" description="First guide" />
<LinkCard title="Guide B" href="/b/" description="Second guide" />
</CardGrid>Key difference: Card does NOT support href — use LinkCard when you need clickable cards. LinkCard takes description as a prop, not as children content.
starlight({
// Custom CSS
customCss: ['./src/styles/custom.css'],
// Component overrides
components: {
PageTitle: './src/components/PageTitle.astro',
Footer: './src/components/Footer.astro',
},
// Custom head elements
head: [
{ tag: 'script', attrs: { src: '/analytics.js', defer: true } },
{ tag: 'meta', attrs: { name: 'theme-color', content: '#00c9db' } },
],
})Starlight's CSS custom properties use semantic names that invert between light and dark modes. This is the most common source of theming bugs — the variable names do NOT correspond to literal colors.
| Variable | Meaning | Light Mode Value | Dark Mode Value |
|---|---|---|---|
--sl-color-white |
Foreground/text | Dark color | Light color |
--sl-color-black |
Background | White/light | Dark/black |
--sl-color-gray-1 |
Darkest text gray | Very dark | Very light |
--sl-color-gray-6 |
Lightest bg gray | Very light | Very dark |
The naming is inverted: "white" means foreground (the thing you read), "black" means background. The gray scale (1-6) runs from text-weight to background-weight, regardless of actual lightness.
Starlight wraps all its styles in @layer starlight.base. Unlayered custom CSS always takes precedence over layered CSS. This means:
- Your custom CSS will override Starlight's defaults automatically
- You MUST define values for BOTH modes explicitly, or your dark-mode values will bleed into light mode
/* src/styles/custom.css */
/* :root = dark mode defaults (Starlight's default is dark) */
:root {
--sl-color-white: #ffffff;
--sl-color-black: #1a1a2e;
--sl-color-gray-1: #e0e0e0;
--sl-color-gray-2: #c0c0c0;
--sl-color-gray-3: #a0a0a0;
--sl-color-gray-4: #606060;
--sl-color-gray-5: #383838;
--sl-color-gray-6: #272730;
--sl-color-accent-low: #1a1a4e;
--sl-color-accent: #6c63ff;
--sl-color-accent-high: #c4c1ff;
}
/* MUST define light mode separately — omitting this causes dark values to override
Starlight's semantic inversions, resulting in unreadable text */
:root[data-theme='light'] {
--sl-color-white: #1a1a2e;
--sl-color-black: #ffffff;
--sl-color-gray-1: #2a2a3e;
--sl-color-gray-2: #444466;
--sl-color-gray-3: #666688;
--sl-color-gray-4: #9999aa;
--sl-color-gray-5: #ccccdd;
--sl-color-gray-6: #f0f0f5;
--sl-color-accent-low: #e8e7ff;
--sl-color-accent: #5046e5;
--sl-color-accent-high: #2d2691;
}The accent color has three variants whose "direction" flips between modes:
| Variable | Purpose | Dark Mode | Light Mode |
|---|---|---|---|
--sl-color-accent-low |
Background tint (subtle highlight) | Dark shade | Light tint |
--sl-color-accent |
The accent color itself | Bright/vivid | Medium |
--sl-color-accent-high |
Text-safe variant (readable on bg) | Light/pastel | Dark/saturated |
/* What controls what: */
--sl-color-text /* Derived from --sl-color-gray-2 */
--sl-color-bg /* = --sl-color-black (the "background" semantic) */
--sl-color-bg-nav /* Top navigation bar background */
--sl-color-bg-sidebar /* Sidebar background */
--sl-color-hairline-light /* Border/divider color */
/* Active nav link text uses --sl-color-white (the "foreground" semantic) */
/* Sidebar active link background uses --sl-color-accent-low */- Only defining
:root(dark mode): Light mode will inherit your dark-mode values, causing white text on white background - Using literal color names mentally:
--sl-color-white: #1a1a2eis correct for light mode (foreground = dark text) - Forgetting the layer: Your unlayered CSS always wins over Starlight's
@layer starlight.base, so partial definitions break things - Not testing both modes: Always verify the theme toggle works after CSS changes
For organizations needing consistent theming across multiple Starlight sites:
@org/astro-theme/
src/
styles/
theme.css # CSS custom properties for both modes
preset.js # Config helper function
package.json
// Merges theme CSS and head elements with user's Starlight config
export function starlightPreset(userConfig = {}) {
return {
...userConfig,
customCss: [
'@org/astro-theme/styles/theme.css',
...(userConfig.customCss || []),
],
head: [
...(userConfig.head || []),
// Add org-wide head elements here (fonts, analytics, etc.)
],
};
}{
"name": "@org/astro-theme",
"version": "1.0.0",
"type": "module",
"exports": {
"./styles/theme.css": "./src/styles/theme.css",
"./preset": "./src/preset.js"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"files": ["src/"]
}- Add
.npmrcto consuming project:
@org:registry=https://npm.pkg.github.com
- Use in
astro.config.mjs:
import { starlightPreset } from '@org/astro-theme/preset';
export default defineConfig({
integrations: [
starlight(starlightPreset({
title: 'My Docs',
sidebar: [/* ... */],
})),
],
});---
import { ViewTransitions } from 'astro:transitions';
---
<head>
<ViewTransitions />
</head>
<!-- Named transition -->
<h1 transition:name="title">{title}</h1>
<!-- Transition animation -->
<div transition:animate="slide">Content</div>import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
// Run before each page render
const { locals, request, redirect } = context;
// Set data available in all pages via Astro.locals
locals.user = await getUser(request);
if (!locals.user && context.url.pathname.startsWith('/dashboard')) {
return redirect('/login');
}
return next();
});import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
newsletter: defineAction({
accept: 'form',
input: z.object({
email: z.string().email(),
}),
handler: async ({ email }) => {
await addToNewsletter(email);
return { success: true };
},
}),
};import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ params, request }) => {
return new Response(JSON.stringify({ message: 'Hello' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
return new Response(JSON.stringify({ received: body }), { status: 200 });
};// astro.config.mjs - enable sessions
export default defineConfig({
session: {
driver: 'fs', // or 'redis', 'netlify-blobs', etc.
},
});---
// In any server-rendered page/endpoint
const session = await Astro.session;
const visits = (await session.get('visits') ?? 0) + 1;
await session.set('visits', visits);
------
import { Image } from 'astro:assets';
import heroImage from '../images/hero.jpg';
---
<!-- Optimized image with automatic format conversion -->
<Image src={heroImage} alt="Hero" width={800} />
<!-- Remote image (must configure domains in astro.config) -->
<Image src="https://cdn.example.com/photo.jpg" alt="Remote" width={400} height={300} />Key files to create:
astro.config.mjs- with sitemap, mdx integrationssrc/content.config.ts- blog collection with glob loadersrc/layouts/BaseLayout.astro- HTML shellsrc/layouts/BlogPostLayout.astro- post layout with metadatasrc/pages/index.astro- home with recent postssrc/pages/blog/index.astro- all posts listingsrc/pages/blog/[...slug].astro- dynamic post pagessrc/content/blog/- markdown posts directory
Key files to create:
astro.config.mjs- with react (for cart), tailwindsrc/content.config.ts- products collection (remote loader from Stripe/Shopify)src/layouts/ShopLayout.astro- with cart sidebarsrc/pages/products/index.astro- product gridsrc/pages/products/[id].astro- product detailsrc/components/CartButton.jsx- client:load for interactivitysrc/pages/api/checkout.ts- payment endpoint
Key files to create:
astro.config.mjs- with image optimization configsrc/content.config.ts- projects collectionsrc/layouts/BaseLayout.astro- with ViewTransitionssrc/pages/index.astro- hero + featured projectssrc/pages/projects/[...slug].astro- project detailssrc/components/ProjectCard.astro- with transition:name
# .github/workflows/deploy.yml
name: Deploy to Azure Static Web Apps
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://npm.pkg.github.com'
scope: '@your-org'
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN }}
- run: npm run build
- uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
action: 'upload'
app_location: '/'
output_location: 'dist'
skip_app_build: true # Already built aboveKey points:
skip_app_build: true— use when you've already runnpm run build(needed for private registry auth)registry-url+scope+NODE_AUTH_TOKEN— required for GitHub Packages private dependenciesoutput_location: 'dist'— Astro's default build output directory
{
"navigationFallback": {
"rewrite": "/index.html"
},
"globalHeaders": {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff"
}
}For edge cases, unfamiliar APIs, or the latest updates not covered above, fetch the relevant documentation page:
- Base URL:
https://docs.astro.build/en/ - Key sections:
/install-and-setup/- Installation/basics/project-structure/- Project structure/basics/astro-components/- Components/basics/astro-pages/- Pages/basics/layouts/- Layouts/guides/content-collections/- Content collections/guides/routing/- Routing/guides/styling/- CSS/styling/guides/view-transitions/- View transitions/guides/server-islands/- Server islands/guides/integrations-guide/- Integrations/guides/deploy/- Deployment guides/guides/actions/- Actions/guides/middleware/- Middleware/guides/endpoints/- API endpoints/guides/sessions/- Sessions/reference/configuration-reference/- Config reference/reference/api-reference/- API reference/reference/directives-reference/- Directives (client:, etc.)
- Starlight:
https://starlight.astro.build//guides/css-and-tailwind/- Custom CSS and Tailwind/guides/customization/- Component overrides/reference/configuration/- Full config reference/guides/sidebar/- Sidebar configuration
Use the context7 MCP tool or firecrawl to fetch current docs when needed. Prefer context7/query-docs with library ID astro for quick lookups.
- Astro v5 uses Content Layer API —
src/content.config.tswithglob()/file()loaders replaces the legacysrc/content/directory convention - The
render()function is now imported from'astro:content'(not a method on the entry) getStaticPaths()usespost.id(notpost.slug) for route params- All component script code runs on the server only — never reaches the browser
<style>tags in.astrofiles are scoped by default; useis:globalfor global styles- Use
client:*directives only on framework components (React, Vue, Svelte, etc.) - TypeScript is supported natively; extend
astro/tsconfigs/strictfor best DX - Images in
src/are optimized; images inpublic/are served as-is
- Starlight's color system uses semantic names —
--sl-color-white= foreground/text, NOT literal white.--sl-color-black= background, NOT literal black - Custom theme CSS MUST define both
:root(dark mode) and:root[data-theme='light']explicitly — omitting light mode causes dark-mode values to bleed through because unlayered CSS beats Starlight's@layer starlight.base - Accent triplet direction flips —
accent-lowis dark in dark mode, light in light mode;accent-highis the opposite
- Sidebar groups:
linkanditemsare mutually exclusive — use an index.md as the first item instead of a group-level link - Use
LinkCard(notCard) for clickable cards —Cardhas nohrefprop;LinkCardtakesdescriptionas a prop, not children autogeneratedoes not create index pages — you must manually create overview/index files
- Social config format changed: Starlight v0.31 uses object format
{ github: 'url' }, v0.37+ uses array format[{ icon: 'github', label: 'GitHub', href: 'url' }] - Upgrade command:
npx @astrojs/upgrade— updates Astro and all official integrations together
- GitHub Pages base path: When deploying to a subpath (e.g.,
username.github.io/repo-name), set bothsiteandbaseinastro.config.mjs. Starlight sidebarslugreferences are handled correctly, but hero actionlinkvalues in MDX frontmatter do NOT getbaseprepended automatically — you must include the full path (e.g.,/repo-name/getting-started/) in frontmatter links - Azure Static Web Apps: Use
skip_app_build: truewhen you need private registry auth duringnpm ci— build in a separate step whereNODE_AUTH_TOKENis available