# Astro 6 Migration Guide (2026): from Astro 5

> I upgraded this site from Astro 5 to Astro 6. The breaking changes that bit me: content.config.ts, the slug to id trap, Zod 4 defaults, removed APIs. →

I upgraded this very site from Astro 5 to Astro 6 last weekend. 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 the first hour staring at a `ContentSchemaContainsSlugError` that read like a riddle, and another forty minutes 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.

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](#still-on-astro-4-the-v4-to-v5-story); 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](https://docs.astro.build/en/guides/upgrade-to/v6/), cross-checked against my own diff:

- **Node `v22.12.0` is the new minimum**, and only even-numbered releases are supported. v23 will not run it.
- **`src/content.config.ts` is mandatory** if you use content collections. The old `src/content/config.ts` location is no longer read.
- **The `legacy.collections` flag is gone.** If you were leaning on it in v5, that crutch has been kicked away.
- **The identifier is `post.id`, not `post.slug`.** The Content Layer made `id` the identifier back in v5; v6 just removes the legacy collections fallback, so there is no `slug` left to lean on. A `slug` field in a schema throws `ContentSchemaContainsSlugError`.
- **`Astro.glob()` is removed.** Replace it with `import.meta.glob()` (which no longer returns a Promise) or `getCollection()`.
- **Zod is on v4.** Default values must match the output type after transforms, and `astro:schema` imports move to `astro/zod`.
- **`getEntryBySlug()` and `getDataEntryById()` are deprecated** in favor of a single `getEntry()`.

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:

```astro
---
// 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. On a 50-file project it is the difference between a five-minute 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:

```typescript
// 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:

```typescript
// 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)`.

## 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:

```typescript
// 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:

```typescript
// 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`.

```typescript
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,
  },
});
```

## 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:

```typescript
// 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.

## The questions you were going to ask anyway

**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, do the v5 work first (below), confirm a green build, then take the v6 step.

**Can I keep the legacy collections flag a little longer?**
No. It is removed, not deprecated. v6 will not read `src/content/config.ts` and will not honor `legacy.collections`. Plan the migration before you bump the version, not after.

**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.

## 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:

```typescript
// 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:

```typescript
// 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:

```typescript
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:** the nastiest trap in this whole saga earned its own post. [How a misplaced `content.config.ts` silently ate my schema fields](/en/blog/astro-content-config-location/), and the five-minute fix. If you are pairing with an AI assistant for this kind of multi-file refactor, my [Google Antigravity review](/en/blog/google-antigravity-review/) covers the agent-first IDEs I tried while doing this upgrade.

## Sources

- [Astro docs: Upgrade to v6](https://docs.astro.build/en/guides/upgrade-to/v6/)
- [Astro docs: Content Collections](https://docs.astro.build/en/guides/content-collections/)

---

**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 [Twitter/X](https://x.com/garbarok).
