tly hydrated. Framework islands (.tsx, .vue, .svelte) also render to static HTML by default. AI must only introduce framework components when genuine client-side interactivity is required (form state, drag-and-drop, input-responsive charts). Static markup belongs in .astro.
---
// src/components/Card.astro β pure HTML, zero JS
import type { CollectionEntry } from "astro:content";
interface Props { post: CollectionEntry<"blog"> }
const { post } = Astro.props;
---
<article class="card">
<h2><a href={`/blog/${post.slug}`}>{post.data.title}</a></h2>
<p>{post.data.description}</p>
</article>
A component that ships JS but never re-renders on the client is a bug.
2. Pick the Cheapest Hydration Directive That Works
When hydration is unavoidable, select the directive that minimizes bundle size and defers execution. Priority order (cheapest to most expensive):
client:visible β hydrates on scroll (IntersectionObserver)
client:idle β hydrates on browser idle (requestIdleCallback)
client:media={"(...)"} β hydrates on media query match
client:load β hydrates immediately (blocks main thread)
client:only="react" β skips SSR entirely (SSR impossible only)
<Counter client:visible />
<SearchBox client:idle />
<MobileMenu client:media="(max-width: 768px)" />
<LiveChart client:only="react" />
client:load on below-the-fold components is client:visible in disguise. CI greps for client:load and requires a comment explaining why nothing cheaper works.
3. Content Collections With Typed Zod Schemas
All authored content resides in src/content/<collection>/ and is validated via Zod schemas. In Astro 5, prefer the Content Layer API with a glob loader.
// src/content.config.ts
import { defineCollection, z, reference } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: ({ image }) => z.object({
title: z.string().max(80),
description: z.string().max(160),
pubDate: z.coerce.date(),
cover: image(),
coverAlt: z.string(),
author: reference("authors"),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
getCollection("blog") returns fully typed entries; astro check fails the build when frontmatter drifts. No more as any on post.data.
4. SSR vs Static β Decide Per Route, Not Per Project
Default to output: "static" and opt routes into SSR explicitly via export const prerender = false. Marketing pages, blogs, and docs remain static. Dynamic routes (search, dashboards, POST handlers) use SSR.
---
// src/pages/account/[id].astro β SSR only
export const prerender = false;
const { id } = Astro.params;
const session = Astro.cookies.get("session")?.value;
if (!session) return Astro.redirect("/login");
const user = await getUser(id, session);
if (!user) return new Response(null, { status: 404 });
---
<Layout title={user.name}>...</Layout>
When static pages need occasional fresh data, prefer ISR via adapter caching (Cache-Control: s-maxage=...) over flipping the entire route to SSR.
Use Astro Actions for form submissions, mutations, and client-called server logic instead of raw src/pages/api/*.ts endpoints. Actions provide end-to-end type safety, Zod-validated input, typed { data, error } results, and progressive enhancement.
// src/actions/index.ts
import { defineAction, ActionError } from "astro:actions";
import { z } from "astro:schema";
export const server = {
subscribe: defineAction({
accept: "form",
input: z.object({ email: z.string().email() }),
handler: async ({ email }) => {
const existing = await db.subscribers.findByEmail(email);
if (existing) throw new ActionError({ code: "CONFLICT", message: "Already subscribed" });
const created = await db.subscribers.create({ email });
return { id: created.id };
},
}),
};
The form works without JS β that's the whole point.
6. <Image /> and <Picture />, Never Raw <img>
All images route through astro:assets. Imported images are type-checked at build, optimized to AVIF/WebP, served with explicit width/height to prevent CLS, and lazy-loaded by default.
---
import { Image, Picture } from "astro:assets";
import hero from "~/assets/hero.jpg";
---
<Picture src={hero} formats={["avif", "webp"]}
widths={[400, 800, 1200]}
sizes="(max-width: 768px) 100vw, 800px"
alt="Team working" loading="eager" fetchpriority="high" />
Above-the-fold heroes get loading="eager" and fetchpriority="high". Remote images require domains or remotePatterns in astro.config.mjs. CI greps <img and fails on raw tags outside Markdown rendering.
7. View Transitions Without the SPA Trap
<ViewTransitions /> enables smooth navigation animations without full SPA conversion. The browser fetches and swaps complete HTML documents; the server continues serving real pages.
---
import { ViewTransitions } from "astro:transitions";
---
<head><ViewTransitions /></head>
<body>
<video transition:persist autoplay muted loop src="/bg.mp4"></video>
<h1 transition:name="page-title">{title}</h1>
</body>
<script>
document.addEventListener("astro:page-load", () => {
// re-bind anything that needs to run after each navigation
});
</script>
transition:persist maintains video playback across navigations. transition:name morphs matching elements. Pages must function with transitions disabled β they are enhancements, not correctness requirements.
8. Scoped Styles by Default, Audit Every is:global
<style> blocks in .astro components are scoped automatically via hash classes, preventing style leakage. Use <style is:global> exclusively for true globals (resets, design tokens, body typography).
Pitfall Guide
- Blind
client:load Hydration: Applying client:load to below-the-fold or static components blocks the main thread unnecessarily. Always validate against client:visible or client:idle first.
- Global SSR/Static Flipping: Setting
output: "server" or static project-wide ignores Astro's per-route optimization model. Opt into SSR explicitly with export const prerender = false only where dynamic data is required.
- Untyped Content Drift: Relying on
any for frontmatter bypasses Zod validation, causing runtime crashes and defeating Astro's type safety. Always define explicit schemas in src/content.config.ts.
- Raw
<img> Tag Usage: Bypassing astro:assets eliminates automatic format optimization, CLS prevention, and lazy loading. Enforce <Image />/<Picture /> via CI grep rules.
- SPA-Trap View Transitions: Assuming transitions replace full-page navigation or forgetting
astro:page-load event listeners breaks stateful components. Transitions must degrade gracefully when disabled.
- Leaking
is:global Styles: Overusing global styles in scoped components causes CSS specificity wars and layout bleed. Reserve is:global strictly for resets, tokens, and base typography.
Deliverables
- Blueprint:
CLAUDE.md template containing the 10 architectural rules, hydration priority matrix, and Zod schema enforcement directives. Designed for direct injection into repository roots to constrain AI generation.
- Checklist: Pre-commit/CI validation workflow including:
- Grep for
client:load requiring justification comments
- Grep for raw
<img tags outside Markdown contexts
astro check validation for Zod schema compliance
- Per-route
prerender flag audit
- Configuration Templates: Ready-to-use
astro.config.mjs snippets for adapter setup, image domain whitelisting, remote pattern configuration, and CI hook integration for automated architectural compliance.