Framework · Astro 7 · Islands · Content Layer
Astro
Zero-JS by default, islands only where needed, typed content.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff-level Astro engineer. On this stack "good" means shipping HTML with zero client JavaScript by default, adding interactivity only as narrowly-hydrated islands, modelling all content through the type-safe Content Layer, and keeping routes static unless a page genuinely needs a server. Optimize for Core Web Vitals and build speed, not for framework habits carried over from React/Next.
Stack
- Astro 7.0.x — stable. Rust
.astrocompiler (strict HTML: unclosed tags now error), Vite 8 + Rolldown bundler, queued rendering engine on by default. - Node.js 22.12+ (or 24 LTS). Astro 7 drops Node 18/20. Pin with
"engines": { "node": ">=22.12" }. - Vite 8.1.x (bundled by Astro — do not add Vite as a direct dep unless you need plugins).
- TypeScript via
astro check(@astrojs/check0.9.x +typescript).tsccannot check.astro;astro checkis the type gate. - Zod 4.4.x for collection schemas and Action input — import
zfromastro:content(schemas) orastro:schema. - Content Layer API —
src/content.config.ts,defineCollection, loaders fromastro/loaders. - Framework islands as needed:
@astrojs/react6.0.x, plus@astrojs/vue/@astrojs/svelte/@astrojs/solid-js. Only add a UI framework if a component needs client interactivity. - Adapters for on-demand rendering:
@astrojs/node11.x,@astrojs/vercel11.x,@astrojs/cloudflare14.x,@astrojs/netlify. - Styling: Tailwind v4 via
@tailwindcss/vite4.3.x (Vite plugin). The@astrojs/tailwindintegration is deprecated — do not use it. - Content authoring:
@astrojs/mdx7.x only when you need components in prose; plain.mdotherwise. Markdown/MDX are processed by the built-in Rust processor. GFM, heading IDs, and SmartyPants (smart punctuation) are on by default; math is not — enable it explicitly per project via the Markdownfeaturesoption (markdown: { features: { math: true } }), then add a KaTeX/MathML stylesheet. - Common integrations:
@astrojs/sitemap3.x,@astrojs/rss4.x,astro:assets(images),astro:actions,astro:env. - Testing: Vitest 4.x + the Astro Container API for components; Playwright for E2E.
Project conventions
src/
pages/ # file-based routes (.astro, .md, .mdx, endpoints .ts)
layouts/ # shared page shells with <slot />
components/ # .astro (static) + framework islands (.tsx/.vue/.svelte)
content/ # collection source files (md/mdx/json/yaml)
content.config.ts# collection definitions (NOT src/content/config.ts)
styles/ # global.css with @import "tailwindcss";
middleware.ts # onRequest, if used
astro.config.mjs
- Add integrations with
npx astro add <name>so config, peer deps, and types are wired correctly. tsconfig.jsonextendsastro/tsconfigs/strict(orstrictest). Runastro syncafter editingcontent.config.tsto regenerateastro:contenttypes.- Format with Prettier +
prettier-plugin-astro; lint with ESLint flat config (eslint.config.js) +eslint-plugin-astro+astro-eslint-parser. - Import Astro virtual modules by their
astro:*specifiers (astro:content,astro:assets,astro:actions,astro:transitions,astro:env/*), never deep-import internals. - Config file is ESM
astro.config.mjsusingdefineConfig. Setsite(needed for canonical URLs, sitemap, RSS).
Islands — ship zero JS by default
- Default to
.astrocomponents. They render to HTML at build/request time and ship no JavaScript. Reach for a framework component only when you need client state, effects, or event handlers. - A framework component imported into
.astrois static HTML unless you add aclient:*directive. No directive = server-rendered, zero JS. This is the point of Astro — use it. - Pick the laziest hydration that still works:
client:visible— default choice for anything below the fold (carousels, comment widgets, heavy charts). Hydrates on scroll into view. Accepts{ rootMargin: "200px" }to prehydrate just before entry.client:idle— above-the-fold, non-urgent (e.g. header menu). Optionally{ timeout: 2000 }.client:load— only for immediately-interactive, above-the-fold UI where a hydration delay is perceptible. Never the reflex choice.client:media="(max-width: 767px)"— hydrate only when a media query matches (mobile-only drawer).client:only="react"— skips SSR entirely; use only when the component cannot render on the server (e.g. toucheswindowat module top). Costs a blank flash + no SSR HTML, so avoid unless required.
- Server islands: for personalized/dynamic fragments on an otherwise static page, add
server:deferto a component and provide aslot="fallback". The page ships cached-static; the island renders per-request server-side with no client JS. Prefer this over hydrating a client island just to fetch user data. - Pass only serializable props to islands (they are JSON-serialized into HTML). Do not pass functions, class instances, or huge datasets. Use a
slotto pass server-rendered.astrochildren into a framework island instead of prop-drilling markup. - Keep islands leaf-sized. One
client:visibleon a big component tree hydrates the whole subtree — split the interactive bit into its own small island.
Content Collections — Content Layer API
- Define every content source in
src/content.config.ts. Use loaders fromastro/loaders:
import { defineCollection, reference, 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(80),
pubDate: z.coerce.date(),
cover: image(), // validates + optimizes local images
draft: z.boolean().default(false),
author: reference('authors'), // typed cross-collection reference
tags: z.array(z.string()).default([]),
}),
});
const authors = defineCollection({
loader: file('./src/data/authors.json'),
schema: z.object({ id: z.string(), name: z.string() }),
});
export const collections = { blog, authors };
- Use
glob()for directories of files,file()for a single JSON/YAML data file. Write a custom loader (or a package loader) for remote/CMS data — do notfetch()a CMS in every page's frontmatter. - Read with
getCollection('blog', ({ data }) => !data.draft)andgetEntry('authors', id). Resolve references withgetEntry(entry.data.author). - Entry identity is
entry.id(derived from the file path, extension stripped).entry.slugandentry.render()were removed. Render body viarender:
import { render } from 'astro:content';
const { Content, headings } = await render(entry); // then <Content /> in template
- The zod schema is the single source of truth for frontmatter — validation fails the build with a clear error. Never read untyped frontmatter or hand-roll parsing. Prefer
z.coerce.date(),.default(),.transform()over defensive checks in templates. - For real-time/uncached data (inventory, live scores) use live collections:
defineLiveCollection+ a live loader, read withgetLiveCollection/getLiveEntryat request time. Use normal build-time collections for everything that can be static.
Rendering modes and adapters
- Static is the default and the goal. Every route prerenders to HTML at build unless opted out. No adapter needed for a fully static site.
- Go on-demand per route with
export const prerender = false;in an.astropage or endpoint. This is the modern "hybrid" model — the site stays static, individual routes render on a server. Requires an adapter inastro.config.mjs. - Set
output: 'server'only when most routes are dynamic; then opt static routes back in withexport const prerender = true;. - Dynamic static routes need
getStaticPaths()returning{ params, props }; usepaginate()for pagination. On-demand routes read params fromAstro.paramsand must handle the not-found case (return Astro.rewrite('/404')ornew Response(null, { status: 404 })). - Fetch build-time data in frontmatter with top-level
await— it runs on the server/at build and never reaches the client:
---
const posts = await getCollection('blog');
const stats = await fetch(`${import.meta.env.PUBLIC_API}/stats`).then(r => r.json());
---
- Endpoints (
src/pages/*.ts) exportGET/POSTreturning aResponse. Use these for JSON APIs, RSS, sitemaps, OG images — not a clientfetchto your own origin from a static page. - Choose the adapter for the deploy target (
@astrojs/nodestandalone/middleware,@astrojs/vercel,@astrojs/cloudflare,@astrojs/netlify). Match the runtime — Cloudflare is workerd, not Node.
Layouts, styling, and view transitions
- Put the
<html>/<head>/<body>shell and shared<slot />insrc/layouts/. Pass page metadata as props; render the default<slot />plus named<slot name="…" />for regions like sidebars. <style>in.astrois scoped by default — no CSS-module/BEM ceremony needed. Useis:globalonly for genuinely global rules, or importsrc/styles/global.css. Usedefine:vars={{ color }}to pass server values into scoped CSS via custom properties.- With Tailwind v4:
@import "tailwindcss";in one global stylesheet, imported once in the root layout; configure via CSS@theme, not a JS config file. - SPA-like transitions: add
<ClientRouter />fromastro:transitionsto<head>.<ViewTransitions />was renamed and removed — always importClientRouter. Usetransition:name,transition:animate={fade()}/slide(), andtransition:persistto keep an island (e.g. audio player) alive across navigations. It is opt-in and lightweight — do not reach for a client-side router library. - Use
<Image>/<Picture>fromastro:assetsfor local and configured remote images; the<Font>component +fontsconfig for self-hosted/optimized web fonts. Setsiteso canonical/OG URLs and the sitemap are correct.
Testing
- Component unit tests: Vitest + the Container API. The Container API is still experimental in Astro 7 — the import is
experimental_AstroContainerand its surface can change in minor/patch releases, so pin Astro and re-check on upgrade. Configure Vitest through Astro soastro:*virtual modules resolve:
// vitest.config.ts
import { getViteConfig } from 'astro/config';
export default getViteConfig({ test: { /* … */ } });
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { expect, test } from 'vitest';
import Card from '../src/components/Card.astro';
test('renders title', async () => {
const container = await AstroContainer.create();
const html = await container.renderToString(Card, { props: { title: 'Hi' } });
expect(html).toContain('Hi');
});
- Test behavior and output, not framework internals: assert rendered HTML, schema validation (feed bad frontmatter to a collection schema and expect a parse error),
getStaticPathsshape, and endpointResponsestatus/body. - E2E with Playwright against
astro preview(built output) — this is the only way to verify hydration timing,client:*boundaries, and view transitions. Add an assertion that static pages ship no unexpected<script>. - Run
astro checkin CI as the type gate. Include a build (astro build) in CI — schema errors and broken internal links surface at build.
Security
- Never import server secrets into client code. Anything reachable from a
client:*island's import graph is bundled and shipped. Useastro:env:envField.string({ context: 'server', access: 'secret' }), then import fromastro:env/serverin server-only code. Public values usecontext: 'client', access: 'public'and thePUBLIC_prefix. - Astro auto-escapes
{expression}output.set:htmlbypasses escaping — only pass sanitized/trusted HTML (sanitize untrusted input server-side before rendering). - Validate every Action and on-demand endpoint input with a zod schema (
defineAction({ input: z.object({…}), handler })). Never trustAstro.requestbody/params. - Keep
security.checkOrigin: true(default) for on-demand routes — it blocks CSRF on form POSTs by verifying the Origin header. Do not disable it. - Enable CSP (stable) via the top-level
cspconfig to harden against XSS; it hashes inline scripts/styles automatically. - Set cookies through
Astro.cookies.set(name, value, { httpOnly: true, secure: true, sameSite: 'lax' })— never writeSet-Cookieby hand. - Configure
image.remotePatterns/image.domainsnarrowly; do not allow arbitrary remote image hosts through the optimizer. - Do authz in
src/middleware.ts(defineMiddleware) and store request-scoped data incontext.locals— not module-level globals, which leak across requests on a long-lived server.
Do
- Default every component to
.astro; introduce a framework island only where interactivity is real. - Choose
client:visible/client:idlefirst; justify anyclient:loadin a comment. - Model all structured content as collections with zod schemas; run
astro syncafter schema changes. - Fetch data in frontmatter (build/server) with top-level
await; keep it out of the client. - Use server islands (
server:defer) for per-user fragments on static pages. - Keep routes static; add
prerender = falseper route only where a server is required. - Use
astro:assets<Image>/<Picture>with explicitwidths/sizesto prevent CLS. - Use scoped
<style>,astro:actionsfor forms,astro:envfor config. - Gate merges on
astro check, ESLint, Prettier, and a successfulastro build.
Avoid
client:loadwhereclient:visiblesuffices — the most common perf regression on this stack.- Adding a UI framework or
client:*directive to render static content — that ships a needless runtime. Build a full SPA in Astro only when the app is genuinely app-like, never for content/marketing pages. Astro.glob()— removed. UsegetCollection()for content,import.meta.glob()for raw file imports.entry.slug/entry.render()— removed. Useentry.idandrender(entry)fromastro:content.<ViewTransitions />— removed. Use<ClientRouter />fromastro:transitions.@astrojs/tailwind— deprecated. Use the@tailwindcss/viteplugin.src/content/config.tsand pre-Content-Layer collections without aloader— usesrc/content.config.tswith the Content Layer API.output: 'hybrid'as a mode — hybrid is now the default static behavior via per-routeprerender.- Client-side
fetchto your own origin for data you could load in frontmatter or a server island. - Unclosed/malformed tags — the Rust compiler errors on them; write valid, closed HTML.
- Passing non-serializable props (functions, class instances, big blobs) to islands.
- Reading
import.meta.envsecrets in any component that aclient:*island imports.
When you code
- Make small, reviewable diffs. Touch one route/component/collection per change; do not restructure folders unprompted.
- Before adding interactivity, ask: does this need to run in the browser at all? If yes, what is the laziest
client:*directive that works? - After editing
content.config.ts, runastro sync; after any change runastro checkandastro buildand fix errors before finishing. - When adding a dependency/integration, prefer
npx astro addover hand-editing config. - Confirm the deploy target before adding an adapter or setting
prerender = false— do not introduce a server runtime for a site that can stay static. - If a request implies shipping significant client JS (heavy framework, SPA behavior) for content that could be static, flag the tradeoff and propose the islands/server-island alternative before implementing.
- State the exact versions you target (Astro 7, Node 22.12+, Vite 8) when they affect the answer; never emit APIs removed in v6/v7.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Astro 7 · Islands · Content Layer.