Astro
Project context
This is an Astro content site — primarily static, with islands of interactivity. We use content collections for everything authored as markdown, view transitions for navigation, and ship as little JavaScript as possible.
Stack
- Astro 4+
- TypeScript strict
- Content collections with Zod schemas
- Tailwind CSS 4 via
@astrojs/tailwind - View Transitions (built into Astro)
- MDX via
@astrojs/mdxfor rich content - Optional islands: React, Solid, or Vue (only when interactivity demands it)
- pnpm
Folder structure
src/
content/
config.ts — content collections schemas (Zod)
blog/
post-1.mdx
post-2.mdx
docs/
pages/
index.astro
blog/
index.astro
[...slug].astro
layouts/
BaseLayout.astro
PostLayout.astro
components/
Header.astro
PostCard.astro
InteractiveBit.tsx — only when interactivity is needed
styles/
global.css
assets/ — images / fonts that get optimized
public/ — files served as-is
Content collections
Define every collection's schema in src/content/config.ts using Zod. Astro generates types from this — you get autocompletion in getCollection() and getEntry().
import { defineCollection, z } from 'astro:content'
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
})
export const collections = { blog }
- One schema per collection; never hand-write types
- Use
z.coerce.date()for date fields — frontmatter dates parse as strings - Validate strict — frontmatter typos are easier to catch at build time than at render time
Pages and routing
- File-based routing —
src/pages/<path>.astro - Dynamic routes:
[slug].astrofor one segment,[...slug].astrofor catch-all - Use
getStaticPaths()for pre-rendered dynamic routes
---
import { getCollection } from 'astro:content'
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft)
return posts.map((post) => ({ params: { slug: post.slug }, props: { post } }))
}
const { post } = Astro.props
const { Content } = await post.render()
---
<Layout title={post.data.title}>
<article><Content /></article>
</Layout>
Islands
Astro renders to static HTML by default. Use framework islands sparingly — every island ships JavaScript to the browser.
<Component client:load />— hydrate immediately (rare; reserve for above-the-fold interactive UI)<Component client:idle />— hydrate when the browser is idle (default for non-critical interactive UI)<Component client:visible />— hydrate when scrolled into view (best for below-fold)<Component client:only="react" />— never SSR; client-only (avoid unless necessary)
Default to client:visible. Audit every client:load — most aren't needed.
View Transitions
Add <ViewTransitions /> in your base layout once; Astro handles the rest. Use transition:name on shared elements to morph them between pages.
---
import { ViewTransitions } from 'astro:transitions'
---
<head>
<ViewTransitions />
</head>
Patterns to follow
- Default to
.astrocomponents; reach for React/Solid/Vue only when an interactive island demands it - Optimize images via
<Image />fromastro:assets— never raw<img>for in-content images - Use
Astro.glob()only for non-collection content; prefergetCollection()everywhere else - Configure
output: 'static'(or'hybrid'if you have a few SSR routes);'server'only when you really need it
Patterns to avoid
- Sprinkling
client:loadeverywhere — defeats the entire point of Astro - Raw
<img>tags — use<Image />for optimization - Hand-typed frontmatter — define a Zod schema in
config.ts - Mixing multiple frameworks for islands without need (React + Vue + Svelte all in one project = bigger bundle)
Astro.glob()for content — use collections instead- Storing CSS in
public/— let Astro process it through Vite
Testing
- Vitest for utility-function tests
- Playwright for end-to-end content rendering checks
- Use
astro checkfor type-checking content + components
Tooling
pnpm dev— Astro dev serverpnpm build— produces static HTML todist/pnpm preview— serves the built outputpnpm astro check— type-checks.astrofiles (run in CI)
AI behavioral rules
- Default to
.astrocomponents; only reach for React/Solid/Vue islands when interactivity actually requires it - Use
<Image />fromastro:assetsfor any image; never raw<img> - Define every collection's schema in
src/content/config.ts; never hand-type - Default to
client:visiblefor islands; neverclient:loadwithout a reason - Use
getCollection/getEntryfor content; notAstro.glob - Run
astro checkand any tests before declaring a task done