Skip to content

·  · 9 min read

Astro 5 to 6 Migration: The 3 Breaking Changes That Bit Me

The Astro 5 to 6 migration looked like a ten-minute security bump. Instead I hit three build failures back to back — every one of them in vendored theme code I never wrote. Here are the exact errors, the fixes, and why a themed site breaks where your own code doesn't.

The Astro 5 to 6 migration looked like a ten-minute security bump. Instead I hit three build failures back to back — every one of them in vendored theme code I never wrote. Here are the exact errors, the fixes, and why a themed site breaks where your own code doesn't.

I upgraded this site from Astro 5 to Astro 6 for the most boring reason possible: five Dependabot CVEs, all cleared by astro@6.4.6 or newer. A security patch. I expected a ten-minute bun add astro@latest, a green build, and back to writing.

Instead I got three build failures in a row. And here’s the part worth writing down: not one of them was in code I wrote. Every failure landed in the vendored theme — the content config, the blog utilities, the layout — files that came with the AstroWind template and that I’d never had a reason to touch. If you run a portfolio or blog on a theme like this and you’ve been putting off the v6 bump, this is the post I wish I’d read first: the exact breaking changes, the verbatim errors, and the one-line fixes.

Why a “security patch” is actually a major migration

Astro 6 is a major version. Semver majors are allowed to break things, and Astro 6 removed a pile of APIs that were deprecated across the 5.x line. On a greenfield app you own every file, so you feel each removal exactly once, in your own code. On a themed site the math is different: most of the code isn’t yours. The breakage surfaces in the theme’s plumbing, with error messages pointing at files you didn’t write and don’t fully know.

That’s the whole story of this migration. Three breaking changes bit me, all in vendored code. Here they are in the order the build hit them.

Breaking change #1: legacy content collections are gone

The first bun run build after the bump refused to even start. Astro 5 introduced the Content Layer API (the glob() loader in a src/content.config.ts file) but kept the old “legacy” content collections working alongside it for a full major cycle. Astro 6 removed the legacy API entirely — no compatibility flag, no grace period. If your collections still rely on the old src/content/config.ts convention, the build stops.

The fix is mechanical but it’s two moves, not one.

Move 1 — relocate and modernize the config. Rename src/content/config.ts to src/content.config.ts (note: out of the content/ folder, up one level) and give every collection an explicit loader:

