Astro Project Scaffolding & Development
Context / Trigger Conditions
- 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
.astro files, astro.config.*, or src/pages/
- User mentions "Astro", "Starlight", or "astro components"
Prerequisites
- Node.js: v18.20.8+, v20.3.0+, or v22.0.0+ (v19/v21 not supported)
- Package manager: npm, pnpm, or yarn
Quick Project Creation
# 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 tailwind
Project Structure (Astro v5)
project-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
Astro Component Anatomy
---
// 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>
Content Collections (Content Layer API - Astro v5)
Configuration: src/content.config.ts
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 };
Querying Collections
---
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);
---
Dynamic Routes from Collections
---
// 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>
Layouts
---
// 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>
Islands Architecture (Client Directives)
---
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" />
Server Islands (Astro v5)
---
import UserGreeting from '../components/UserGreeting.astro';
---
<!-- Defer rendering, show fallback while loading -->
<UserGreeting server:defer>
<div slot="fallback">Loading user data...</div>
</UserGreeting>
Astro Configuration (astro.config.mjs)
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 },
},
});
Starlight Documentation Site
Setup
npm create astro@latest -- --template starlight
Configuration
// astro.config.mjs
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
export default defineConfig({
integ