Astro 5 to 6 Upgrade: Fix slug→id and Zod 4 Errors Fast
Three checks before bumping to Astro 6: grep .slug, one content.config.ts, no .default() after .transform(). Node 22.12+ and the fix for each error. →
Software engineer · Málaga

On this page
Short answer: before you bump to Astro 6, run three checks. grep -rn '\.slug' src: every hit becomes .id. Make sure your schema lives at src/content.config.ts and nowhere else. And look for any .default() that comes after a .transform(), because in Zod 4 the default has to match the transformed type. Add Node 22.12 or newer and you’ve covered the changes that bite.
I upgraded this very site from Astro 5 to Astro 6. The plan was a quick pnpm up astro, a glance at the changelog, and a celebratory coffee.
I did not get the coffee.
I spent a good while staring at a ContentSchemaContainsSlugError that read like a riddle, and another stretch untangling a Zod default that compiled fine in v5 and exploded in v6.
Here is the good news before the war story: v6 is mostly an evolution of v5, not a rewrite. Same Content Layer, same Server Islands. What changed is that the grace periods are over. The legacy escape hatches that let you limp along on v4-shaped code in v5 are gone. So if your v5 upgrade was half-finished, v6 is where the bill comes due.
A note on dates: Astro 6 went stable on 10 March 2026, so the upgrade I describe came after this post’s original publication date (December 2025). The misplaced content.config.ts part does date from November 2025, still on Astro 5.15.
This post is the upgrade path I actually walked, v5 to v6, with the breaking changes that bit me first. If you are coming straight from Astro 4, jump to the section at the bottom; the v5 foundations still apply and you will want them first.
What breaks going from 5 to 6 (the 60-second version)
Everything below is from the official upgrade to v6 guide, cross-checked against my own diff:
- Node
v22.12.0is the new minimum, and only even-numbered releases are supported. v23 will not run it. src/content.config.tsis mandatory if you use content collections. The oldsrc/content/config.tslocation is no longer read.- The
legacy.collectionsflag is gone. If you were leaning on it in v5, that crutch has been kicked away. - The identifier is
post.id, notpost.slug. The Content Layer madeidthe identifier back in v5; v6 just removes the legacy collections fallback, so there is noslugleft to lean on. Aslugfield in a schema throwsContentSchemaContainsSlugError. Astro.glob()is removed. Replace it withimport.meta.glob()(which no longer returns a Promise) orgetCollection().- Zod is on v4. Default values must match the output type after transforms, and
astro:schemaimports move toastro/zod. getEntryBySlug()andgetDataEntryById()are deprecated in favor of a singlegetEntry().
If you only read one line: the v5 to v6 jump is not about new toys. It is about finally deleting the compatibility shims you have been ignoring.
The slug to id trap (the one that cost me an hour)
This is where v6 hit me hardest, because the error message points at your schema, not at the real culprit.
In Astro 4, every content entry had a slug, and most of us wired it straight into getStaticPaths. Astro 5’s Content Layer already moved the identifier to id, but the legacy collections path kept slug limping along, so sloppy code survived. Astro 6 removes that legacy path. No more slug to fall back on.
Here is the diff that fixed my build:
---
// src/pages/blog/[...slug].astro
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
// Astro 5 leftover: post.slug is gone in v6
// params: { slug: post.slug },
params: { slug: post.id },
props: post,
}));
}
---
If your schema also declares a slug field, that is what trips ContentSchemaContainsSlugError. Remove it and let the glob loader derive the id from the filename. Do a global search for .slug before you upgrade. It is the difference between a quick fix and an afternoon.
Zod 4: the default that lies to you
The second wall. In Zod 3 (Astro 5), a default value matched the input type. In Zod 4 (Astro 6), it has to match the output type, the one you get after transforms run.
So this, which I had copied from my own homepage view counter, broke:
// Astro 5, Zod 3: default is the pre-transform string
views: z.string().transform(Number).default("0"),
// Astro 6, Zod 4: default must match the post-transform number
views: z.string().transform(Number).default(0),
While you are in there, fix the imports too. astro:schema is deprecated in v6:
// Before
import { z } from "astro:schema";
// After
import { z } from "astro/zod";
You do not strictly need the astro/zod path. zod is a direct dependency in Astro 6, so plain import { z } from "zod" works too, and it is what I run in production. astro/zod just re-exports the exact Zod version Astro validates your schemas against, so you cannot drift onto a mismatched copy.
.nonempty() on strings is also gone in Zod 4. Swap it for .min(1).
Getting these type boundaries right (defaults matching the post-transform type, no silent string/number drift) is the same discipline I lean on across the codebase; I collected the rest in TypeScript best practices.
The removed APIs, and what to use instead
Astro.glob() was deprecated back in v5 and is now fully removed. The replacement depends on what you were globbing:
// Querying content collections? Use the Content Layer.
const posts = await getCollection('blog');
// Globbing arbitrary source files? import.meta.glob, and note:
// it no longer returns a Promise, so drop the await.
const modules = import.meta.glob('./data/*.json', { eager: true });
For collection lookups, getEntryBySlug() and getDataEntryById() both collapse into one function:
// Before
const post = await getEntryBySlug('blog', 'my-post');
// After
const post = await getEntry('blog', 'my-post');
And the cleanup that took ten seconds but felt the best: deleting the legacy block from astro.config.mjs.
import { defineConfig } from 'astro/config';
export default defineConfig({
// Delete this whole block. It does nothing in v6 except remind
// you that you put off the real migration.
legacy: {
collections: true,
},
});
Missing schema fields? Check where content.config.ts lives
On Astro 5 this one failed silently, which is the worst way to fail. I added a relatedSlug field to my blog schema, put it in the frontmatter, and it never showed up: not in the types, not in post.data. Making it required didn’t throw. Renaming it didn’t help. When even a required field is ignored, Astro isn’t reading your config at all.
The cause was one slash. Back in November 2025, on Astro 5.15, I was editing src/content/config.ts while an older src/content.config.ts sat at the root of src/, and Astro read that one. v6 is kinder: a leftover src/content/config.ts now throws LegacyContentConfigError instead of being ignored. To confirm which file Astro actually loads, look at the generated types:
// .astro/content.d.ts
export type ContentConfig = typeof import("../src/content.config.js");
The checklist, before you blame the framework:
- Location.
src/content.config.ts, at the root ofsrc/, not insidesrc/content/. - Name. Exactly
content.config.ts. Any other variation is ignored. - No duplicates.
find src -path '*content*' -name 'config.*'should only find one file. Merge the schema into the real one and delete the other. - Exports. The file has to
export const collections. - Cache. Delete
.astro/and restart the dev server so the types regenerate from the right file.
content.config.ts: the glob() loader and defineCollection
None of the above is the reason to upgrade. The reason is the thing v5 introduced and v6 makes the only way: the Content Layer is a real data pipeline, not just a Markdown reader. It all lives in content.config.ts, where every collection is a defineCollection paired with a loader. For local Markdown that loader is glob() (shown in the v4 section below); the power is that you can swap it for one you write yourself.
The glob loader you wire up in content.config.ts is just a function that returns an array of objects. Which means you can write your own. Here is the loader I use to pull GitHub stars into my projects section, with no top-level fetch() rotting inside a component:
// src/content.config.ts
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
const githubLoader = {
name: 'github-repos',
load: async ({ store, logger }) => {
logger.info('Fetching repos from GitHub...');
const response = await fetch('https://api.github.com/users/garbarok/repos');
const repos = await response.json();
for (const repo of repos) {
store.set({
id: repo.name,
data: {
description: repo.description,
stars: repo.stargazers_count,
url: repo.html_url,
},
});
}
},
};
const projects = defineCollection({
loader: githubLoader,
schema: z.object({
stars: z.coerce.number(),
url: z.string().url(),
}),
});
export const collections = { projects };
In my components I just call getCollection('projects'). The frontend has no idea the data came from an API, and Astro caches it at build time. That separation is what the migration buys you.
Server Islands (server:defer) are the other v5 idea that carries straight into v6 unchanged. A static shell on the CDN with a dynamic hole that gets filled by a tiny per-island fetch. No hydration, no client React. If you skipped it in v5, v6 is a fine excuse to finally use it.
The honest part: where v6 is annoying
I promised a war story, not a brochure.
The ContentSchemaContainsSlugError message is genuinely bad. It blames your schema when the real problem is usually a params: { slug: post.slug } three files away. I lost time chasing the wrong line.
The Zod 4 default change is the kind of break that compiles, passes a quick smoke test, and then serves wrong data in production because "0" and 0 both look fine until something downstream does math. Test your defaults, not just your types.
And the import.meta.glob() “no longer returns a Promise” change is easy to miss because removing an await from working code feels wrong. It will not error loudly. It will just hand you a Promise where you expected data.
None of these are dealbreakers. But anyone selling v6 as a frictionless bump has not migrated a real project. (Astro 7 was the opposite: a version bump and a coffee.)
Still on Astro 4? The v4 to v5 story
Everything above assumes you already made the v4 to v5 jump. If you did not, this is the part that still matters, because the Content Layer was born here.
In Astro 4, you defined collections with a magic string:
// Astro 4, the old way
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content', // "look for files, somehow"
schema: z.object({ /* ... */ }),
});
Astro 5 stopped believing in magic and asked for explicit loaders:
// Astro 5+, the new way
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
pubDate: z.date(),
}),
});
Two more v4-era traps that v6 simply assumes you have already fixed:
The output: 'hybrid' mode is gone. Astro is now static by default with opt-in SSR, or server by default with opt-in static. Delete hybrid from your config.
And post.render() no longer exists. Content entries are plain objects now, so you import the renderer:
import { render } from 'astro:content';
const { Content } = await render(post);
Do that work, get a green v5 build, and the v6 step at the top of this post becomes a quiet afternoon instead of a bad one.
Related reading: if you are pairing with an AI assistant for this kind of multi-file refactor, my Google Antigravity review covers the agent-first IDEs I tried in November 2025, during the Astro 5 work described above.
Related reading: if you want the Astro 6 overview first (what it is, islands, and why it ships so little JavaScript) before migrating, start with the Astro 6 introduction.
Sources
- Astro docs: Upgrade to v6
- Astro docs: Content Collections
- Astro, Astro 6.0 announcement (stable release date).
P.S. Have you taken the v6 step yet? Did the slug to id change break your heart twice, once in v5 and once in v6? Tell me on LinkedIn.
Frequently asked questions
- Do I have to go through v5 to get to v6?
- Yes, in practice. The upgrade guides are sequential, and v6 assumes your collections already use the Content Layer. If you are on v4, follow the steps in "Still on Astro 4?" above, get a green v5 build, then take the v6 step.
- Can I keep the legacy collections flag a little longer?
- No.
legacy.collectionsis removed, and a leftoversrc/content/config.tsthrowsLegacyContentConfigError. v6 does offerlegacy.collectionsBackwardsCompatas a temporary migration helper, but plan to move tosrc/content.config.tsand drop it. - Can I use Server Islands on Vercel or Netlify?
- Yes, with the matching adapter installed. The island is a serverless function that returns HTML, so you need a host that runs functions. Nothing about that changed in v6.
Last updated:


