Confession: I sat on the Tailwind v4 upgrade for almost three months after it shipped. Not because I read a scary thread on X. Because I’d already burned a Saturday on the v2-to-v3 jump and wasn’t excited to do it again.
When I finally did it on a real production app this March, the actual code changes took maybe ninety minutes. The thing that ate my afternoon was a single CSS module file using @apply that quietly stopped working with no error message, just the wrong styles. If you’re looking for a Tailwind CSS v4 migration guide that’s honest about the rough edges, this is mine.
I’ll skip the marketing pitch (it’s faster, the colors are nicer, etc.) and walk through the things I actually had to fix. The whole upgrade in numbers: a Next.js 15 app with about 240 components, three custom plugins, two themes (light and a “high-contrast” mode for one client), and a pile of @apply rules I’d been embarrassed to look at for a year.
The config file move was the loudest change, and the easiest
Before v4, the canonical setup was tailwind.config.js at the project root. v4 wants the config inside your CSS, in an @theme block. The official v4 release post on tailwindcss.com walks through the reasoning. The short version: your design tokens already want to be CSS variables, so why are they JavaScript?
Here’s how my brand color setup looked in v3:
// tailwind.config.js
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
brand: {
50: '#f0f7ff',
500: '#2b6cff',
900: '#0a2454',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
}
And the v4 equivalent, in app.css:
@import "tailwindcss";
@theme {
--color-brand-50: #f0f7ff;
--color-brand-500: #2b6cff;
--color-brand-900: #0a2454;
--font-sans: "Inter", system-ui, sans-serif;
}
Two things to flag. First, you can drop the content glob entirely if you’re using the Vite plugin or the new automatic source detection. Tailwind walks your project and figures it out. Second, every theme variable becomes a real CSS custom property at runtime. So bg-brand-500 and var(--color-brand-500) reference the same value. That sounds boring on paper, but it changes how I write components: a Headless UI dialog overlay can pull var(--color-brand-500) directly without knowing Tailwind exists. I wrote about living with this setup six months on in my Tailwind v4 config notes, and I haven’t missed the JS file.
For projects with a lot of custom JS in their config (functions, deep merges, generated palettes), v4 still supports @config "./tailwind.config.js" as an escape hatch. I used that during the migration window so I could move things over piece by piece instead of doing a big-bang rewrite.
@apply in scoped CSS files needs @reference now
This is the one that ate my afternoon. v4 split the way utilities are exposed to scoped contexts: Vue’s <style scoped>, CSS modules, Svelte’s <style>, anything that doesn’t share Tailwind’s main scope. My main app.css was fine. But Button.module.css had something like:
/* v3, worked fine */
.primary {
@apply bg-brand-500 text-white px-4 py-2 rounded-lg;
}
After upgrading, this compiled, didn’t throw, and produced nothing. The class existed in the DOM with zero styles attached. The fix is documented in the Tailwind v4 upgrade guide, and once you know the rule it’s obvious. I had to find it by reading a GitHub issue.
/* v4, works */
@reference "../app.css";
.primary {
@apply bg-brand-500 text-white px-4 py-2 rounded-lg;
}
@reference tells the scoped file where to find your theme without re-emitting the whole Tailwind base. Skip it and @apply runs in a vacuum. You get a class with no styles. No warning, just sad buttons. If you have any module CSS or scoped styles in your app, grep for @apply before you upgrade and add the reference at the top of every file that needs it.
Plugins, PostCSS, and Vite: the install dance
The plugin packages got renamed. The PostCSS plugin moved out of the main package into @tailwindcss/postcss, and there’s a first-party Vite plugin now (@tailwindcss/vite) that’s faster than going through PostCSS at all.
My v3 postcss.config.js:
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
v4:
// postcss.config.js
export default {
plugins: {
"@tailwindcss/postcss": {},
},
}
Autoprefixer goes away because v4 ships its own prefixing. If you’re on Vite, drop PostCSS entirely:
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
export default {
plugins: [tailwindcss()],
};
That cut my dev startup from about 1.4 seconds to under 400ms on the same machine. Some of that is the new Oxide engine, some is just skipping a layer of tooling. I won’t claim 10x like the marketing says, but on a hot edit my browser refreshes feel instant in a way they didn’t before.
Third-party plugins are the place to actually slow down. @tailwindcss/forms, @tailwindcss/typography, and @tailwindcss/aspect-ratio all had v4-compatible releases by the time I migrated. A community plugin I was using for a custom prose theme didn’t, and I had to inline its rules into @theme and a small @layer components block. Check every plugin’s release notes before you start. If you’ve got a plugin author who’s gone quiet, budget time to copy out their CSS.
Default values changed in ways your designer will notice
This is the boring section that’s actually the dangerous one. A few default utilities changed values, and the diffs are subtle enough that a designer eyeballing the staging site is going to be the one who flags them, usually right before launch.
The one that got me: ring used to be 3px. In v4 it’s 1px by default. So every focus ring on every input across the app got skinnier overnight. The fix is one line:
@theme {
--default-ring-width: 3px;
}
I had to discover the change by squinting at a Figma file next to my staging site and going “wait, that looks off.” Ring color also changed (it was currentColor derived in v3, now it’s a theme token), so check anywhere you’d assumed the old behavior.
A few other defaults that moved.
space-x-* and space-y-* use :not(:last-child) instead of the old > * + * selector. If you had any flexed layout where last-child wasn’t the visual last child (think flex-row-reverse), the spacing flips.
The default container utility is gone. If you used container mx-auto, you now define one in @theme or write your own.
Default border color is now currentColor instead of gray-200. Every border without an explicit color reads from the surrounding text color now. I had a few divider lines that had been quietly relying on the old gray default.
I didn’t notice any of these in my unit tests. Nothing failed. The visual regression tests caught two of them. The third (the rings) I caught with my eyes. If you don’t have visual regression for your design-heavy pages, it’s worth standing up Playwright snapshots for the migration even if you tear them down after.
What I’d do differently next time
Three things, in order of how badly I wish I’d known them.
First, run the upgrade tool on a branch and read the diff before you do anything else. The official npx @tailwindcss/upgrade command got my project 80% of the way there. I ran it, eyeballed the diff, undid the parts I wasn’t ready for, and committed the rest in chunks. That alone would have saved me half my time.
Second, search your codebase for @apply and triage every file. Files inside scoped contexts need @reference. Files in your main entry don’t. I should have done this in five minutes at the start. Instead I debugged a styleless component for forty.
Third, if you’re using TypeScript and have a tailwind.config.ts that’s importing types from tailwindcss, those types still work, but the import path may have shifted. I had a typed plugin where import type { Config } from 'tailwindcss' started warning. Painless fix, but worth knowing before you panic about your CI failing on type-check.
If you’re upgrading a real production app, set aside an afternoon, not a Saturday. Branch the project, run the upgrade tool, fix @apply, re-test your forms (they’re the most likely place for ring and border defaults to bite), and ship it behind a flag if you can. I’ve been shipping under v4 in a couple of client projects on my work page, and the dev experience win is bigger than I expected. The build speed is the obvious thing. The quieter win is that my design tokens finally live in one file, in CSS, where the rest of my app can see them.
The migration’s friendly. The defaults aren’t. Read the diff before you trust the diff.