Migrating from Astro 5 to Astro 6: A Real-World Breakdown 📖

dev.to

I have been meaning to upgrade my personal site to Astro 6 for a while. The release notes sat in my open tabs for weeks, and every time I sat down to do it, I found an excuse to work on something else. This week, I finally ran out of excuses. I carved out an afternoon, ran npx @astrojs/upgrade, crossed my fingers, and expected a smooth ride.

The dev server crashed immediately with a cryptic error about a missing tailwindcss package.

I stared at the error for a minute. Then I did what any reasonable developer would do in 2026 — I looped in an AI coding agent and told it to help me fix everything.

This post is the field guide I wish I had, and also a reminder that AI agents are incredibly helpful but dangerously confident. You still need to know what you're doing.

What I started with

  • Astro 5.x (various patch releases)
  • @astrojs/cloudflare v12.x
  • @astrojs/tailwind v5.x
  • Legacy content collections (src/content/config.ts, type: 'content'/'data')
  • astro-expressive-code v0.38.x
  • astro-icon v1.1.5

What I ended up with

  • Astro 6.2
  • @astrojs/cloudflare v13
  • Tailwind CSS v4 (@tailwindcss/vite)
  • astro-expressive-code v0.41.7
  • A custom inline-SVG icon component (replacing astro-icon)

Error 1: Cannot find package 'tailwindcss'

Cannot find package 'tailwindcss' imported from
.../node_modules/@astrojs/tailwind/dist/index.js
Enter fullscreen mode Exit fullscreen mode

What happened

@astrojs/tailwind v6 was installed by the upgrade command, but its package.json only lists tailwindcss as a peer dependency — it does not bundle it. Since tailwindcss wasn't in my dependencies, Node couldn't resolve it.

The agent's diagnosis

I pasted the error into the agent and asked what was going on. It diagnosed the peer dependency issue immediately and confidently moved to PostCSS. It added postcss.config.mjs, autoprefixed and the @tailwind directives. The site was functional again, but the apporach was completely off.

I had to ask the agent to stick with TailwindCSS and migrate to PostCSS. I asked the agent to check the Astro docs instead of guessing. The agent then used the tools and came up with two options:

Option A: Stay on Tailwind 3 (simplest)

Install tailwindcss@3 alongside the integration and keep everything as-is:

npm install tailwindcss@3
Enter fullscreen mode Exit fullscreen mode

Your astro.config.mjs stays the same:

import tailwind from '@astrojs/tailwind';
// ...
integrations: [tailwind()]
Enter fullscreen mode Exit fullscreen mode

This is the path Astro docs recommend for legacy Tailwind 3 projects.

Option B: Upgrade to Tailwind 4

Astro 5.2+ includes an astro add tailwind command that installs the official Vite plugin (@tailwindcss/vite), which is the new recommended way to use Tailwind 4 in Astro.

npx astro add tailwind
Enter fullscreen mode Exit fullscreen mode

This sets up:

  1. @tailwindcss/vite in your Vite plugins (via astro.config.mjs)
  2. src/styles/global.css with @import "tailwindcss";
  3. Removes the need for tailwind.config.mjs — v4 uses CSS-based configuration instead

If you go this route, you can then remove @astrojs/tailwind entirely and follow Tailwind's v4 upgrade guide to migrate your custom theme to CSS variables.

What happened next: The correct apporach

I instructed the agent to proceed with Option B, running npx astro add tailwind to get the proper Vite plugin set up, then migrated the custom theme into the new @theme block in global.css:

@import "tailwindcss";

@theme {
  --color-primary: #f97316;
  --color-hover: #ea580c;
  --color-light: rgba(249, 115, 22, 0.15);
  --color-secondary: #fdba74;
}

@variant dark (&:where(.dark, .dark *));
Enter fullscreen mode Exit fullscreen mode

If you want to keep Tailwind 3: use Option A above and let @astrojs/tailwind do the work.

If you want Tailwind 4: use Option B and the official Vite plugin. Don't manually wire PostCSS.

Error 2: LegacyContentConfigError

With Tailwind sorted, I restarted the dev server feeling optimistic. Then the next error hit.

[LegacyContentConfigError] Found legacy content config file in
"src/content/config.ts". Please move this file to
"src/content.config.ts" and ensure each collection has a loader defined.
Enter fullscreen mode Exit fullscreen mode

What happened

Astro 5 introduced the Content Layer API, but kept automatic backwards compatibility for old collections. Astro 6 removed that safety net entirely. My src/content/config.ts with type: 'content' and type: 'data' was no longer valid.

The fix

The agent handled this migration competently. It moved the file, rewrote the imports, and configured the loaders:

Before:

import { defineCollection, z } from 'astro:content';

const writings = defineCollection({
  type: 'content',
  schema: z.object({ ... })
});

