Astro 5’s Content Layer API replaces the old content collections with a unified data system that handles Markdown, JSON, YAML, external APIs, and databases under one interface. View Transitions give SPA-like navigation without the JavaScript overhead. Server Islands render dynamic components at request time while the rest of the page stays static. The output modes — static, server, hybrid — give precise control over what gets prerendered. Claude Code generates Astro content schemas, component patterns, routing configurations, and the deployment adapters for production Astro sites.
CLAUDE.md for Astro Projects
## Astro Stack
- Version: Astro 5.x with TypeScript
- Rendering: hybrid (pages default to static, opt-in server mode)
- Content: Content Layer API (defineCollection with loaders)
- UI: React islands for interactive components (Preact for smaller bundle)
- Styling: Tailwind CSS v4 (new CSS-first config)
- Images: astro:assets with <Image /> component
- Transitions: View Transitions API (client:only animation)
- Deploy: Cloudflare Pages adapter (prefer over Vercel for edge)
- MDX: @astrojs/mdx for component integration in Markdown
Project Structure
src/
├── content/
│ ├── blog/ # Markdown/MDX posts
│ └── authors/ # YAML author profiles
├── pages/
│ ├── index.astro
│ ├── blog/
│ │ ├── index.astro
│ │ └── [slug].astro
│ └── api/
│ └── newsletter.ts # Server endpoint
├── components/
│ ├── BlogCard.astro
│ ├── Newsletter.tsx # React island
│ └── Search.tsx # Interactive island
├── layouts/
│ ├── Base.astro
│ └── BlogPost.astro
├── middleware.ts
└── content.config.ts # Content Layer (Astro 5)
Content Layer Configuration
// src/content.config.ts — Astro 5 Content Layer API
import { defineCollection, z } from 'astro:content'
import { glob, file } from 'astro/loaders'
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: ({ image }) => z.object({
title: z.string().max(100),
description: z.string().max(300),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
author: z.string().default('CS360 Team'),
cover: image().optional(),
coverAlt: z.string().optional(),
tags: z.array(z.string()).default([]),
featured: z.boolean().default(false),
draft: z.boolean().default(false),
readTime: z.string().optional(),
}),
})
const authors = defineCollection({
loader: file('./src/data/authors.json'),
schema: z.object({
id: z.string(),
name: z.string(),
bio: z.string().optional(),
avatar: z.string().url().optional(),
twitter: z.string().optional(),
github: z.string().optional(),
}),
})
// External API loader — fetch from CMS
const products = defineCollection({
loader: async () => {
const resp = await fetch('https://cms.example.com/api/products')
const data = await resp.json()
return data.map((p: any) => ({ id: p.slug, ...p }))
},
schema: z.object({
id: z.string(),
name: z.string(),
price: z.number(),
description: z.string(),
inStock: z.boolean(),
}),
})
export const collections = { blog, authors, products }
Blog Listing Page
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content'
import Base from '@/layouts/Base.astro'
import BlogCard from '@/components/BlogCard.astro'
// Filter drafts in production
const posts = (await getCollection('blog', ({ data }) => {
return import.meta.env.PROD ? !data.draft : true
})).sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
const featured = posts.filter(p => p.data.featured).slice(0, 3)
const recent = posts.filter(p => !p.data.featured).slice(0, 12)
// Group by tag for filtering
const tagMap = posts.reduce((acc, post) => {
post.data.tags.forEach(tag => {
if (!acc[tag]) acc[tag] = []
acc[tag].push(post)
})
return acc
}, {} as Record<string, typeof posts>)
const allTags = Object.keys(tagMap).sort()
---
<Base title="Blog" description="Articles about Claude Code and AI development">
<main class="max-w-6xl mx-auto px-4 py-12">
{featured.length > 0 && (
<section class="mb-16">
<h2 class="text-2xl font-bold mb-6">Featured</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{featured.map(post => (
<BlogCard post={post} variant="featured" />
))}
</div>
</section>
)}
<section>
<div class="flex gap-2 flex-wrap mb-8">
<a href="/blog" class="tag-pill active">All</a>
{allTags.map(tag => (
<a href={`/blog/tag/${tag}`} class="tag-pill">{tag}</a>
))}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{recent.map(post => <BlogCard post={post} />)}
</div>
</section>
</main>
</Base>
Blog Post Page
---
// src/pages/blog/[slug].astro
import { getCollection, getEntry, render } from 'astro:content'
import Base from '@/layouts/Base.astro'
import { Image } from 'astro:assets'
// Generate all static paths
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft)
return posts.map(post => ({
params: { slug: post.id },
props: { post },
}))
}
const { post } = Astro.props
const { Content, headings, remarkPluginFrontmatter } = await render(post)
// Get related posts
const allPosts = await getCollection('blog', ({ data }) => !data.draft)
const related = allPosts
.filter(p => p.id !== post.id && p.data.tags.some(t => post.data.tags.includes(t)))
.slice(0, 3)
---
<Base
title={post.data.title}
description={post.data.description}
image={post.data.cover?.src}
>
<article class="max-w-3xl mx-auto px-4 py-12">
<header class="mb-8">
<div class="flex gap-2 mb-4">
{post.data.tags.map(tag => (
<a href={`/blog/tag/${tag}`} class="tag-pill text-sm">{tag}</a>
))}
</div>
<h1 class="text-4xl font-bold mb-4">{post.data.title}</h1>
<p class="text-xl text-gray-600 mb-6">{post.data.description}</p>
<div class="flex items-center gap-4 text-sm text-gray-500">
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric'
})}
</time>
{post.data.readTime && <span>{post.data.readTime}</span>}
</div>
{post.data.cover && (
<Image
src={post.data.cover}
alt={post.data.coverAlt ?? post.data.title}
class="w-full rounded-xl mt-6"
widths={[400, 800, 1200]}
sizes="(max-width: 768px) 100vw, 800px"
/>
)}
</header>
<div class="prose prose-lg max-w-none">
<Content />
</div>
</article>
</Base>
View Transitions
---
// src/layouts/Base.astro — enable view transitions globally
import { ViewTransitions } from 'astro:transitions'
interface Props {
title: string
description?: string
image?: string
}
const { title, description, image } = Astro.props
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} | Claude Skills 360</title>
{description && <meta name="description" content={description} />}
<!-- Enables smooth page transitions -->
<ViewTransitions />
</head>
<body>
<nav>
<!-- Links navigate with transitions automatically -->
<a href="/">Home</a>
<a href="/blog">Blog</a>
</nav>
<slot />
</body>
</html>
---
// Per-element transition names for coordinated animations
---
<article>
<!-- Named transitions persist element across pages -->
<img
src={post.cover}
alt={post.title}
transition:name={`post-cover-${post.id}`}
transition:animate="initial"
/>
<h2 transition:name={`post-title-${post.id}`}>
{post.title}
</h2>
</article>
Server Islands and Hybrid Rendering
---
// src/pages/dashboard.astro — hybrid: static shell, dynamic island
export const prerender = false // This page serves dynamically
import { getSession } from '@/lib/auth'
const session = await getSession(Astro.request)
if (!session) return Astro.redirect('/login')
---
<Base title="Dashboard">
<h1>Welcome, {session.user.name}</h1>
<!-- Server island: renders at request time, rest is edge-cached -->
<astro:island
component="./components/LiveStats.astro"
server:defer
fallback="loading"
>
<div slot="fallback" class="animate-pulse">Loading stats...</div>
</astro:island>
<!-- Static content — cached at edge -->
<section>
<h2>Quick Links</h2>
<!-- ... -->
</section>
</Base>
Middleware
// src/middleware.ts — request middleware
import { defineMiddleware } from 'astro:middleware'
import { getSession } from '@/lib/auth'
export const onRequest = defineMiddleware(async (context, next) => {
const { pathname } = new URL(context.request.url)
// Auth guard for protected routes
if (pathname.startsWith('/dashboard') || pathname.startsWith('/admin')) {
const session = await getSession(context.request)
if (!session) {
return context.redirect('/login?from=' + encodeURIComponent(pathname))
}
// Add session to locals for downstream use
context.locals.session = session
context.locals.user = session.user
}
// Security headers for all responses
const response = await next()
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'"
)
return response
})
API Endpoints
// src/pages/api/newsletter.ts — server endpoint
import type { APIRoute } from 'astro'
import { z } from 'zod'
const SubscribeSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
})
export const POST: APIRoute = async ({ request }) => {
const body = await request.json()
const parsed = SubscribeSchema.safeParse(body)
if (!parsed.success) {
return new Response(JSON.stringify({ error: 'Invalid input' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
await subscribeToNewsletter(parsed.data)
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
For the React components used as interactive islands within Astro pages, see the React 19 guide for server component patterns that complement Astro’s architecture. For the SvelteKit alternative framework with similar SSR‐first philosophy, the SvelteKit advanced guide covers form actions and load functions. The Claude Skills 360 bundle includes Astro skill sets covering content collections, view transitions, and server islands. Start with the free tier to try Astro site generation.