Astro Sites skill for Codex
Static by default, islands only where they earn it. Codex builds Astro sites that ship no JavaScript unless an island needs it, picks client:visible or client:idle over client:load wherever possible, and types every content collection so bad frontmatter breaks the build. Personalised fragments become server islands on otherwise static pages, only truly dynamic routes render on demand, and older projects get walked through the Astro 6 content and Zod 4 changes.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- astro-sites Installs to ~/.agents/skills/astro-sites/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install astro-sites The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project astro-sites Installs to .agents/skills/astro-sites/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/astro-sites.zipunzip -o astro-sites.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) astro-sites PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $astro-sites, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds Astro 5 and 6 sites - content collections with the Content Layer (glob and file loaders, Zod schemas from astro/zod, render()), islands with the right client directive, server islands with server:defer, per-page prerendering versus on-demand rendering, actions, astro:env, images and adapters for Node, Vercel, Netlify and Cloudflare. Use when creating an Astro blog, docs or marketing site, adding a framework component, moving content into collections, upgrading from older content APIs, or deciding what renders statically versus on request.
Use it when
- Building a blog, docs or marketing site in Astro
- Adding React, Vue, Svelte or Solid components as islands
- Deciding which pages prerender and which render per request
- Migrating legacy content collections or upgrading Astro majors
Use something else when
- The site needs heavy client-side app state across every page: use nextjs-app-router or sveltekit
- Only image formats and srcset are the problem: use responsive-images
How to check the result
astro check and astro build pass, pages without islands ship no JS files in dist, and an invalid frontmatter field fails the build with the field named.
The mistake it prevents
Putting client:load on every component out of habit, which turns a zero-JS static page into a full hydration bundle.
Done well means: pages ship zero JavaScript unless an island needs it, every island uses the laziest client directive that still works, content is typed through collection schemas so a bad frontmatter field fails the build, and only the routes that need per-request data render on demand.
Inspect first
- Read
AGENTS.md,package.json(astromajor; v6 requires Node 22.12+) andastro.config.mjs:output,adapter,integrations,site,env. - Find the collections config:
src/content.config.ts(Content Layer, v5+). Asrc/content/config.tsfile means the legacy API, which Astro 6 removed. - Check which UI frameworks are installed (
@astrojs/react,vue,svelte,solid-js,preact). Do not add a second framework for one widget.
Content collections
// 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(90),
description: z.string().max(160),
pubDate: z.coerce.date(),
updated: 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({ name: z.string(), url: z.url().optional() }),
});
export const collections = { blog, authors };
---
// src/pages/blog/[...id].astro
import { getCollection, getEntry, render } from "astro:content";
import Layout from "../../layouts/Post.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => import.meta.env.DEV || !data.draft);
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);
---
<Layout title={post.data.title} description={post.data.description}>
<p>By {author?.data.name}</p>
<Content />
</Layout>
- Entries have
id(path-based forglob), notslug. Userender(entry), notentry.render(). - Import
zfromastro/zod(Astro 6 uses Zod 4:z.email(),z.url()). Older projects import it fromastro:content, which still works but is deprecated. getCollectionorder is not guaranteed; sort explicitly (b.data.pubDate - a.data.pubDate).- Custom loaders can pull from a CMS or API at build time. For content that must be fresh
per request, use live collections (
src/live.config.ts,getLiveCollection), which require on-demand rendering.
Islands
| Component needs | Directive |
|---|---|
| No interactivity | none (renders to HTML, ships no JS) |
| Interactive immediately above the fold (nav menu, search box) | client:load |
| Interactive but not urgent (chat widget, cookie banner) | client:idle |
| Below the fold (comments, carousel) | client:visible |
| Only on some viewports (mobile menu) | client:media="(max-width: 768px)" |
Cannot render on the server (uses window at import) | client:only="react" |
<SearchBox client:load placeholder="Search docs" />
<Comments client:visible postId={post.id} />
- Props passed to islands are serialized: no functions, no class instances. Pass data, and define event handlers inside the island.
- Islands do not share React context with each other. Share state through a small store (nanostores) or URL, not a provider wrapping the page.
- Prefer a plain
<script>in an.astrocomponent for small behaviour (toggle, copy button); it is bundled and deduplicated without a framework runtime.
Static, on-demand and server islands
Default output: 'static' prerenders everything. Add an adapter to render some routes
on demand:
---
// src/pages/account.astro
export const prerender = false; // this route renders per request
const user = Astro.locals.user;
if (!user) return Astro.redirect("/login");
---
Server islands keep a page static and cached while deferring one personalised part:
<ProductDetails product={product} />
<UserCartSummary server:defer>
<p slot="fallback">Loading your cart...</p>
</UserCartSummary>
| Page content | Strategy |
|---|---|
| Same for everyone, changes on deploy | prerendered (default) |
| Same for everyone except a small personalised block | prerendered + server:defer island |
| Different per user or per request throughout | prerender = false |
| Mostly dynamic app | output: 'server' and opt pages back in with prerender = true |
Server islands and prerender = false both need an adapter. Props to a server island are
encrypted into the URL; keep them small.
Actions, env and images
// 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.email() }),
handler: async ({ email }, ctx) => {
if (await isRateLimited(ctx.clientAddress)) throw new ActionError({ code: "TOO_MANY_REQUESTS" });
await addSubscriber(email);
return { ok: true };
},
}),
};
- Call from a form (
<form method="POST" action={actions.subscribe}>) on an on-demand page, or from client code withconst { data, error } = await actions.subscribe(formData). - Declare env vars in
astro.config.mjswithenv.schemaandenvField(context: 'server', access: 'secret'), then import fromastro:env/server. Secrets never go inPUBLIC_*vars. - Use
<Image />/<Picture />fromastro:assetswith local imports orimage()schema fields; remote images needimage.domainsorimage.remotePatternsin config. Always setalt. - Page transitions:
<ClientRouter />fromastro:transitions(the old<ViewTransitions />name is removed in v6).
Adapters
npx astro add node # or vercel, netlify, cloudflare; needs network
@astrojs/node needs mode: 'standalone' (own server) or 'middleware' (inside
Express/Fastify). Adding or switching an adapter edits config; deploying is
outward-facing, so build locally and ask before any deploy command.
Upgrading to Astro 6
- [ ] Node 22.12+ locally and in CI
- [ ] npx @astrojs/upgrade (needs network)
- [ ] Move src/content/config.ts to src/content.config.ts with loaders; slug -> id,
entry.render() -> render(entry), getEntryBySlug -> getEntry
- [ ] z from astro/zod; z.string().email() -> z.email(); check .default() types
- [ ] <ViewTransitions /> -> <ClientRouter />; Astro.glob -> import.meta.glob
- [ ] Bump adapters to their matching major; re-read Cloudflare adapter notes
- [ ] astro check && astro build; diff the dist/ route list against before
Verification
npx astro check # types, collection schemas, .astro templates
npx astro build && npx astro preview
- Inspect
dist/: static pages exist as HTML; count JS files and confirm pages without islands ship none. - Lighthouse or DevTools Network on a key page: only the islands you intended hydrate.
- Introduce an invalid frontmatter value in a scratch copy; the build must fail with the field name.
Output
Report collections and their schemas, pages and their render strategy (static, on-demand, server island), islands with directives, required env vars, and the adapter.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Astro Sites"
short_description: "Static by default, islands only where they earn it"
default_prompt: "$astro-sites Set up a typed blog collection in Astro with authors and tags" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.