Astro is a framework that wins by default — server-rendered HTML, zero JavaScript unless you ask for it, fast on any host. Then AI coding assistants get involved, and three weeks later your "fast Astro site" ships 280 KB of React on a marketing page that has one button on it.
Not because the assistant is dumb. Because the assistant doesn't know which Astro patterns are load-bearing and which are footguns. So it picks client:load because it's the simplest hydration directive in the docs. It writes a React component because that's what most of its training data is. It reads frontmatter as any because nobody told it the schema exists.
A CLAUDE.md file in your repo root fixes this. Claude Code reads it on every task, and so does any AI assistant that respects the convention. Below are the 10 rules that have stopped my Astro projects from drifting into "SPA-shaped" by accident.
1. Default to Zero JS, Hydrate by Exception
Astro's superpower is that a .astro component with no client:* directive ships zero JavaScript. UI framework components (.tsx, .vue, .svelte) also render to static HTML unless you explicitly hydrate them.
The rule for Claude: reach for a framework island only when you need genuine client-side interactivity — form state, drag-and-drop, charts that respond to input. Static markup, links, and content rendering belong in .astro files.
---
// 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 a component genuinely needs JS, choose the directive that ships the least and runs the latest. Cheapest to most expensive:
-
client:visible— hydrates when scrolled into view (IntersectionObserver) -
client:idle— hydrates when the browser is idle (requestIdleCallback) -
client:media={"(...)"}— hydrates only when a media query matches -
client:load— hydrates as soon as the page loads (blocks main thread soonest) -
client:only="react"— skips SSR entirely; only when SSR is impossible
<Counter client:visible />
<SearchBox client:idle />
<MobileMenu client:media="(max-width: 768px)" />
<LiveChart client:only="react" />
client:load on a below-the-fold component 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
Every piece of authored content (blog posts, docs, authors, products) lives in src/content/<collection>/ and is described by a Zod schema. 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 the project to output: "static" and opt routes into SSR explicitly with export const prerender = false. Marketing pages, blog posts, and docs are always static. Search results, dashboards, dynamic redirects, and form POST handlers go 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 a static page needs occasional fresh data, prefer ISR via the adapter (Cache-Control: s-maxage=...) over flipping the whole route to SSR.
5. Astro Actions for Forms and Mutations
For form submissions, mutations, and any server-side logic called from the client, use Astro Actions instead of raw src/pages/api/*.ts endpoints. Actions are type-safe end-to-end: Zod-validated input, typed { data, error } result, progressive enhancement out of the box.
// 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>
Every image goes through astro:assets. Imported images are type-checked at build, optimized to AVIF/WebP, served with explicit width/height to prevent CLS, 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 need 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 /> gives you smooth navigation animations without going full SPA. The browser still fetches and swaps full HTML documents — your server still serves 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 keeps a video playing across navigations. transition:name morphs matching elements. The page must still work with transitions disabled — they're an enhancement, not correctness.
8. Scoped Styles by Default, Audit Every is:global
<style> blocks in .astro components are scoped automatically. Astro adds a hash class so styles can't leak. Use <style is:global> only for true globals (resets, design tokens, body typography) and put them in a single Layout.astro.
<style>
/* scoped to this component automatically */
.card { padding: var(--space-4); border-radius: var(--radius-md); }
</style>
<style is:global>
/* applied site-wide — only in Layout.astro */
:root { --color-accent: oklch(60% 0.18 250); }
</style>
Never use :global(.foo) to reach into another component's internals — that's a sign of bad component boundaries. Use CSS custom properties for theming.
9. SEO — Sitemap, OG, Canonical, JSON-LD
Every page sets a unique <title>, meta description, canonical URL, and Open Graph tags via a dedicated <SEO /> component consumed in Layout.astro. Defaults flow from the content schema so a blog post can never ship without them.
---
// src/components/SEO.astro
interface Props { title: string; description: string; image?: string; }
const { title, description, image } = Astro.props;
const canonical = new URL(Astro.url.pathname, Astro.site).toString();
const ogImage = image ? new URL(image, Astro.site).toString() : undefined;
---
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:title" content={title} />
<meta property="og:url" content={canonical} />
{ogImage && <meta property="og:image" content={ogImage} />}
<meta name="twitter:card" content="summary_large_image" />
Install @astrojs/sitemap and @astrojs/rss. JSON-LD structured data goes in the layout — Google reads it, your bundler doesn't ship it as JS.
10. Lighthouse 95+ as a CI Gate
An Astro site that scores below 95 on Lighthouse Performance is a regression. Set explicit budgets:
- Total JS shipped on the homepage: < 50 KB compressed
- LCP < 2.5s on slow 4G
- CLS < 0.05
- INP < 200ms
Run Lighthouse CI on every PR against the preview deploy. The most common regressions: a client:load slipped onto a non-critical component (move to client:visible), an unoptimized hero image (run through <Image />), a third-party analytics script that ships 200 KB (load via partytown or remove it).
astro build prints per-route JS size in its output — anything growing > 10 KB warrants a PR comment.
Why This Matters
The reason Astro is fast isn't magic — it's a set of conscious defaults. AI assistants reach for the patterns they've seen most often, which are the patterns of frameworks that hydrate everything by default. A CLAUDE.md file makes the Astro defaults legible and enforceable, so the assistant can help you ship features without quietly turning your fast site into a slow one.
If this was useful, the full CLAUDE.md Rules Pack covers 40+ stacks (Go, Rust, Python, TypeScript, Next.js, React, Vue 3, Svelte, Angular, Django, FastAPI, Java/Spring, C#/.NET, Postgres, Docker, Kubernetes, Terraform, AWS CDK, GraphQL, Redis, MongoDB, and more) — same enforce-by-CI style, deeper rules per stack.
→ oliviacraftlat.gumroad.com/l/skdgt — $27, lifetime updates.
Top comments (0)