astro
Use when building a content-driven or marketing site with Astro 6: static-first pages, islands and partial hydration, content collections, server islands, per-route on-demand rendering, deploy adapters, and Astro 5→6 migration. NOT app-router React with server actions and heavy c
Install
npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/astro
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
git clone https://github.com/ericrisco/rsc-harness.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole ericrisco/rsc-harness collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Astro 6 — static-first sites, islands, content collections
The prime directive
Ship zero client JavaScript by default. Hydrate the smallest possible surface, as late as you can
get away with. An .astro component renders to HTML at build time and ships no runtime; every
island is a bundle the visitor downloads, parses, and executes. Content and marketing sites win on
TTFB/LCP and Lighthouse, not on React-everywhere. If you find yourself adding client:load to make
a page "work," stop — the page already works; you are adding interactivity, and interactivity is the
expensive exception, not the default.
First: detect the project version
Astro 6.0 is stable (released 2026-03-10); the Astro 5 line is
still production-ready. Do not mix advice across majors — read package.json → the astro version
before advising. What v6 changes, per the
upgrade-to-v6 guide:
- Node
22.12.0or higher is required (18 and 20 are dropped) — check the actual runtime. - Content config lives at
src/content.config.ts. The legacysrc/content/config.tspath is removed, not merely discouraged, and the old auto-detection (legacy.collections) is gone. Thelegacy.collectionsBackwardsCompatescape hatch is a migration crutch, not a supported layout. - Vite 7 and Zod 4 for content schemas —
zis imported fromastro/zod, notastro:content(see Content collections below). - Live Content Collections, the Fonts API and the CSP API are stable.
- The Rust compiler succeeding the Go one is experimental — do not rely on or configure it in production advice.
Decision table — what kind of thing is this?
Pick the cheapest row that satisfies the requirement. Read top-down; stop at the first match.
| Need | Use | Why |
|---|---|---|
| Pure content, no interactivity | .astro component, static |
Renders to HTML at build, ships 0 KB JS |
| One small interactive widget | UI-framework component + client:* |
Hydrate just that island; the rest stays static |
| Per-request personalization on a mostly-static page | server island (server:defer) |
Static CDN page + one deferred fragment, no full SSR |
| Whole route needs request data on every load | export const prerender = false + adapter |
Opt that one route into on-demand rendering |
| Many static routes generated from data | getStaticPaths() |
Build-time fan-out, still fully static |
Rendering model
Default: every page is prerendered to static HTML at build time. You opt into dynamism per route — never the other way around.
---
// src/pages/dashboard.astro — opt this ONE route into on-demand (SSR) rendering.
// Requires a configured adapter (Vercel/Netlify/Cloudflare/Node). Everything else stays static.
export const prerender = false;
const user = await getUser(Astro.request); // runs per request
---
<h1>Hello {user.name}</h1>
---
// src/pages/blog/[slug].astro — many STATIC routes generated from data at build time.
import { getCollection } from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}
const { post } = Astro.props;
---
<h1>{post.data.title}</h1>
In Astro 6 the dev server runs the production runtime (Vite 7 Environment API), so dev no longer
diverges from prod on Cloudflare/Bun/Deno — fewer "works in dev, breaks on deploy" surprises. Adapter
choice per platform → references/deploy-and-integrations.md.
Islands & client directives
A client:* directive turns a framework component into a hydrated island. Choose the latest
directive that still feels instant to the user — never default to client:load.
| Directive | Hydrates when | Use for |
|---|---|---|
client:load |
Immediately on page load | Above-the-fold, must-be-interactive-now controls |
client:idle |
On requestIdleCallback |
Important but not first-paint-critical widgets |
client:visible |
When it scrolls into view (IO) | Below-the-fold carousels, comment boxes, maps |
client:media={query} |
When a media query matches | Mobile-only menu, desktop-only panel |
client:only="react" |
Client-only, no SSR HTML | Components that crash during SSR (browser-only deps) |
---
import Carousel from "../components/Carousel.tsx";
---
<!-- Bad: a below-the-fold carousel paying for JS at first paint -->
<Carousel client:load />
<!-- Good: defer its bundle until the user actually scrolls to it -->
<Carousel client:visible />
client:only gotcha: it skips SSR entirely, so the component produces no server HTML (expect a
flash/layout shift) and you must name the framework (client:only="react") — Astro can't infer
it without the server render. Reach for it only when SSR genuinely breaks; otherwise prefer
client:visible.
Content collections (Content Layer)
Type-safe content lives in a single config file. The path is load-bearing:
// src/content.config.ts ← v6 path. NOT src/content/config.ts (legacy path removed in v6)
import { defineCollection } from "astro:content";
import { z } from "astro/zod"; // v6: z moved OUT of astro:content into astro/zod (Zod 4)
import { glob } from "astro/loaders";
const blog = defineCollection({
// glob() sources files from anywhere; `id` comes from the filename minus extension
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/data/blog" }),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
draft: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { blog };
Query and render in a page. render() is now a standalone call (not entry.render()):
---
// src/pages/blog/[slug].astro
import { getCollection, getEntry, render } from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<article><h1>{post.data.title}</h1><Content /></article>
Built-in loaders are glob() (many files) and file() (one JSON/YAML array). Custom and CMS
loaders, Zod 4 schema patterns, collection references, Live Content Collections (real-time data with
no rebuild, stable in v6), querying and MDX details → references/content-layer.md.
Server islands
When most of a page is static and CDN-cacheable but one fragment is per-visitor, use a server island instead of turning the whole route into SSR. The page ships static; the island is fetched and rendered after first paint.
---
// src/components/UserGreeting.astro — rendered on demand, deferred after the static shell
const user = await getUserFromCookie(Astro.request);
---
<span>Welcome back, {user.name}</span>
---
import UserGreeting from "../components/UserGreeting.astro";
---
<header>
<!-- static page, one deferred personalized fragment with a placeholder while it loads -->
<UserGreeting server:defer>
<span slot="fallback">Welcome</span>
</UserGreeting>
</header>
This beats full SSR when: the page is otherwise cacheable on a CDN, and only a small slice depends on the request. You keep static LCP and personalize without making every request hit the origin.
Integrations & setup
Use astro add so it patches astro.config.mjs and installs peers in one step:
npx astro add react mdx sitemap
- Tailwind 4 wires through the official Vite plugin (
@tailwindcss/vite), not the legacy@astrojs/tailwindintegration (that path was for Tailwind 3). - Fonts API (stable in v6) self-hosts and optimizes fonts from
astro.config.mjs— no manual@font-face. - CSP API (stable in v6) emits a Content-Security-Policy with hashes for your inline scripts/styles.
Adapter recipes per platform, hybrid rendering, env handling, SSR endpoints (src/pages/api/*.ts)
and the Fonts/CSP config → references/deploy-and-integrations.md.
Performance rules
- Images: always
<Image>/<Picture>fromastro:assets— automatic width/height, format, and lazy-loading kill CLS and over-sized payloads. Never a raw<img>for local assets. - Never global-hydrate: there is no "make the page interactive" switch; hydrate per island.
- View transitions: add
<ClientRouter />fromastro:transitionsto the<head>for SPA-like navigation without an SPA. Prefetch links with theprefetchconfig/attribute.
Astro 5 → 6 migration checklist
Run the codemod first, then verify each item:
npx @astrojs/upgrade
- Node runtime is
22.12.0+ (CI image, local, deploy target). - Dependencies on Vite 7 (Vite v7.0; custom Vite plugins/config may need updates).
- Schema
zimport moved:import { z } from "astro/zod"—zandastro:schemaare gone fromastro:content. Then review for Zod 4 breaking changes. - Content config renamed to
src/content.config.ts(deletesrc/content/config.ts; the legacy path is removed, not just deprecated). - Full guide (dated 2026):
docs.astro.build/en/guides/upgrade-to/v6.
Anti-patterns
| Anti-pattern | Reality |
|---|---|
"Add client:load so the page works" |
An .astro page already works statically; you're shipping JS for nothing |
"client:load everywhere, simplest" |
Pick client:visible/idle/media; first-paint JS is the LCP killer |
| "Make the whole route SSR to personalize the header" | Use a server island (server:defer); keep the page static & CDN-cached |
"src/content/config.ts worked before, keep it" |
v6 removed that path (LegacyContentConfigError) — must be src/content.config.ts |
"fetch() the CMS inside the .astro frontmatter" |
Write a content-collection loader so content is typed, cached, and queryable |
| "Pull in React just to render this static markup" | Static markup is an .astro component — 0 KB, no framework runtime |
| "Skip the Zod schema, content is just frontmatter" | Untyped content = silent build-time drift; the schema is the contract |
"client:only without the framework name" |
It can't infer the framework with no SSR — must be client:only="react" |
"Use the old @astrojs/tailwind for Tailwind 4" |
Tailwind 4 wires through @tailwindcss/vite; the old integration is v3-era |
Verify
bash scripts/verify.sh from the Astro project root — grep-based, needs no install. It FAILS if
a v6 project still has src/content/config.ts instead of src/content.config.ts, WARNS on
over-hydration smells (many client:load, or client:only with no framework string), CHECKS
that content schemas import from astro:content, and — only if the astro binary resolves —
optionally runs npx astro check. On an empty or clean tree it prints OK and exits 0; warnings are
advisory and never fail the run.
See Also
../nextjs/SKILL.md— when the project is really an app-router React app with server actions and heavy client interactivity, not a content/marketing site.../landing-copy/SKILL.mdand../seo-geo/SKILL.md— this skill builds the site; those write the copy and decide the SEO/structured-data strategy that fills it.../vercel/SKILL.md,../netlify/SKILL.md,../cloudflare/SKILL.md— platform mechanics (DNS, env, build settings) once the code and adapter are ready.
Files (rsc-harness)
-
evals
-
cases.yaml 2.4 KB
skill: astro should_trigger: - prompt: "Build an Astro blog with content collections." why: "Astro content collections and static-first pages are core to the skill." - prompt: "Make this widget hydrate only when it scrolls into view in Astro." why: "Partial hydration/client directives are Astro-specific." - prompt: "Use a server island to personalize a static Astro page." why: "Server islands and static-plus-dynamic composition belong here." - prompt: "Migrate this project from Astro 5 to Astro 6." why: "Astro version migration and v6 content config are skill-specific." - prompt: "Quiero una web de marketing rapida con Astro y cero JS innecesario." why: "Spanish marketing site with Astro and zero-JS directive triggers this skill." should_not_trigger: - prompt: "Implement a Next.js server action with App Router." route_to: "nextjs" why: "Next.js RSC/App Router is not Astro." - prompt: "Write conversion copy for this landing page." route_to: "landing-copy" why: "Copywriting belongs to landing-copy; Astro may implement it later." - prompt: "Configure Cloudflare DNS and workers." route_to: "cloudflare" why: "Platform configuration is the Cloudflare skill." - prompt: "Design the visual system for this marketing page." route_to: "design" why: "Visual design belongs to design." - prompt: "Write the hooks and state logic for this React island." route_to: "react" why: "The component-internals (hooks/state) of a React island are React's job; Astro only decides whether and how the island hydrates (client:* directive), not its internal logic — the closest near-miss sibling." capability: - scenario: "A user wants an Astro 6 marketing site with a static blog, one interactive pricing calculator, and per-request personalization." must_include: - "Keeps the default static/zero-client-JS model and hydrates only the calculator island." - "Chooses the appropriate client directive such as client:visible/client:idle instead of client:load by habit." - "Uses content collections for typed blog/content data." - "Uses server islands or per-route on-demand rendering only for request-specific personalization." - "Checks Astro major version and Node/runtime requirements before giving migration advice." - "Routes copy, SEO, deploy-platform details to their matching skills when needed." - "Explains that interactivity is the exception, not the default." -
README.md 84 B
# astro evals Trigger and capability checks for Astro content and marketing sites.
-
-
references
-
content-layer.md 5.1 KB
# Content Layer — loaders, schemas, querying The Content Layer (stable since Astro 5.0) sources content from anywhere through **loaders** and gives you type-safe, queryable collections. Config lives in `src/content.config.ts` (v6 path; the legacy `src/content/config.ts` location is removed in v6, per the [upgrade-to-v6 guide](https://docs.astro.build/en/guides/upgrade-to/v6/)). ## defineCollection: loader + schema A collection is `defineCollection({ loader, schema })`. The loader produces entries; the schema (Zod 4) validates and types their `data`. ```typescript // src/content.config.ts import { defineCollection, reference } from "astro:content"; import { z } from "astro/zod"; // v6: z is imported from astro/zod, not astro:content import { glob, file } from "astro/loaders"; // glob() — many files, one entry per file. `id` = path minus base & extension. const blog = defineCollection({ loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/data/blog" }), schema: ({ image }) => z.object({ title: z.string(), description: z.string().max(160), pubDate: z.coerce.date(), updated: z.coerce.date().optional(), draft: z.boolean().default(false), cover: image(), // astro:assets image, validated at build author: reference("authors"), // cross-collection reference tags: z.array(z.string()).default([]), }), }); // file() — ONE structured file (JSON/YAML) holding an array of entries. const authors = defineCollection({ loader: file("./src/data/authors.json"), // each object needs an `id` schema: z.object({ id: z.string(), name: z.string(), url: z.string().url().optional() }), }); export const collections = { blog, authors }; ``` ## Zod 4 notes (Astro 6) In Astro 6 you import `z` from **`astro/zod`** (it is **Zod 4**); `z` was removed from `astro:content` and the old `astro:schema` alias is gone — both consolidate into `astro/zod`. Source: [upgrade-to-v6 guide](https://docs.astro.build/en/guides/upgrade-to/v6/) and the [Astro 6.0 release post, 2026-03-10](https://astro.build/blog/astro-6/). When migrating from v5: - Replace `import { z } from "astro:content"` with `import { z } from "astro/zod"` (and any `astro:schema` import likewise). `defineCollection`, `reference`, `getCollection`, etc. still come from `astro:content`. - `z.coerce.date()` for ISO date strings in frontmatter — unchanged, still the right call. - Review any custom error-map / `.refine()` usage and string-format helpers; Zod 4 changed some error shapes and deprecated a few v3 APIs. Run a build and read the validation errors. - `image()` (injected via the `({ image }) => ...` schema form) validates and optimizes local images referenced in frontmatter. ## Querying & rendering ```astro --- import { getCollection, getEntry, render } from "astro:content"; // All non-draft posts, newest first const posts = (await getCollection("blog", ({ data }) => !data.draft)) .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()); // A single entry by id const post = await getEntry("blog", "hello-world"); // Resolve a reference() to the full entry const author = post ? await getEntry(post.data.author) : undefined; // Render Markdown/MDX body to a component (standalone render(), not entry.render()) const { Content, headings } = await render(posts[0]); --- <h2>{author?.data.name}</h2> <Content /> ``` ## Custom & CMS loaders When content comes from an API/CMS rather than files, write an inline loader or use a community one (`@ascorbic/*`, Storyblok, Contentful, etc.). The contract: return an array of objects each with an `id`, validated by the same `schema`. ```typescript // src/content.config.ts — minimal inline loader fetching from a headless CMS at build time const products = defineCollection({ loader: async () => { const res = await fetch("https://cms.example.com/api/products"); const items = await res.json(); return items.map((p: any) => ({ id: String(p.id), ...p })); // each entry needs an `id` }, schema: z.object({ id: z.string(), name: z.string(), price: z.number() }), }); ``` For incremental/syncing loaders that cache between builds, implement the full loader object (`{ name, load(context) }`) and use `context.store` — see `docs.astro.build/en/reference/content-loader-reference`. ## Live Content Collections (stable in Astro 6) Stable as of the [Astro 6.0 release, 2026-03-10](https://astro.build/blog/astro-6/). Live collections fetch **at request time** instead of build time, so data stays fresh without a rebuild — ideal for inventory, prices, or anything that changes between deploys on an otherwise static site. They use a separate `src/live.config.ts` and `getLiveCollection`/`getLiveEntry`. Use them only for genuinely live data; build-time `glob()`/`file()` collections stay faster and cacheable for everything stable. Reference: [Live Content Collections guide](https://docs.astro.build/en/guides/content-collections/). ## MDX `npx astro add mdx` enables `.mdx` in the same `glob()` pattern. MDX lets a content file import and render components (including hydrated islands with `client:*`). Keep islands inside MDX rare and deferred for the same reason as everywhere else: they ship JS. -
deploy-and-integrations.md 5.3 KB
# Deploy adapters & integrations Static output needs no adapter — Astro builds HTML you can host anywhere. The moment any route uses `prerender = false` (on-demand rendering) or a server island, you need an **adapter** for the target platform. ## Adapter table | Platform | Adapter | Install | Notes | | --------------- | ---------------------- | ----------------------------------------- | ---------------------------------------------- | | Vercel | `@astrojs/vercel` | `npx astro add vercel` | Serverless/edge functions for on-demand routes | | Netlify | `@astrojs/netlify` | `npx astro add netlify` | Netlify Functions for SSR | | Cloudflare | `@astrojs/cloudflare` | `npx astro add cloudflare` | Workers runtime; v6 dev runs this in dev too | | Node (self-host)| `@astrojs/node` | `npx astro add node` | `mode: "standalone"` for a standalone server | Platform mechanics (DNS, env vars in the dashboard, build settings) are out of scope here — those belong to `../vercel/SKILL.md`, `../netlify/SKILL.md`, `../cloudflare/SKILL.md`. This file covers the *code* side: which adapter, and how rendering modes interact with it. ## Rendering modes Astro renders static by default; you opt routes into on-demand individually. ```javascript // astro.config.mjs — add an adapter once; static routes still build to HTML. import { defineConfig } from "astro/config"; import vercel from "@astrojs/vercel"; export default defineConfig({ adapter: vercel(), // no top-level `output` needed in v6: per-route `prerender` decides static vs on-demand }); ``` ```astro --- // A route that needs request data on every load → on-demand. export const prerender = false; --- ``` Astro 6's dev server runs the production runtime via Vite 7's Environment API, so adapter-specific behavior (Cloudflare Workers APIs, Bun, Deno) shows up in dev — far fewer deploy-only surprises. ## SSR endpoints (API routes) Server endpoints live in `src/pages/api/*.ts` and export HTTP-method functions. They need an adapter (they are on-demand by definition). ```typescript // src/pages/api/subscribe.ts import type { APIRoute } from "astro"; export const prerender = false; export const POST: APIRoute = async ({ request }) => { const data = await request.formData(); const email = String(data.get("email") ?? ""); if (!email.includes("@")) { return new Response(JSON.stringify({ error: "invalid" }), { status: 422 }); } // ... persist / forward return new Response(JSON.stringify({ ok: true }), { status: 201 }); }; ``` ## Env handling Use `astro:env` for typed, validated env with a clear client/server split — never leak a secret to the client bundle. ```javascript // astro.config.mjs import { defineConfig, envField } from "astro/config"; export default defineConfig({ env: { schema: { // server-only secret: never reaches the browser CMS_TOKEN: envField.string({ context: "server", access: "secret" }), // safe to expose to the client PUBLIC_SITE_NAME: envField.string({ context: "client", access: "public" }), }, }, }); ``` ```astro --- import { CMS_TOKEN } from "astro:env/server"; // server context only --- ``` ## `astro add` recipes `astro add` patches `astro.config.mjs` and installs peers in one step: ```bash npx astro add react # React islands npx astro add mdx # .mdx content npx astro add sitemap # sitemap.xml at build npx astro add vercel # (or netlify / cloudflare / node) adapter for on-demand ``` ## Fonts API (stable in Astro 6) Stable as of the [Astro 6.0 release, 2026-03-10](https://astro.build/blog/astro-6/). Self-host and optimize fonts from config — no manual `@font-face`, no extra round-trip, zero CLS. ```javascript // astro.config.mjs import { defineConfig, fontProviders } from "astro/config"; export default defineConfig({ experimental: {}, // Fonts API is stable in v6; configure under `fonts` fonts: [ { provider: fontProviders.google(), name: "Inter", cssVariable: "--font-inter", weights: [400, 600, 700], }, ], }); ``` Use the CSS variable in your styles. Reference: [Fonts guide](https://docs.astro.build/en/guides/fonts/). ## CSP API (stable in Astro 6) Stable as of the [Astro 6.0 release, 2026-03-10](https://astro.build/blog/astro-6/). Astro can emit a Content-Security-Policy with hashes for your inline scripts/styles, tightening XSS defenses on static and on-demand pages alike. ```javascript // astro.config.mjs export default defineConfig({ csp: true, // or an object to extend directives (e.g. allowed connect-src for islands) }); ``` Reference: [Content Security Policy guide](https://docs.astro.build/en/reference/configuration-reference/#csp). ## Tailwind 4 Tailwind 4 wires through the official Vite plugin, **not** the legacy `@astrojs/tailwind` integration (that was for Tailwind 3). ```javascript // astro.config.mjs import { defineConfig } from "astro/config"; import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ vite: { plugins: [tailwindcss()] }, }); ``` ```css /* src/styles/global.css */ @import "tailwindcss"; ```
-
-
scripts
-
verify.sh 5.6 KB
#!/usr/bin/env bash # verify.sh — Astro project static gate. # # Usage: # bash scripts/verify.sh # run from the Astro project root # # What it does (read-only, grep-based, no install required): # 1. FAIL if a v6 project still uses src/content/config.ts instead of # src/content.config.ts (the legacy path is removed on v6 → # LegacyContentConfigError). # 2. WARN on over-hydration smells: many `client:load`, or `client:only` # with no framework string. # 3. CHECK content schemas import from `astro:content`. # 4. If the `astro` binary resolves, OPTIONALLY run `npx astro check` # (type/diagnostic check; read-only). Skipped if not resolvable. # # Exit code: non-zero ONLY on a real FAIL. Warnings are advisory. On an empty # or clean tree it prints OK and exits 0 (no false failure). # # Portability: targets stock macOS bash 3.2. No `mapfile`, no jq, no node # required for the grep checks. `set -e` is intentionally NOT used; `set -u` is. set -u if [ -t 1 ] && command -v tput >/dev/null 2>&1; then RED="$(tput setaf 1)"; GREEN="$(tput setaf 2)"; YELLOW="$(tput setaf 3)"; RESET="$(tput sgr0)" else RED=""; GREEN=""; YELLOW=""; RESET="" fi failures=0 warnings=0 have() { command -v "$1" >/dev/null 2>&1; } fail() { printf '%s\n' "${RED}FAIL: $1${RESET}"; failures=$((failures + 1)); } warn() { printf '%s\n' "${YELLOW}WARN: $1${RESET}"; warnings=$((warnings + 1)); } ok() { printf '%s\n' "${GREEN}OK: $1${RESET}"; } skip() { printf '%s\n' "${YELLOW}SKIP: $1${RESET}"; } # Is this even an Astro project? If not, exit clean (no false failure). if [ ! -f astro.config.mjs ] && [ ! -f astro.config.ts ] && [ ! -f astro.config.js ] \ && ! grep -rq '"astro"' package.json 2>/dev/null; then ok "no Astro project detected here — nothing to check" exit 0 fi # Source files to scan (skip node_modules / dist / .astro). Empty list is fine. src_files() { find . \( -name node_modules -o -name dist -o -name .astro -o -name .vercel \ -o -name .netlify \) -prune -o \ -type f \( -name '*.astro' -o -name '*.ts' -o -name '*.tsx' \ -o -name '*.jsx' -o -name '*.mdx' \) -print 2>/dev/null } # Detect Astro major version (best-effort, bash-3.2 safe). Echoes a bare integer or nothing. astro_major() { [ -f node_modules/astro/package.json ] || return 0 ver="$(grep -m1 '"version"' node_modules/astro/package.json 2>/dev/null \ | sed -e 's/.*"version"[^0-9]*//' -e 's/[^0-9].*//')" [ -n "$ver" ] && printf '%s' "$ver" } # --- 1. content config path ------------------------------------------------ # On v6 the only valid path is src/content.config.ts. The old src/content/config.ts # is removed (LegacyContentConfigError). Treat its presence as a FAIL on v6, a WARN if version unknown. if [ -f src/content/config.ts ]; then amajor="$(astro_major)" if [ -n "$amajor" ] && [ "$amajor" -ge 6 ] 2>/dev/null; then fail "src/content/config.ts exists on Astro ${amajor} — rename to src/content.config.ts (legacy path removed in v6)" else warn "src/content/config.ts found — on Astro 6 this must be src/content.config.ts" fi elif [ -f src/content.config.ts ]; then ok "content config at src/content.config.ts" else skip "no content config found — collections not in use" fi # --- 2. content schema imports --------------------------------------------- # A content config should import defineCollection/z from astro:content. if [ -f src/content.config.ts ]; then if grep -q 'astro:content' src/content.config.ts; then ok "content schema imports from astro:content" else warn "src/content.config.ts does not import from astro:content — schemas may be untyped" fi fi # --- 3. over-hydration smell ----------------------------------------------- # Count client:load occurrences and flag a high count; flag client:only without a framework. load_count=0 only_bad=0 files="$(src_files)" if [ -n "$files" ]; then load_count="$(printf '%s\n' "$files" | xargs grep -ho 'client:load' 2>/dev/null | grep -c 'client:load')" # client:only that is NOT immediately followed by ="<framework>" only_bad="$(printf '%s\n' "$files" \ | xargs grep -hoE 'client:only(="[^"]+")?' 2>/dev/null \ | grep -c 'client:only$' )" fi [ -z "$load_count" ] && load_count=0 [ -z "$only_bad" ] && only_bad=0 if [ "$load_count" -gt 5 ] 2>/dev/null; then warn "client:load appears ${load_count} times — prefer client:visible/idle/media; first-paint JS hurts LCP" elif [ "$load_count" -gt 0 ] 2>/dev/null; then ok "client:load used sparingly (${load_count})" else ok "no client:load directives (static-first)" fi if [ "$only_bad" -gt 0 ] 2>/dev/null; then warn "client:only used without a framework string (e.g. client:only=\"react\") in ${only_bad} place(s)" fi # --- 4. optional astro check ---------------------------------------------- if [ -x node_modules/.bin/astro ] || have astro; then printf '%s\n' "Running astro check... (read-only diagnostics)" if [ -x node_modules/.bin/astro ]; then if node_modules/.bin/astro check; then ok "astro check"; else fail "astro check"; fi elif have npx; then if npx --no-install astro check; then ok "astro check"; else fail "astro check"; fi fi else skip "astro binary not resolvable — skipping astro check" fi # --- summary --------------------------------------------------------------- printf '\n' if [ "$warnings" -gt 0 ]; then printf '%s\n' "${YELLOW}verify.sh: ${warnings} warning(s) — advisory${RESET}" fi if [ "$failures" -gt 0 ]; then printf '%s\n' "${RED}verify.sh: ${failures} check(s) failed${RESET}" exit 1 fi printf '%s\n' "${GREEN}verify.sh: all runnable checks passed${RESET}" exit 0
-
-
SKILL.md 13 KB
--- name: astro description: "Use when building a content-driven or marketing site with Astro 6: static-first pages, islands and partial hydration, content collections, server islands, per-route on-demand rendering, deploy adapters, and Astro 5→6 migration. NOT app-router React with server actions and heavy client interactivity (that is `nextjs`)." tags: [astro, ssg, islands, content-collections, partial-hydration, marketing-site, frameworks] recommends: [landing-copy, seo-geo, vercel, cloudflare, netlify] origin: risco --- # Astro 6 — static-first sites, islands, content collections ## The prime directive **Ship zero client JavaScript by default. Hydrate the smallest possible surface, as late as you can get away with.** An `.astro` component renders to HTML at build time and ships *no* runtime; every island is a bundle the visitor downloads, parses, and executes. Content and marketing sites win on TTFB/LCP and Lighthouse, not on React-everywhere. If you find yourself adding `client:load` to make a page "work," stop — the page already works; you are adding interactivity, and interactivity is the expensive exception, not the default. ## First: detect the project version Astro 6.0 is stable ([released 2026-03-10](https://astro.build/blog/astro-6/)); the Astro 5 line is still production-ready. Do not mix advice across majors — read `package.json` → the `astro` version before advising. What v6 changes, per the [upgrade-to-v6 guide](https://docs.astro.build/en/guides/upgrade-to/v6/): - **Node `22.12.0` or higher is required** (18 and 20 are dropped) — check the actual runtime. - Content config lives at `src/content.config.ts`. The legacy `src/content/config.ts` path is **removed**, not merely discouraged, and the old auto-detection (`legacy.collections`) is gone. The `legacy.collectionsBackwardsCompat` escape hatch is a migration crutch, not a supported layout. - **Vite 7** and **Zod 4** for content schemas — `z` is imported from `astro/zod`, **not** `astro:content` (see Content collections below). - **Live Content Collections**, the **Fonts API** and the **CSP API** are stable. - The Rust compiler succeeding the Go one is *experimental* — do not rely on or configure it in production advice. ## Decision table — what kind of thing is this? Pick the cheapest row that satisfies the requirement. Read top-down; stop at the first match. | Need | Use | Why | | ------------------------------------------------------ | ---------------------------------------- | ---------------------------------------------------------- | | Pure content, no interactivity | `.astro` component, static | Renders to HTML at build, ships **0 KB** JS | | One small interactive widget | UI-framework component + `client:*` | Hydrate just that island; the rest stays static | | Per-request personalization on a mostly-static page | server island (`server:defer`) | Static CDN page + one deferred fragment, no full SSR | | Whole route needs request data on every load | `export const prerender = false` + adapter | Opt that one route into on-demand rendering | | Many static routes generated from data | `getStaticPaths()` | Build-time fan-out, still fully static | ## Rendering model Default: **every page is prerendered to static HTML** at build time. You opt *into* dynamism per route — never the other way around. ```astro --- // src/pages/dashboard.astro — opt this ONE route into on-demand (SSR) rendering. // Requires a configured adapter (Vercel/Netlify/Cloudflare/Node). Everything else stays static. export const prerender = false; const user = await getUser(Astro.request); // runs per request --- <h1>Hello {user.name}</h1> ``` ```astro --- // src/pages/blog/[slug].astro — many STATIC routes generated from data at build time. import { getCollection } from "astro:content"; export async function getStaticPaths() { const posts = await getCollection("blog"); return posts.map((post) => ({ params: { slug: post.id }, props: { post } })); } const { post } = Astro.props; --- <h1>{post.data.title}</h1> ``` In Astro 6 the dev server runs the **production runtime** (Vite 7 Environment API), so dev no longer diverges from prod on Cloudflare/Bun/Deno — fewer "works in dev, breaks on deploy" surprises. Adapter choice per platform → `references/deploy-and-integrations.md`. ## Islands & client directives A `client:*` directive turns a framework component into a hydrated island. Choose the **latest** directive that still feels instant to the user — never default to `client:load`. | Directive | Hydrates when | Use for | | ----------------------- | ----------------------------------- | ---------------------------------------------------- | | `client:load` | Immediately on page load | Above-the-fold, must-be-interactive-now controls | | `client:idle` | On `requestIdleCallback` | Important but not first-paint-critical widgets | | `client:visible` | When it scrolls into view (IO) | Below-the-fold carousels, comment boxes, maps | | `client:media={query}` | When a media query matches | Mobile-only menu, desktop-only panel | | `client:only="react"` | Client-only, **no SSR HTML** | Components that crash during SSR (browser-only deps) | ```astro --- import Carousel from "../components/Carousel.tsx"; --- <!-- Bad: a below-the-fold carousel paying for JS at first paint --> <Carousel client:load /> <!-- Good: defer its bundle until the user actually scrolls to it --> <Carousel client:visible /> ``` `client:only` gotcha: it **skips SSR entirely**, so the component produces no server HTML (expect a flash/layout shift) and you **must** name the framework (`client:only="react"`) — Astro can't infer it without the server render. Reach for it only when SSR genuinely breaks; otherwise prefer `client:visible`. ## Content collections (Content Layer) Type-safe content lives in a single config file. The path is load-bearing: ```typescript // src/content.config.ts ← v6 path. NOT src/content/config.ts (legacy path removed in v6) import { defineCollection } from "astro:content"; import { z } from "astro/zod"; // v6: z moved OUT of astro:content into astro/zod (Zod 4) import { glob } from "astro/loaders"; const blog = defineCollection({ // glob() sources files from anywhere; `id` comes from the filename minus extension loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/data/blog" }), schema: z.object({ title: z.string(), pubDate: z.coerce.date(), draft: z.boolean().default(false), tags: z.array(z.string()).default([]), }), }); export const collections = { blog }; ``` Query and render in a page. `render()` is now a standalone call (not `entry.render()`): ```astro --- // src/pages/blog/[slug].astro import { getCollection, getEntry, render } from "astro:content"; export async function getStaticPaths() { const posts = await getCollection("blog", ({ data }) => !data.draft); return posts.map((post) => ({ params: { slug: post.id }, props: { post } })); } const { post } = Astro.props; const { Content } = await render(post); --- <article><h1>{post.data.title}</h1><Content /></article> ``` Built-in loaders are `glob()` (many files) and `file()` (one JSON/YAML array). Custom and CMS loaders, Zod 4 schema patterns, collection references, Live Content Collections (real-time data with no rebuild, stable in v6), querying and MDX details → `references/content-layer.md`. ## Server islands When most of a page is static and CDN-cacheable but **one fragment** is per-visitor, use a server island instead of turning the whole route into SSR. The page ships static; the island is fetched and rendered after first paint. ```astro --- // src/components/UserGreeting.astro — rendered on demand, deferred after the static shell const user = await getUserFromCookie(Astro.request); --- <span>Welcome back, {user.name}</span> ``` ```astro --- import UserGreeting from "../components/UserGreeting.astro"; --- <header> <!-- static page, one deferred personalized fragment with a placeholder while it loads --> <UserGreeting server:defer> <span slot="fallback">Welcome</span> </UserGreeting> </header> ``` This beats full SSR when: the page is otherwise cacheable on a CDN, and only a small slice depends on the request. You keep static LCP and personalize without making every request hit the origin. ## Integrations & setup Use `astro add` so it patches `astro.config.mjs` and installs peers in one step: ```bash npx astro add react mdx sitemap ``` - **Tailwind 4** wires through the official **Vite plugin** (`@tailwindcss/vite`), not the legacy `@astrojs/tailwind` integration (that path was for Tailwind 3). - **Fonts API** (stable in v6) self-hosts and optimizes fonts from `astro.config.mjs` — no manual `@font-face`. - **CSP API** (stable in v6) emits a Content-Security-Policy with hashes for your inline scripts/styles. Adapter recipes per platform, hybrid rendering, env handling, SSR endpoints (`src/pages/api/*.ts`) and the Fonts/CSP config → `references/deploy-and-integrations.md`. ## Performance rules - Images: always `<Image>`/`<Picture>` from `astro:assets` — automatic width/height, format, and lazy-loading kill CLS and over-sized payloads. Never a raw `<img>` for local assets. - Never global-hydrate: there is no "make the page interactive" switch; hydrate per island. - View transitions: add `<ClientRouter />` from `astro:transitions` to the `<head>` for SPA-like navigation without an SPA. Prefetch links with the `prefetch` config/attribute. ## Astro 5 → 6 migration checklist Run the codemod first, then verify each item: ```bash npx @astrojs/upgrade ``` - [ ] Node runtime is **`22.12.0`+** (CI image, local, deploy target). - [ ] Dependencies on **Vite 7** (Vite v7.0; custom Vite plugins/config may need updates). - [ ] Schema `z` import moved: **`import { z } from "astro/zod"`** — `z` and `astro:schema` are gone from `astro:content`. Then review for **Zod 4** breaking changes. - [ ] Content config renamed to **`src/content.config.ts`** (delete `src/content/config.ts`; the legacy path is removed, not just deprecated). - [ ] Full guide (dated 2026): `docs.astro.build/en/guides/upgrade-to/v6`. ## Anti-patterns | Anti-pattern | Reality | | -------------------------------------------------------- | --------------------------------------------------------------------------- | | "Add `client:load` so the page works" | An `.astro` page already works statically; you're shipping JS for nothing | | "`client:load` everywhere, simplest" | Pick `client:visible`/`idle`/`media`; first-paint JS is the LCP killer | | "Make the whole route SSR to personalize the header" | Use a server island (`server:defer`); keep the page static & CDN-cached | | "`src/content/config.ts` worked before, keep it" | v6 removed that path (LegacyContentConfigError) — must be `src/content.config.ts` | | "`fetch()` the CMS inside the `.astro` frontmatter" | Write a content-collection loader so content is typed, cached, and queryable | | "Pull in React just to render this static markup" | Static markup is an `.astro` component — 0 KB, no framework runtime | | "Skip the Zod schema, content is just frontmatter" | Untyped content = silent build-time drift; the schema is the contract | | "`client:only` without the framework name" | It can't infer the framework with no SSR — must be `client:only="react"` | | "Use the old `@astrojs/tailwind` for Tailwind 4" | Tailwind 4 wires through `@tailwindcss/vite`; the old integration is v3-era | ## Verify `bash scripts/verify.sh` from the Astro project root — grep-based, needs no install. It **FAILS** if a v6 project still has `src/content/config.ts` instead of `src/content.config.ts`, **WARNS** on over-hydration smells (many `client:load`, or `client:only` with no framework string), **CHECKS** that content schemas import from `astro:content`, and — only if the `astro` binary resolves — optionally runs `npx astro check`. On an empty or clean tree it prints OK and exits 0; warnings are advisory and never fail the run. ## See Also - `../nextjs/SKILL.md` — when the project is really an app-router React app with server actions and heavy client interactivity, not a content/marketing site. - `../landing-copy/SKILL.md` and `../seo-geo/SKILL.md` — this skill builds the site; those write the copy and decide the SEO/structured-data strategy that fills it. - `../vercel/SKILL.md`, `../netlify/SKILL.md`, `../cloudflare/SKILL.md` — platform mechanics (DNS, env, build settings) once the code and adapter are ready.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.