// src/content.config.ts
import { z, defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';

const postCollection = defineCollection({
  // Astro 6 Content Layer API — replaces the removed legacy content collections
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/post' }),
  schema: z.object({
    title: z.string(),
    publishDate: z.date().optional(),
    // ...rest of your schema
  }),
});

export const collections = { post: postCollection };

Move 2 — fix the ripple in every file that reads the collection. This is the part that hurts on a themed site, because the theme’s blog utilities are full of the old API. Two renames matter:

  • entry.render()render(entry)render is now a named import from astro:content, not a method on the entry.
  • entry.slugentry.id — Content Layer entries expose id, not slug.

In the AstroWind blog helper that meant this:

// Astro 5 (before)
const { Content, remarkPluginFrontmatter } = await post.render();
const slug = cleanSlug(post.slug);

became this:

// Astro 6 (after)
import { getCollection, render } from 'astro:content';
// ...
const { Content, remarkPluginFrontmatter } = await render(post);
const slug = cleanSlug(post.id); // Content Layer: entry.id is the slug

None of that is hard. But you don’t find it by reading a migration guide — you find it because the build throws LegacyContentConfigError, you fix the config, and then the next build throws a render is not a function deeper in a file you’d never opened.

Breaking change #2: the integration version matrix (the error that ate the most time)

With the content config fixed, the next build died with something genuinely cryptic — and this is the one that cost me the most time, because the error blames a file inside node_modules and says nothing about why:

[ERROR] [vite] ✗ Build failed
node_modules/@astrojs/mdx/dist/server.js (3:9): "chunkToString" is not exported by
"node_modules/astro/dist/runtime/server/index.js", imported by
"node_modules/@astrojs/mdx/dist/server.js".

Here’s what actually happened. The integrations that hook into Astro’s runtime ship a new major for each Astro major. @astrojs/mdx@6 targets Astro 6; @astrojs/mdx@7 targets Astro 7. When I bumped Astro, my package manager happily resolved @astrojs/mdx to its newest major — v7 — because the caret range allowed it. But mdx v7 reaches into Astro’s internal runtime for an export (chunkToString) that only exists in Astro 7. On Astro 6, that import resolves to nothing, and Rollup fails the build with the message above.

If you install it manually you actually get a heads-up that’s easy to miss in a wall of upgrade output:

warn: incorrect peer dependency "astro@6.4.8"

The fix is to pin the integration to the major that matches your Astro version:

// package.json
"devDependencies": {
  "@astrojs/mdx": "^6.0.1"   // match Astro 6, NOT the latest (v7 = Astro 7)
}

The general rule that saves you next time: not every integration bumps its major in lockstep with Astro. On this Astro 6 site, @astrojs/mdx is v6 — but @astrojs/sitemap is still v3, @astrojs/rss is v4, and @astrojs/partytown is v2, and all of them are fine. The ones that break on a mismatch are the runtime-coupled integrations — mdx, and the framework renderers like @astrojs/react / vue / svelte — because they import from Astro’s internals. So after a major bump, don’t blanket-upgrade everything; check each integration’s peerDependencies against your Astro version (that incorrect peer dependency warning is the tell) and pin the runtime-coupled ones to the matching major so a ^ range can’t float you onto the next one. The build error won’t say “wrong integration version”; it’ll show you an internal export that doesn’t exist and let you figure out the rest.

Here’s the split on this exact site, so you can see it’s not all-or-nothing:

IntegrationVersion on this Astro 6 siteTracks Astro’s major?
@astrojs/mdxv6Yes — bump it with Astro (this was the culprit)
@astrojs/react / vue / sveltematches AstroYes — runtime-coupled renderers
@astrojs/sitemapv3No — versions independently
@astrojs/rssv4No — versions independently
@astrojs/partytownv2No — versions independently

Breaking change #3: ViewTransitions is now ClientRouter

The third failure was an unresolved import. Astro renamed the view-transitions component from <ViewTransitions /> to <ClientRouter /> back in Astro 5 (the name now describes what it does — a client-side router — rather than the CSS feature it drives), and it deprecated the old name for the 5.x cycle. Astro 6 removed the ViewTransitions export. If your layout still imports it, the build can’t resolve it.

One import and one tag:

---
// src/layouts/Layout.astro
import { ClientRouter } from 'astro:transitions'; // was: ViewTransitions
---

<head>
  <!-- ... -->
  <ClientRouter fallback="swap" />
  <!-- was: <ViewTransitions /> -->
</head>

Trivial to fix — but the docs flag a subtler trap here that a find-and-replace won’t catch: the lifecycle event timing changed. If you have JavaScript hooking astro:page-load or astro:after-swap, don’t just swap the component and assume you’re done — click through a few navigations and confirm your scripts still fire when you expect. Mine were fine; yours might not be.

The ones that didn’t bite me — but might bite you

Three breaking changes hit my setup. Astro 6 removed more than that, and which ones bite depends on what your theme does. Before you upgrade, scan this list:

  • Astro.glob() is removed. Use import.meta.glob() instead. I didn’t use it — but plenty of themes do for building nav or listing pages, so grep for it first.
  • Node 22+ is required (Node 18 and 20 are dropped). Here’s a sneaky one: my package.json still declared "engines": { "node": "^18.17.1 || ^20.3.0 || >= 21.0.0" }. That field is advisory, so my local build didn’t complain — but a CI runner or a Vercel/Netlify project still pinned to Node 18 would fail at build or, worse, at runtime. Update engines and your deploy platform’s Node version together.
  • Zod 4. Astro 6 ships Zod 4. If your content schemas lean on Zod 3-only behavior, they’ll break. Simple z.object({...}) schemas like mine carried over untouched.
  • emitESMImage() is removed. This one’s for integration authors, not app code — but if a niche image integration in your stack hasn’t updated, that’s where it shows up.

The real lesson: on a themed site, the build is your migration guide — pointing at code you didn’t write

Astro’s official v6 upgrade guide is genuinely good, and you should read it first. But it’s written for someone who owns their codebase — “update your content config,” “update your layout,” “update your imports.” On a theme-based site, you didn’t write the content config, the layout, or half the imports. The upgrade guide describes changes to code you’ve never read.

So the practical playbook that actually worked:

  1. Read the official upgrade guide to know what’s coming — you want the shape of the changes in your head before the errors start.
  2. Pin every @astrojs/* integration to your Astro major before rebuilding, so change #2 never happens to you.
  3. Run the build and let it drive. Each failure names a file and a symptom; fix it, rebuild, repeat. On a mature theme it’s three to five mechanical fixes, not a rewrite. And once it’s green, don’t stop at “the build passed” — validate the post output before it goes live, because a migration can compile clean and still change how a page renders.
  4. Budget for the vendored code. The fixes are small, but they’re in files you don’t know by heart, so each one costs a minute of “wait, where is this and what calls it?” that a greenfield migration wouldn’t.

Start to finish this was about thirty minutes, and more than half of it was chasing the chunkToString red herring in change #2 before I understood it was just a version mismatch. The security CVEs were real and worth clearing on their own — so if you’re sitting on Astro 5, do the bump. Just don’t schedule it as a ten-minute chore, and don’t be surprised when the stack traces point somewhere you’ve never been.

If you liked the shape of this — a small, sharp seam hiding inside an Astro theme — it’s the exact same class of bug as the Partytown trap that silently killed my Google Analytics: a default in vendored code doing something you never asked it to. And if you want the full tour of how an AstroWind theme’s config layers fit together (the same layering that turns a version bump into a scavenger hunt), that’s my deep-dive on the AstroWind template.

    Share:

    Enjoyed this post?

    Get new posts on web dev, AI and SEO straight to your inbox. No spam, unsubscribe anytime.

    No spam. By subscribing you agree to the privacy policy .

    Back to blog

    Related Posts

    View All Posts »
    How I Cut My Homepage Image Weight 88% (Astro + Sharp + WebP)

    How I Cut My Homepage Image Weight 88% (Astro + Sharp + WebP)

    A static Astro site with 15KB of JavaScript was shipping 3.4MB of images. Here are the three failure modes that got past the image pipeline — the fallback-always-ships rule, the screenshot the optimizer never touched, and the boilerplate nobody referenced — and the 15 lines of Sharp that fixed all of it.