const projects = defineCollection({
  type: 'data',
  schema: z.array(z.object({ ... }))
});

export const collections = { writings, projects };
Enter fullscreen mode Exit fullscreen mode

After:

import { defineCollection } from 'astro:content';
import { glob, file } from 'astro/loaders';
import { z } from 'astro/zod';

const writings = defineCollection({
  loader: glob({ pattern: '**/[^_]*.mdx', base: './src/content/writings' }),
  schema: z.object({ ... })
});

const projects = defineCollection({
  loader: file('src/content/projects/projects.json'),
  schema: z.object({ ... })  // not z.array(...)
});

export const collections = { writings, projects };
Enter fullscreen mode Exit fullscreen mode

Key changes:

  • File location: src/content/config.ts → src/content.config.ts (project root inside src/).
  • z import: astro:content → astro/zod.
  • type removed: No more type: 'content' or type: 'data'. You explicitly declare a loader.
  • Data collections: If you were using type: 'data' with a JSON file, the new file() loader returns one entry per top-level object. The schema should be z.object(...) (one item), not z.array(...) (the whole file).
  • slug → id: In the old API, entry.slug was auto-derived. In the new API, entry.id is the identifier. My URLs needed updating:
// Before
href={`/writings/${post.slug}`}
// After
href={`/writings/${post.id}`}
Enter fullscreen mode Exit fullscreen mode

Here's where the agent overstepped. It told me that since my posts live in folder/index.mdx structures, glob() would generate IDs from file paths, producing something like migrating-astro-5-to-astro-6/index. It suggested I strip the suffix:

href={`/writings/${post.id.replace(/\/index$/, '')}`}
Enter fullscreen mode Exit fullscreen mode

Something about that felt off. Every post in my writings collection already has a slug field in its frontmatter, and I had a hunch Astro would use that as the entry id. I pushed back and asked the agent to verify against Astro's documentation.

After checking the docs, it couldn't actually confirm that /index gets appended when slug is present in frontmatter. And in practice, post.id was already migrating-astro-5-to-astro-6 and never migrating-astro-5-to-astro-6/index. The .replace() was unnecessary for my setup, and the simple post.id works perfectly.

Note: always verify agent output against your own code and build output. The agent is fast, but you are the one who has to ship it.

So far, so good. The agent was saving me hours. Then it got cocky.

Error 3: Property 'runtime' does not exist on type 'Locals'

Content collections were fixed. I was ready to see the site render. But the Cloudflare adapter had other plans.

ts(2339): Property 'runtime' does not exist on type 'Locals'.
Enter fullscreen mode Exit fullscreen mode

What happened

@astrojs/cloudflare v13 removed Astro.locals.runtime entirely. The new documented approach is:

import { env } from 'cloudflare:workers';
const kv = env.MY_KV;
Enter fullscreen mode Exit fullscreen mode

I migrated my API routes to this pattern. But then I hit a runtime error in dev:

module is not defined
Enter fullscreen mode Exit fullscreen mode

originating from the Cloudflare Vite plugin's worker runner.

The agent spent a while suggesting random fixes — clearing caches, reordering imports, checking Vite config — none of which worked. I ended up tracing it myself: @astrojs/cloudflare v13's dev server runs inside workerd, and it has an incompatibility with the astro-icon integration. When astro-icon's <Icon> component was first rendered, the workerd module runner would crash with that cryptic module is not defined error, breaking every route on the site.

At this point, the was frustrated. It had already spent more time on this than it planned. It downgraded back to @astrojs/cloudflare v12 just to get something working.

But we didn't stop there. The agent suggested replacing astro-icon entirely with a tiny custom component that inlines SVG paths from @iconify-json/mdi. Thirty lines of code. No virtual modules. No workerd compatibility issues. We tried it, switched back to v13, and that was the unlock.

The fix: finish the v13 migration properly

First, update wrangler.toml to use the v13 entrypoint:

main = "@astrojs/cloudflare/entrypoints/server"
Enter fullscreen mode Exit fullscreen mode

Then migrate all code from Astro.locals.runtime.env to import { env } from 'cloudflare:workers':

Before:

const { env } = Astro.locals.runtime;
const kv = env.VOTES;
Enter fullscreen mode Exit fullscreen mode

After:

import { env } from 'cloudflare:workers';
const kv = env.VOTES;
Enter fullscreen mode Exit fullscreen mode

For TypeScript, I also had to augment Cloudflare.Env in src/env.d.ts with secrets that aren't declared in wrangler.toml (like YOUTUBE_API_KEY and GITHUB_TOKEN):

declare namespace Cloudflare {
  interface Env {
    YOUTUBE_API_KEY: string;
    PLAYLIST_ID: string;
    GITHUB_TOKEN: string;
  }
}
Enter fullscreen mode Exit fullscreen mode

