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.
- name
astro-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
- The product is an app with heavy client state on every page — use nextjs-app-router or sveltekit-apps
- You only need page transition polish — use web-animations
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.
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 noslugfield in the Content Layer. Set a custom ID with aslugfrontmatter 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 syncafter schema changes if types look stale. - Sort explicitly —
getCollectionorder is not guaranteed:posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()).
Islands: choosing a directive
| Directive | Hydrates | Use for |
|---|---|---|
| none | Never (static HTML) | Anything without interactivity — default |
client:visible | When scrolled into view | Below-the-fold widgets, comments, carousels |
client:idle | After page load, on requestIdleCallback | Non-critical above-the-fold widgets (newsletter form) |
client:load | Immediately | Critical interactive UI the user touches first (search box, nav toggle) |
client:media="(max-width: 48em)" | When the media query matches | Mobile-only menus |
client:only="react" | Client only, no SSR | Components that cannot render on the server (canvas, window at import) |
- Directives only work on framework components imported into
.astrofiles, 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
| Need | Config |
|---|---|
| Pure static site | output: "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 dynamic | output: "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 inpublic/are copied as-is. - Remote images need
image.domainsorimage.remotePatternsin 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:persistkeeps 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 callactions.subscribe(formData)from an island. src/middleware.tswithdefineMiddlewaresetscontext.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.astrocomponents are bundled and deduplicated; addis:inlineonly for scripts that must stay exactly as written (analytics snippets).- Styles in
.astroare scoped by default; use:global()oris:globaldeliberately. - 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
npx astro checkpasses (types and content schemas).npx astro build— read the output: each page listed as prerendered or on-demand as intended; no schema errors.- Inspect
dist/(or the network tab inastro preview): pages without islands ship no framework JS; island bundles are only loaded where used. - 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.