Upgrading jaimebuilds.com to Astro 7, Tailwind 4 and TypeScript 6
I shipped the portfolio on the bleeding edge of the Astro stack. Five things broke during the migration. Page weight dropped from around 100 KB to 13.6 KB. Here is the changelog and the fixes.
This site is now running on Astro 7.0.3, Tailwind 4.3.1 and TypeScript 6.0.3. The redesign branch went out earlier today and the live proof strip on the homepage settles at 13.6 KB of total page weight and 313 ms of load on a desktop browser.
The upgrade was a single afternoon. The interesting part was not bumping the version numbers in package.json. It was the five small breaking changes that the version bump dragged with it. None of them were in any release note I read up front. All of them showed up as build errors or as a silently broken layout.
Sharing the changelog because somebody else is about to do this exact upgrade.
The starting point
Before this migration the site ran on:
- Astro 5.16.6 with
output: 'server'on Vercel - Tailwind 3.4 with
@astrojs/tailwindintegration and atailwind.config.ts - React 19.2.3 islands
- TinaCMS for content editing
lenisfor smooth scroll,motionfor animations, a dark/light theme toggle,astro-icon,astro-i18next
I had also just landed a full redesign (Swiss editorial layout, warm cream palette, Shopify Plus-forward copy) on a separate branch. So the upgrade and the redesign shipped together.
1. LegacyContentConfigError
The first build failure after the version bump:
[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.Astro 6 moved content collections out of src/content/config.ts and required every collection to declare an explicit loader. The new location is src/content.config.ts (note: no content/ directory in the path). The new shape replaces type: 'content' with a loader callable from astro/loaders.
Before:
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({ title: z.string(), date: z.coerce.date() }),
});
export const collections = { blog };After:
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({ title: z.string(), date: z.coerce.date() }),
});
export const collections = { blog };The site has nine collections (blog, work, lab times three languages each), so this was nine loader: glob(...) lines and a file rename.
2. post.render() is gone
Inside [slug].astro pages, the old API was:
const { post } = Astro.props;
const { Content } = await post.render();post.render() was removed in Astro 6. The replacement is a top-level render function:
import { getCollection, render } from 'astro:content';
const { post } = Astro.props;
const { Content } = await render(post);Identical behavior, different import. Nine [slug].astro files needed the change.
3. post.slug is now post.id
Same migration. When you read entries with the new loader API, the property that used to be post.slug is now post.id. The shape changed because loaders can produce non-file collection entries where “slug” did not really make sense.
Where I used to write:
<a href={`/blog/${post.slug}/`}>{post.data.title}</a>I now write:
<a href={`/blog/${post.id}/`}>{post.data.title}</a>For glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), the id is the file path relative to base with the extension stripped, which is exactly what slug used to be. So no URL changes, just a rename.
4. @astrojs/tailwind does not support Astro 6 or 7
The @astrojs/tailwind integration capped its peer dependency at Astro 5. For Astro 6+ the supported path is the Tailwind 4 Vite plugin: @tailwindcss/vite.
Practically:
// astro.config.mjs (before)
import tailwind from '@astrojs/tailwind';
export default defineConfig({
integrations: [tailwind({ applyBaseStyles: false })],
});
// astro.config.mjs (after)
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
vite: { plugins: [tailwindcss()] },
});Tailwind 4 also dropped the JavaScript config file in favor of a CSS-first config. The tailwind.config.ts is gone. Design tokens now live inside a @theme block in your main CSS file, and Tailwind generates utilities from them automatically:
@import "tailwindcss";
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/typography";
@theme {
--color-v2-bg: #f4f1ea;
--color-v2-ink: #161310;
--color-v2-accent: #8a2417;
--font-v2-sans: "Geist", -apple-system, sans-serif;
--font-v2-serif: "Newsreader", Georgia, serif;
}The --color-v2-bg: #f4f1ea automatically generates bg-v2-bg, text-v2-bg, border-v2-bg and the variants. The --font-v2-sans generates font-v2-sans. Plugins are pulled in with @plugin directives in the same file. The whole JS config file is gone.
5. The one that nearly shipped a broken site
After the build was green and the deploy was live, I opened the preview URL and the homepage looked half styled. Colors and fonts worked. The grid did not. The container had no max-width. The navigation menu was invisible. The CTAs were stacked full width instead of side by side.
The culprit was astro-compress. It runs a second CSS minification pass after Vite. With Tailwind 3 this was harmless. With Tailwind 4 it appears to drop or break utilities it does not recognize as referenced, specifically the arbitrary values (max-w-[1280px], lg:grid-cols-[1.4fr_1fr]) and the responsive variants (hidden md:flex). Simple utilities like font-medium or border survived. The complex ones did not.
The fix:
compress({
CSS: false,
HTML: true,
Image: true,
JavaScript: true,
SVG: true,
}),Vite already minifies the CSS bundle correctly. The second pass was redundant. With CSS: false the layout came back.
Worth flagging because the build was green. The deploy was live. The error did not appear in any log. The only signal was a screenshot from the preview.
What dropped out as a result
The upgrade became a good moment to clean house. I removed nine dependencies the redesign no longer used:
lenisandmotion: no smooth scroll, no animation libraryastro-iconand@iconify-json/mdi: inline SVG onlyastro-i18nextandi18next: the redesign uses hardcoded language paths@astrojs/tailwind: replaced by@tailwindcss/vite- The dark mode CSS and theme toggle: the redesign is light only
I also deleted seventeen V1 components that the new layouts had made obsolete.
Result: the live proof strip on the homepage measures 13.6 KB of total transferred bytes for HTML, CSS and JS combined, and 313 ms of domInteractive on a desktop browser. The previous site was in the 80 to 120 KB range. Roughly a 7x to 9x reduction, on a redesign that is visually heavier than what it replaced.
The migration in five bullets
- Rename
src/content/config.tstosrc/content.config.tsand addloader: glob({...})to each collection. - Replace
await post.render()withawait render(post)and importrenderfrom'astro:content'. - Rename
post.slugtopost.ideverywhere it is a collection entry (the URLparams: { slug }does not change). - Drop
@astrojs/tailwind, install@tailwindcss/vite, deletetailwind.config.ts, move tokens to@themein CSS. - Set
CSS: falseonastro-compress. Trust Vite.
If you are doing the same upgrade, that order is what worked. The whole branch is public if you want to see the diff: github.com/jaimesolis/jaimebuilds.
I post the rest of these notes (and the occasional rant about Shopify Plus theme builds) on X. If this kind of debugging is useful, you can follow along there: @jaimesolis. If you are in the middle of an Astro upgrade or a Hydrogen migration and want a second pair of eyes, the contact form is two clicks away.