Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download ericrisco-rsc-harness-skills_astro-953fef5.zip · 13 KB
Part of ericrisco/rsc-harness — 46 skills

Install

skills CLI npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/astro
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
Git 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.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.

---
// 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/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:

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.
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.

No comments yet.

Reviews (0)

No reviews yet.

Related