Astro Sites skill for Claude Code

Static HTML first, hydrate only what users touch. Claude builds Astro sites that stay static by default and ship framework JavaScript only for real widgets, choosing client:visible or client:idle before client:load. Content lives in typed Content Layer collections validated at build time, images go through astro:assets, and SSR is enabled per route with an adapter rather than flipping the whole site to server output.

  • nameastro-sites
  • size216 lines · ≈2,200 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds Astro 5+ sites that ship minimal JavaScript — Content Layer collections with glob/file loaders and Zod schemas, islands with the right client directive, server islands, view transitions, astro:assets images, Actions, and static vs. on-demand rendering with adapters. Use when creating or extending an Astro project, adding a blog or docs collection, hydrating React/Vue/Svelte components inside .astro pages, enabling page transitions, or switching a page or site from static to SSR.

Try asking

  • Add a blog to our Astro site with typed frontmatter, tags and author references
  • Our Astro pages ship 300 KB of React — figure out which islands can hydrate later
  • Make the pricing page server-rendered but keep the rest of the site static

Use it when

  • Starting or extending an Astro marketing, docs or blog site
  • Defining content collections or migrating from legacy collections or Astro.glob
  • Embedding React, Vue or Svelte components as islands
  • Moving specific routes to on-demand rendering

Skip it when

How to check the result

astro check passes, the build output lists each route as prerendered or on-demand as intended, and pages without islands load no framework JavaScript.

The common mistake it prevents

Importing a framework component without a client directive and wondering why its click handlers never fire, since it renders as inert HTML.

~/.claude/skills/astro-sites/SKILL.md

--- name: astro-sites description: Builds Astro 5+ sites that ship minimal JavaScript — Content Layer collections with glob/file loaders and Zod schemas, islands with the… license: MIT ---

A good Astro site is static HTML by default, hydrates only the components that need interaction with the laziest directive that still feels instant, validates every content entry against a schema at build time, and opts into server rendering per route only when the data truly changes per request.

Before changing anything

npm ls astro @astrojs/react @astrojs/vue @astrojs/svelte   # version + UI integrations
cat astro.config.* | head -40                                # output mode, adapter, integrations
ls src/content.config.* src/content/config.* 2>/dev/null     # Content Layer vs. legacy collections
npx astro check                                              # type + content schema errors

src/content/config.ts with type: "content" is the legacy collections API and Astro.glob() is deprecated; migrate to src/content.config.ts with loaders when touching collections.

Workflow

- [ ] 1. Decide rendering per route: prerendered (default) or on-demand
- [ ] 2. Model content as collections with schemas
- [ ] 3. Build pages as .astro components (zero JS)
- [ ] 4. Add islands only for interactive widgets; pick the directive
- [ ] 5. Optimize images with astro:assets
- [ ] 6. Add transitions, actions, middleware as needed
- [ ] 7. astro check, astro build, inspect output JS size

Content collections (Content Layer)

// src/content.config.ts
import { defineCollection, reference } from "astro:content";
import { glob, file } from "astro/loaders";
import { z } from "astro/zod";

const blog = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
  schema: ({ image }) =>
    z.object({
      title: z.string().max(80),
      description: z.string().max(160),
      pubDate: z.coerce.date(),
      updatedDate: z.coerce.date().optional(),
      draft: z.boolean().default(false),
      cover: image().optional(),
      author: reference("authors"),
      tags: z.array(z.string()).default([]),
    }),
});

const authors = defineCollection({
  loader: file("src/data/authors.json"),
  schema: z.object({ id: z.string(), name: z.string(), url: z.string().url().optional() }),
});

export const collections = { blog, authors };
---
// src/pages/blog/[...id].astro
import { getCollection, getEntry, render } from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";

export async function getStaticPaths() {
  const posts = await getCollection("blog", ({ data }) => import.meta.env.PROD ? !data.draft : true);
  return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}

const { post } = Astro.props;
const author = await getEntry(post.data.author);
const { Content, headings } = await render(post);
---
<BaseLayout title={post.data.title} description={post.data.description}>
  <article>
    <h1>{post.data.title}</h1>
    <p>By {author?.data.name} · <time datetime={post.data.pubDate.toISOString()}>{post.data.pubDate.toLocaleDateString("en-US")}</time></p>
    <Content />
  </article>
</BaseLayout>
  • Entries use id (derived from the file path); there is no slug field in the Content Layer. Set a custom ID with a slug frontmatter key if URLs must differ.
  • Remote data (CMS, API) uses a custom or community loader instead of fetching in every page; it caches between builds and is typed by the same schema.
  • Run npx astro sync after schema changes if types look stale.
  • Sort explicitly — getCollection order is not guaranteed: posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()).

