Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Framework · Astro 7 · Islands · Content Layer

Astro

Zero-JS by default, islands only where needed, typed content.

astroislandsssgcontent

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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 .astro compiler (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/check 0.9.x + typescript). tsc cannot check .astro; astro check is the type gate.
  • Zod 4.4.x for collection schemas and Action input — import z from astro:content (schemas) or astro:schema.
  • Content Layer APIsrc/content.config.ts, defineCollection, loaders from astro/loaders.
  • Framework islands as needed: @astrojs/react 6.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/node 11.x, @astrojs/vercel 11.x, @astrojs/cloudflare 14.x, @astrojs/netlify.
  • Styling: Tailwind v4 via @tailwindcss/vite 4.3.x (Vite plugin). The @astrojs/tailwind integration is deprecated — do not use it.
  • Content authoring: @astrojs/mdx 7.x only when you need components in prose; plain .md otherwise. 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 Markdown features option (markdown: { features: { math: true } }), then add a KaTeX/MathML stylesheet.
  • Common integrations: @astrojs/sitemap 3.x, @astrojs/rss 4.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.json extends astro/tsconfigs/strict (or strictest). Run astro sync after editing content.config.ts to regenerate astro:content types.
  • 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.mjs using defineConfig. Set site (needed for canonical URLs, sitemap, RSS).

Islands — ship zero JS by default

  • Default to .astro components. 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 .astro is static HTML unless you add a client:* 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:loadonly 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. touches window at 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:defer to a component and provide a slot="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 slot to pass server-rendered .astro children into a framework island instead of prop-drilling markup.
  • Keep islands leaf-sized. One client:visible on 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 from astro/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 not fetch() a CMS in every page's frontmatter.
  • Read with getCollection('blog', ({ data }) => !data.draft) and getEntry('authors', id). Resolve references with getEntry(entry.data.author).
  • Entry identity is entry.id (derived from the file path, extension stripped). entry.slug and entry.render() were removed. Render body via render:
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 with getLiveCollection/getLiveEntry at 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 .astro page or endpoint. This is the modern "hybrid" model — the site stays static, individual routes render on a server. Requires an adapter in astro.config.mjs.
  • Set output: 'server' only when most routes are dynamic; then opt static routes back in with export const prerender = true;.
  • Dynamic static routes need getStaticPaths() returning { params, props }; use paginate() for pagination. On-demand routes read params from Astro.params and must handle the not-found case (return Astro.rewrite('/404') or new 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) export GET/POST returning a Response. Use these for JSON APIs, RSS, sitemaps, OG images — not a client fetch to your own origin from a static page.
  • Choose the adapter for the deploy target (@astrojs/node standalone/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 /> in src/layouts/. Pass page metadata as props; render the default <slot /> plus named <slot name="…" /> for regions like sidebars.
  • <style> in .astro is scoped by default — no CSS-module/BEM ceremony needed. Use is:global only for genuinely global rules, or import src/styles/global.css. Use define: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 /> from astro:transitions to <head>. <ViewTransitions /> was renamed and removed — always import ClientRouter. Use transition:name, transition:animate={fade()}/slide(), and transition:persist to 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> from astro:assets for local and configured remote images; the <Font> component + fonts config for self-hosted/optimized web fonts. Set site so 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_AstroContainer and its surface can change in minor/patch releases, so pin Astro and re-check on upgrade. Configure Vitest through Astro so astro:* 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), getStaticPaths shape, and endpoint Response status/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 check in 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. Use astro:env: envField.string({ context: 'server', access: 'secret' }), then import from astro:env/server in server-only code. Public values use context: 'client', access: 'public' and the PUBLIC_ prefix.
  • Astro auto-escapes {expression} output. set:html bypasses 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 trust Astro.request body/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 csp config 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 write Set-Cookie by hand.
  • Configure image.remotePatterns/image.domains narrowly; do not allow arbitrary remote image hosts through the optimizer.
  • Do authz in src/middleware.ts (defineMiddleware) and store request-scoped data in context.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:idle first; justify any client:load in a comment.
  • Model all structured content as collections with zod schemas; run astro sync after 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 = false per route only where a server is required.
  • Use astro:assets <Image>/<Picture> with explicit widths/sizes to prevent CLS.
  • Use scoped <style>, astro:actions for forms, astro:env for config.
  • Gate merges on astro check, ESLint, Prettier, and a successful astro build.

Avoid

  • client:load where client:visible suffices — 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. Use getCollection() for content, import.meta.glob() for raw file imports.
  • entry.slug / entry.render() — removed. Use entry.id and render(entry) from astro:content.
  • <ViewTransitions /> — removed. Use <ClientRouter /> from astro:transitions.
  • @astrojs/tailwind — deprecated. Use the @tailwindcss/vite plugin.
  • src/content/config.ts and pre-Content-Layer collections without a loader — use src/content.config.ts with the Content Layer API.
  • output: 'hybrid' as a mode — hybrid is now the default static behavior via per-route prerender.
  • Client-side fetch to 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.env secrets in any component that a client:* 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, run astro sync; after any change run astro check and astro build and fix errors before finishing.
  • When adding a dependency/integration, prefer npx astro add over 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.

Back to top ↑