Co-authored by Paul Bratslavsky and Chris from Coding in Public
With Astro 6 now out, we wanted to take the opportunity to cover what's new and share how we updated our Astro starter for Strapi.
Overall the migration was smooth. Our only hiccup was needing to update the community loader to handle the Zod 3 to Zod 4 transition. The good news, the loader now supports Astro 6.
Astro 6 — New dev server, Node 22 minimum, Zod 4, Fonts API, live collections, built-in CSP, and an experimental Rust compiler.
Starter setup — Clone, yarn setup && yarn seed && yarn dev, and you have a working Astro 6 + Strapi 5 site with seed data out of the box.
/add-page skill — A Claude Code skill that scaffolds new pages end-to-end: Strapi content type, seed script, Astro collection, and styled templates in one command.
Chris from Coding in Public did a full walkthrough of everything that landed in Astro 6. You can watch the full video here, but we will cover all the highlights.
If you're wondering where I learned Astro — it's from Chris and his Astro course. Highly recommend it.
src/content/ directory approach is gone. Use the Content Layer API with content.config.ts and explicit loaders.schema as an async function is deprecated. Use a static schema property or createSchema() instead.<link> tags or @font-face. We use it with Roboto via Google Fonts.getLiveEntry() and getLiveCollection() in live.config.ts. Great for data that can't be stale.Breaking changes worth noting: <ViewTransitions /> → <ClientRouter />, Astro.site → import.meta.env.SITE, Zod imports unified as astro:zod. See the full Astro v6 upgrade guide.
The Zod 4 change broke our strapi-community-astro-loader — it was bundling its own Zod 3, which conflicted with Astro 6. We published v4.0.0 to fix it. The loader no longer bundles Zod, and schemas are now defined in defineCollection() instead of inside the loader:
1// Before (loader v2/v3)
2const strapiPosts = defineCollection({
3 loader: strapiLoader({ contentType: "article", schema: articleSchema, ... }),
4});
5
6// After (loader v4)
7const strapiPosts = defineCollection({
8 loader: strapiLoader({ contentType: "article", ... }),
9 schema: z.object({ ... }),
10});Under the hood, the loader uses @strapi/client, pages through all content automatically, and calls Astro's parseData() for Zod validation. It also uses generateDigest() to fingerprint entries so Astro can skip unchanged content on rebuilds. Full source is on GitHub.
If you want to get up and running with Astro 6 and and Strapi 5 quickly, clone the astro-strapi-example-project and run three commands:
git clone https://github.com/PaulBratslavsky/astro-strapi-example-project.git
cd astro-strapi-example-project
yarn install
yarn setup
yarn seed
yarn dev Both the Astro and Strapi servers start together. The seed script populates your Strapi instance with content so you have a working site immediately — no manual data entry needed.
The starter includes:
@theme directive, custom design tokens)The starter includes a Claude Code skill that lets you add new pages without manually wiring up Strapi schemas, Astro collections, and page routes. One command scaffolds the full stack — from database schema to styled frontend.
The skill supports two page architectures:
The starter already includes a community page and workshops collection that were built this way — a single /add-page command created the Strapi content types, seed scripts, Astro collections, and styled pages for both.
The skill handled both a collection page (workshops at /workshops) and a block-based page (community rendered via the catch-all [slug]/index.astro route) in one pass.
The skill lives in .claude/skills/add-page/ and comes with the starter. If you want to use it in a different Astro + Strapi project, copy it over:
# From the starter repo, copy the skill into your project
cp -r .claude/skills/add-page /path/to/your-project/.claude/skills/add-pageOr make it available globally across all your projects:
cp -r .claude/skills/add-page ~/.claude/skills/add-pageProject-level skills live in .claude/skills/, global skills live in ~/.claude/skills/. Claude Code checks both locations.
The skill is just a markdown file — you can open .claude/skills/add-page/SKILL.md and update it for your own stack, design patterns, or conventions. Or use it as a starting point to create entirely new skills. For a deeper look at what agent skills are and how to build your own, check out What Are Agent Skills and How to Use Them.
Let's build a FAQ page from scratch using the /add-page skill so you can see the full workflow.
Open Claude Code in the project root and run:
1> /add-page faq
2
3Create a FAQ page with two parts:
4
51. A landing page at /faqs with a hero section with a heading
6"Frequently Asked Questions" and subtext, followed by all FAQs
7grouped by category with expandable accordion-style sections.
8
92. A FAQ collection type with individual FAQ entries. Each FAQ
10should have: question (string), answer (richtext), category
11(enum: getting-started, content-management, deployment,
12customization), and sortOrder (integer). Create 8 sample FAQs
13(2 per category). The skill will:
server/src/api/faq/ with schema, controller, routes, and servicestrapiFaqs in content.config.ts with a Zod schema and populate configclient/src/pages/faqs/ that groups FAQs by category with accordion sectionsOnce it finishes, seed and restart.
Then visit http://localhost:4321/faqs to see the result.
Curious how we handle data loading in our Strapi starter? Whether you're using the /add-page skill or building pages manually, understanding the data pipeline will help you get the most out of the project.
We demonstrate two ways to load data from Strapi: the content loader and the Strapi client. You could use the loader for everything, but we wanted to include both approaches as examples.
For collection types like articles and pages, we use the loader inside content.config.ts. Each collection pairs a loader (what to fetch) with a Zod schema (how to validate it). We use reusable schemas like imageSchema to keep things DRY:
1// content.config.ts
2import { defineCollection, z } from "astro:content";
3import { strapiLoader } from "strapi-community-astro-loader";
4
5const clientConfig = {
6 baseURL: import.meta.env.STRAPI_BASE_URL || "http://localhost:1337/api",
7};
8
9const imageSchema = z.object({
10 url: z.string(),
11 alternativeText: z.string().nullable().optional(),
12});
13
14const strapiPosts = defineCollection({
15 loader: strapiLoader({
16 contentType: "article",
17 clientConfig,
18 params: {
19 fields: ["title", "slug", "description", "content", "publishedAt"],
20 populate: {
21 featuredImage: { fields: ["url", "alternativeText"] },
22 author: {
23 fields: ["fullName"],
24 populate: { image: { fields: ["url", "alternativeText"] } },
25 },
26 },
27 },
28 }),
29 schema: z.object({
30 title: z.string(),
31 slug: z.string(),
32 description: z.string().nullable().optional(),
33 content: z.string().nullable().optional(),
34 publishedAt: z.string().nullable().optional(),
35 featuredImage: imageSchema.optional(),
36 author: z.object({
37 fullName: z.string(),
38 image: imageSchema.optional(),
39 }).optional(),
40 }),
41});
42
43export const collections = { strapiPosts, strapiPages, strapiWorkshops };The params object controls what Strapi sends back — only the fields and relations your templates actually use. The schema validates every document at build time, so you catch mismatches early instead of shipping broken pages.
Then you query it in pages — everything is fully typed from the Zod schema:
1---
2// blog/[slug].astro
3import { getCollection } from "astro:content";
4
5export async function getStaticPaths() {
6 const collection = await getCollection("strapiPosts");
7 return collection.map((article) => ({
8 params: { slug: article.data.slug },
9 props: article.data,
10 }));
11}
12
13const { featuredImage, author, title, content } = Astro.props;
14---
15
16<h1>{title}</h1>
17{author && <p>By {author.fullName}</p>}For single types like global settings (header, footer, banner) that don't map to a content collection, we use @strapi/client directly:
1// utils/strapi-client.ts
2import { strapi } from "@strapi/client";
3
4const BASE_API_URL = (import.meta.env.STRAPI_BASE_URL ?? "http://localhost:1337") + "/api";
5const strapiClient = strapi({ baseURL: BASE_API_URL });
6
7// Query single types with nested populate
8const data = await strapiClient.single("global").find({
9 populate: {
10 header: {
11 populate: {
12 logo: { populate: { image: { fields: ["url", "alternativeText"] } } },
13 navItems: true,
14 cta: true,
15 },
16 },
17 },
18});Same @strapi/client package under the hood — the loader just wraps it with Astro's content layer. Both use a single STRAPI_BASE_URL environment variable.
Getting populate right makes a real difference in payload size. Three patterns we use:
1// 1. Only fetch specific fields
2fields: ["title", "slug", "publishedAt"]
3
4// 2. Populate a relation with field selection
5populate: {
6 author: {
7 fields: ["fullName"],
8 populate: { image: { fields: ["url", "alternativeText"] } },
9 },
10}
11
12// 3. Handle dynamic zones with the "on" syntax
13populate: {
14 blocks: {
15 on: {
16 "blocks.hero": {
17 populate: { image: { fields: ["url", "alternativeText"] } },
18 },
19 "blocks.card-grid": { populate: { card: true } },
20 "blocks.markdown": true,
21 },
22 },
23}Without fields, Strapi returns every column. Without targeted populate, you get either nothing or everything. For a deep dive, check out Demystifying Strapi's Populate and Filtering.
Alternative approach: Instead of managing populate configs from the Astro side, you can handle it entirely in Strapi using route-based middleware. This lets you define default population logic server-side so every API response comes back fully populated — no populate params needed from the client. See Route-Based Middleware to Handle Default Population for how to set that up.
Strapi returns relative image paths like /uploads/photo_abc123.jpg. Our StrapiImage component resolves them to full URLs:
1---
2// components/StrapiImage.astro
3import { Image as AstroImage } from "astro:assets";
4
5const BASE_URL = import.meta.env.STRAPI_BASE_URL ?? "http://localhost:1337";
6const { src, alt, height, width, class: className } = Astro.props;
7
8function getStrapiMedia(url: string | null) {
9 if (url == null) return null;
10 if (url.startsWith("http") || url.startsWith("//") || url.startsWith("data:")) return url;
11 return `${BASE_URL}${url}`;
12}
13---
14
15{getStrapiMedia(src) && (
16 <AstroImage src={getStrapiMedia(src)} alt={alt || "No alternative text"}
17 height={height} width={width} class={className ?? undefined} />
18)}Handles absolute, relative, and data URLs. Astro's <Image /> handles optimization from there.
The loader is open source at github.com/PaulBratslavsky/strapi-community-astro-loader — issues and PRs welcome. The full Astro starter is at github.com/PaulBratslavsky/astro-strapi-example-project.
Want to go deeper on Astro? Chris from Coding in Public has an Astro course he's currently updating for v6.