Note: The agent didn't know about the wrangler types command. This command generates the types, which would have prevented the manual addition.

This is required because import { env } from 'cloudflare:workers' is typed against the global Cloudflare.Env interface, not the project-level Env that wrangler types generates.

Finally, remove astro-icon from package.json and astro.config.mjs, and replace <Icon name="mdi:github" /> usages with the custom component. Problem solved.

Error 4: astro-expressive-code peer dependency mismatch

That was the biggest battle. I thought I was in the clear. Then npm run build reminded me that integrations have their own timelines.

peer astro@"^4.0.0-beta || ^5.0.0-beta || ^3.3.0" from astro-expressive-code@0.38.3
Enter fullscreen mode Exit fullscreen mode

What happened

The upgrade command didn't bump astro-expressive-code, so it still had a peer dependency range that excluded Astro 6.

The fix

npm install astro-expressive-code@0.41.7
Enter fullscreen mode Exit fullscreen mode

v0.41.7 officially supports Astro 6.

This one the agent got right on the first try. I take the wins where I can get them.

Error 5: Buffer<ArrayBufferLike>' is not assignable to parameter of type 'BodyInit'

Just when I thought the dependency wars were over, TypeScript had one more surprise for me.

In my OG image generation endpoint, I had:

const screenshot = await page.screenshot({ ... });
return new Response(screenshot, { ... });
Enter fullscreen mode Exit fullscreen mode

After the upgrades, TypeScript started rejecting Buffer as a Response body. This wasn't a runtime issue — Puppeteer still returns a Buffer — but astro check (and therefore npm run build) flags it.

The fix

Converted to Uint8Array before passing to Response:

return new Response(new Uint8Array(screenshot), { ... });
Enter fullscreen mode Exit fullscreen mode

This satisfies both the Workers runtime types and TypeScript's strict checks.

Final checklist

Command Status
npm install ✅ (no --legacy-peer-deps)
npm run dev ✅ All routes render in workerd
npm run build ✅
npm run preview ✅

What I learned

  • npx @astrojs/upgrade handles the Astro core bump, but integrations often have their own timeline. Always check npm ls for peer dependency warnings after upgrading.

  • Content collections migration is unavoidable in v6. Astro 5 gave you a grace period. Astro 6 does not. The new loader API is actually clearer once you get used to it.

  • Adapter upgrades are the riskiest part. @astrojs/cloudflare v13 made major changes to how env bindings work and moved the dev server into workerd. The upside is your dev environment is now nearly identical to production. The downside is that some integrations (like astro-icon) aren't yet compatible with workerd's module loading.

  • Build != dev. My site built successfully long before the dev server worked. The v13 Cloudflare adapter broke only in dev (astro dev) because of how it runs code inside workerd. Always test both.

  • When an integration breaks, check the official docs before inventing a workaround. I had already pulled @astrojs/tailwind and installed tailwindcss directly, but the agent pointed me to @tailwindcss/vite — the proper Vite plugin for Tailwind CSS v4. Astro's npx astro add tailwind command sets this up automatically for v4, which is the supported path.

  • import { env } from 'cloudflare:workers' is the new standard. It replaces Astro.locals.runtime.env completely. If you're on v13, embrace it — just remember that wrangler types generates the Env interface, but cloudflare:workers reads from Cloudflare.Env, so you may need to augment the namespace for secrets.

  • AI agents are great teammates, but bad team leads. They will suggest wrong approaches confidently, miss root causes, and hallucinate migration details. You need to know enough to push back, verify claims, and direct the strategy.

Summary

Upgrading from Astro 5 to 6 is not a single command. The core bump is smooth, but the integrations around it — Tailwind, Cloudflare, expressive-code — each carry their own breaking changes. If I were doing this again, I would:

  1. Start with the content collections migration (src/content/config.ts → src/content.config.ts).
  2. Decide on Tailwind 3 vs. 4 before running the upgrade. Use npx astro add tailwind for v4, or install tailwindcss@3 for legacy v3.
  3. Go straight to @astrojs/cloudflare v13. The Astro.locals.runtime → import { env } from 'cloudflare:workers' migration is mechanical.
  4. Test npm run dev, not just npm run build. workerd is stricter than Node.

The workerd dev server in v13 is a net positive — my local environment now behaves almost exactly like production — but it is unforgiving. If you hit module is not defined or similar low-level errors, trace which integration is triggering them. Replacing astro-icon with a custom 30-line SVG component removed an entire class of compatibility issues for me.

Further reading


If you're planning this migration yourself, I'd love to hear how it goes. Feel free to hit me up on X/Twitter if you hit a wall I didn't cover, or if you found a cleaner fix for any of these errors. Stay tuned for more write-ups like this.

Source: dev.to

arrow_back Back to News