I bumped this site from Astro 5 to Astro 6 expecting a changelog skim and a green build. Instead I got three unrelated failures stacked on top of each other: a content collections error, a routing crash, and a Tailwind build that failed only on Cloudflare and nowhere else. None of them were hard once I understood them. All of them cost more time than they should have.
Here’s what broke, why, and the fix for each.
Gotcha 1: legacy content collections are just gone
First build attempt:
[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.
In Astro 5, src/content/config.ts with defineCollection({ type: 'content', schema: ... }) was the standard setup. Astro 6 removes that API entirely. Two changes are required:
- The file moves from
src/content/config.tstosrc/content.config.ts(one level up, renamed). - Every collection needs an explicit
loader. Thetype: 'content'shorthand is gone.
// before
const blog = defineCollection({
type: 'content',
schema: z.object({ /* ... */ }),
});
// after
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({ /* ... */ }),
});
This is the Content Layer API that’s been available since Astro 5, but in 6 it’s no longer optional, the implicit file-based loader is removed completely.
Gotcha 2: entry.slug and entry.render() are dead
With the loader-based collections working, the build moved on to a much less helpful error:
Missing parameter: slug
No file, no line number. Just that.
The cause: entries from the Content Layer API don’t have a .slug property anymore. They have .id, which is the slugified filename by default. Every dynamic route, RSS feed, sitemap entry, and OG image generator that did post.slug needed to become post.id:
// before
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
// after
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.id },
props: { post },
}));
}
Same story for rendering. entry.render() as a method is gone, render is now a standalone import from astro:content:
// before
const { Content, headings } = await post.render();
// after
import { render } from 'astro:content';
const { Content, headings } = await render(post);
Easy fixes, but they’re scattered across every page that touches a collection, so grep for .slug and .render( before you call the migration done.
One trap inside this trap: heading anchors also have a .slug property (from the headings array render() returns), and that one is unrelated and unchanged. Don’t find-and-replace blindly, post.slug needs to become post.id, but heading.slug stays exactly as it is.
Gotcha 3: an empty draft file fails the type check, not just the lint
Unrelated to the Astro version bump, but it surfaced because of it: an in-progress draft .mdx file with no frontmatter at all failed schema validation with title: Required and date: Required. The old loader was apparently more forgiving about files it couldn’t fully parse; the new one validates everything it globs. If you’ve got placeholder files sitting in your content directory, give them minimal frontmatter (draft: true is your friend) or the whole build dies on a file you weren’t even working on yet.
Gotcha 4: the Tailwind plugin builds fine locally and fails only on Cloudflare
This one was the most expensive, because every signal pointed the wrong way.
npm run build worked. Every page rendered. CSS output looked correct. I pushed. Cloudflare Pages failed with:
[@tailwindcss/vite:generate:build] Missing field `tsconfigPaths` on BindingViteResolvePluginConfig.resolveOptions
at makeBuiltinPluginCallable (node_modules/rolldown/dist/shared/normalize-string-or-regex-*.mjs)
at oxcResolvePlugin (node_modules/vite/dist/node/chunks/node.js)
at node_modules/@tailwindcss/vite/dist/index.mjs
My first instinct was a stale dependency, bump @tailwindcss/vite, clear the build cache, pin the Node version to match my local 22.19.0. None of it changed a single character of the error. Same file, same line numbers, every retry.
The actual cause: Astro 6 ships with a rolldown-powered build of Vite (7.3+), and @tailwindcss/vite doesn’t fully support its resolver yet, specifically a tsconfig.json in the project triggers a code path in @tailwindcss/vite that calls into Vite’s native rolldown resolver with a config object missing a field the binding now requires. It’s a real upstream gap (withastro/astro#16542), and it apparently doesn’t reproduce on every platform it built clean on macOS and failed every time on Cloudflare’s Linux build image.
The fix is to stop using the Vite plugin entirely and go back to plain PostCSS, which Tailwind v4 still supports as a first-class path:
npm uninstall @tailwindcss/vite
npm install @tailwindcss/postcss
// postcss.config.mjs
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
And drop the plugin from astro.config.mjs:
// before
vite: {
plugins: [tailwindcss()],
}
// after
// nothing PostCSS picks it up automatically
Same output, same CSS, no rolldown resolver in the path at all.
What I’d tell past me
- Move
src/content/config.ts→src/content.config.tsand add aglob()loader to every collection before anything else nothing else builds until this is fixed. grep -rn '\.slug\|\.render('acrosssrc/pagesand fixpost.slug→post.id,entry.render()→render(entry). Leaveheading.slugalone.- Give every draft content file real frontmatter, even placeholders.
- If
@tailwindcss/vitebuilds locally but fails on your host with a rolldown/oxc resolver error, don’t chase cache or Node version switch to@tailwindcss/postcssand move on.
None of these are individually hard. But they show up as four unrelated-looking errors in sequence, and the last one actively lies to you by working perfectly on your machine. Worth a half hour of reading before you upgrade, which is exactly the half hour I didn’t take.