Islands: choosing a directive

DirectiveHydratesUse for
noneNever (static HTML)Anything without interactivity — default
client:visibleWhen scrolled into viewBelow-the-fold widgets, comments, carousels
client:idleAfter page load, on requestIdleCallbackNon-critical above-the-fold widgets (newsletter form)
client:loadImmediatelyCritical interactive UI the user touches first (search box, nav toggle)
client:media="(max-width: 48em)"When the media query matchesMobile-only menus
client:only="react"Client only, no SSRComponents that cannot render on the server (canvas, window at import)
  • Directives only work on framework components imported into .astro files, not inside another framework component.
  • Props passed to islands are serialized into HTML — keep them small and never pass secrets or functions.
  • Share state between islands with a tiny store (nanostores) or custom events; islands do not share a React/Vue tree or context.
  • Server islands: <UserMenu server:defer><GuestMenu slot="fallback" /></UserMenu> render a personalized piece on demand while the page stays cached/static. Requires an adapter.

Rendering mode

NeedConfig
Pure static siteoutput: "static" (default), no adapter
Mostly static, a few dynamic routes (API, auth pages)Keep static, add an adapter, put export const prerender = false in those routes
Mostly dynamicoutput: "server" + adapter; mark static pages export const prerender = true

Add adapters with npx astro add node (or vercel, netlify, cloudflare). Astro 5 removed output: "hybrid"; its behavior is now static plus per-route prerender = false.

Images

---
import { Image } from "astro:assets";
import hero from "../assets/hero.jpg";
---
<Image src={hero} alt="Team reviewing a release checklist" widths={[480, 960, 1440]} sizes="(max-width: 768px) 100vw, 960px" loading="eager" fetchpriority="high" />
  • Images under src/ are optimized at build; files in public/ are copied as-is.
  • Remote images need image.domains or image.remotePatterns in config to be optimized.
  • Mark only the LCP image loading="eager" + fetchpriority="high"; the rest lazy-load by default.

View transitions

  • Cross-document (no JS router): add to global CSS and keep MPA behavior:
@view-transition { navigation: auto; }
  • SPA-like transitions with persistence: import { ClientRouter } from "astro:transitions" and render <ClientRouter /> in the <head> of the layout (it replaced the <ViewTransitions /> component). Then:
    • transition:name="hero-{id}" pairs elements across pages (names must be unique per page).
    • transition:persist keeps an island’s state (audio player) across navigations.
    • Inline and module scripts do not re-run on client navigation; move setup into a document.addEventListener("astro:page-load", ...) handler.
  • Respect reduced motion; Astro’s built-in animations do, custom ones must (see web-animations).

Actions, middleware, env

// src/actions/index.ts
import { defineAction, ActionError } from "astro:actions";
import { z } from "astro/zod";

export const server = {
  subscribe: defineAction({
    accept: "form",
    input: z.object({ email: z.string().email() }),
    handler: async ({ email }, context) => {
      const ok = await addSubscriber(email);
      if (!ok) throw new ActionError({ code: "CONFLICT", message: "Already subscribed" });
      return { email };
    },
  }),
};
  • Use in a form with <form method="POST" action={actions.subscribe}> (the page must be on-demand rendered) or call actions.subscribe(formData) from an island.
  • src/middleware.ts with defineMiddleware sets context.locals (auth, locale); it runs at build time for prerendered pages, so do not depend on request headers there.
  • Declare env vars with astro:env (envField.string({ context: "server", access: "secret" })) so missing or misplaced secrets fail at startup.

Gotchas

  • Frontmatter code runs at build time for static pages. new Date() there is the build date; anything per-request needs on-demand rendering or client code.
  • <script> tags in .astro components are bundled and deduplicated; add is:inline only for scripts that must stay exactly as written (analytics snippets).
  • Styles in .astro are scoped by default; use :global() or is:global deliberately.
  • A React component imported without a client:* directive renders to static HTML and its handlers silently never run.
  • Trailing slash behavior (trailingSlash) must match the host’s redirects, or you get duplicate URLs and redirect chains.

Verification

  1. npx astro check passes (types and content schemas).
  2. npx astro build — read the output: each page listed as prerendered or on-demand as intended; no schema errors.
  3. Inspect dist/ (or the network tab in astro preview): pages without islands ship no framework JS; island bundles are only loaded where used.
  4. Lighthouse or WebPageTest on a representative page: LCP image eager, total JS within budget (see core-web-vitals).

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc