{"id":217,"date":"2026-05-11T13:04:33","date_gmt":"2026-05-11T13:04:33","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/migrating-to-tailwind-v4-what-broke-what-i-wish-id-known\/"},"modified":"2026-05-11T13:04:33","modified_gmt":"2026-05-11T13:04:33","slug":"migrating-to-tailwind-v4-what-broke-what-i-wish-id-known","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/migrating-to-tailwind-v4-what-broke-what-i-wish-id-known\/","title":{"rendered":"Migrating to Tailwind v4: What Broke, What I Wish I&#8217;d Known"},"content":{"rendered":"<p>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&rsquo;d already burned a Saturday on the v2-to-v3 jump and wasn&rsquo;t excited to do it again.<\/p>\n<p>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 <code>@apply<\/code> that quietly stopped working with no error message, just the wrong styles. If you&rsquo;re looking for a Tailwind CSS v4 migration guide that&rsquo;s honest about the rough edges, this is mine.<\/p>\n<p>I&rsquo;ll skip the marketing pitch (it&rsquo;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 &ldquo;high-contrast&rdquo; mode for one client), and a pile of <code>@apply<\/code> rules I&rsquo;d been embarrassed to look at for a year.<\/p>\n<h2 id=\"the-config-file-move-was-the-loudest-change-and-the-easiest\">The config file move was the loudest change, and the easiest<\/h2>\n<p>Before v4, the canonical setup was <code>tailwind.config.js<\/code> at the project root. v4 wants the config inside your CSS, in an <code>@theme<\/code> block. The official <a href=\"https:\/\/tailwindcss.com\/blog\/tailwindcss-v4\" rel=\"nofollow noopener\" target=\"_blank\">v4 release post on tailwindcss.com<\/a> walks through the reasoning. The short version: your design tokens already want to be CSS variables, so why are they JavaScript?<\/p>\n<p>Here&rsquo;s how my brand color setup looked in v3:<\/p>\n<pre><code class=\"language-js\">\/\/ tailwind.config.js\nmodule.exports = {\n  content: ['.\/src\/**\/*.{js,ts,jsx,tsx}'],\n  theme: {\n    extend: {\n      colors: {\n        brand: {\n          50:  '#f0f7ff',\n          500: '#2b6cff',\n          900: '#0a2454',\n        },\n      },\n      fontFamily: {\n        sans: ['Inter', 'system-ui', 'sans-serif'],\n      },\n    },\n  },\n}\n<\/code><\/pre>\n<p>And the v4 equivalent, in <code>app.css<\/code>:<\/p>\n<pre><code class=\"language-css\">@import &quot;tailwindcss&quot;;\n\n@theme {\n  --color-brand-50:  #f0f7ff;\n  --color-brand-500: #2b6cff;\n  --color-brand-900: #0a2454;\n\n  --font-sans: &quot;Inter&quot;, system-ui, sans-serif;\n}\n<\/code><\/pre>\n<p>Two things to flag. First, you can drop the <code>content<\/code> glob entirely if you&rsquo;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 <code>bg-brand-500<\/code> and <code>var(--color-brand-500)<\/code> reference the same value. That sounds boring on paper, but it changes how I write components: a Headless UI dialog overlay can pull <code>var(--color-brand-500)<\/code> directly without knowing Tailwind exists. I wrote about living with this setup six months on in <a href=\"https:\/\/abrarqasim.com\/blog\/tailwind-config-v4-six-months-without-config-js\" rel=\"noopener\">my Tailwind v4 config notes<\/a>, and I haven&rsquo;t missed the JS file.<\/p>\n<p>For projects with a lot of custom JS in their config (functions, deep merges, generated palettes), v4 still supports <code>@config \".\/tailwind.config.js\"<\/code> 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.<\/p>\n<h2 id=\"apply-in-scoped-css-files-needs-reference-now\">@apply in scoped CSS files needs @reference now<\/h2>\n<p>This is the one that ate my afternoon. v4 split the way utilities are exposed to scoped contexts: Vue&rsquo;s <code>&lt;style scoped&gt;<\/code>, CSS modules, Svelte&rsquo;s <code>&lt;style&gt;<\/code>, anything that doesn&rsquo;t share Tailwind&rsquo;s main scope. My main <code>app.css<\/code> was fine. But <code>Button.module.css<\/code> had something like:<\/p>\n<pre><code class=\"language-css\">\/* v3, worked fine *\/\n.primary {\n  @apply bg-brand-500 text-white px-4 py-2 rounded-lg;\n}\n<\/code><\/pre>\n<p>After upgrading, this compiled, didn&rsquo;t throw, and produced nothing. The class existed in the DOM with zero styles attached. The fix is documented in the <a href=\"https:\/\/tailwindcss.com\/docs\/upgrade-guide\" rel=\"nofollow noopener\" target=\"_blank\">Tailwind v4 upgrade guide<\/a>, and once you know the rule it&rsquo;s obvious. I had to find it by reading a GitHub issue.<\/p>\n<pre><code class=\"language-css\">\/* v4, works *\/\n@reference &quot;..\/app.css&quot;;\n\n.primary {\n  @apply bg-brand-500 text-white px-4 py-2 rounded-lg;\n}\n<\/code><\/pre>\n<p><code>@reference<\/code> tells the scoped file where to find your theme without re-emitting the whole Tailwind base. Skip it and <code>@apply<\/code> 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 <code>@apply<\/code> before you upgrade and add the reference at the top of every file that needs it.<\/p>\n<h2 id=\"plugins-postcss-and-vite-the-install-dance\">Plugins, PostCSS, and Vite: the install dance<\/h2>\n<p>The plugin packages got renamed. The PostCSS plugin moved out of the main package into <code>@tailwindcss\/postcss<\/code>, and there&rsquo;s a first-party Vite plugin now (<code>@tailwindcss\/vite<\/code>) that&rsquo;s faster than going through PostCSS at all.<\/p>\n<p>My v3 <code>postcss.config.js<\/code>:<\/p>\n<pre><code class=\"language-js\">module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n<\/code><\/pre>\n<p>v4:<\/p>\n<pre><code class=\"language-js\">\/\/ postcss.config.js\nexport default {\n  plugins: {\n    &quot;@tailwindcss\/postcss&quot;: {},\n  },\n}\n<\/code><\/pre>\n<p>Autoprefixer goes away because v4 ships its own prefixing. If you&rsquo;re on Vite, drop PostCSS entirely:<\/p>\n<pre><code class=\"language-js\">\/\/ vite.config.ts\nimport tailwindcss from &quot;@tailwindcss\/vite&quot;;\n\nexport default {\n  plugins: [tailwindcss()],\n};\n<\/code><\/pre>\n<p>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&rsquo;t claim 10x like the marketing says, but on a hot edit my browser refreshes feel instant in a way they didn&rsquo;t before.<\/p>\n<p>Third-party plugins are the place to actually slow down. <code>@tailwindcss\/forms<\/code>, <code>@tailwindcss\/typography<\/code>, and <code>@tailwindcss\/aspect-ratio<\/code> all had v4-compatible releases by the time I migrated. A community plugin I was using for a custom prose theme didn&rsquo;t, and I had to inline its rules into <code>@theme<\/code> and a small <code>@layer components<\/code> block. Check every plugin&rsquo;s release notes before you start. If you&rsquo;ve got a plugin author who&rsquo;s gone quiet, budget time to copy out their CSS.<\/p>\n<h2 id=\"default-values-changed-in-ways-your-designer-will-notice\">Default values changed in ways your designer will notice<\/h2>\n<p>This is the boring section that&rsquo;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.<\/p>\n<p>The one that got me: <code>ring<\/code> used to be 3px. In v4 it&rsquo;s 1px by default. So every focus ring on every input across the app got skinnier overnight. The fix is one line:<\/p>\n<pre><code class=\"language-css\">@theme {\n  --default-ring-width: 3px;\n}\n<\/code><\/pre>\n<p>I had to discover the change by squinting at a Figma file next to my staging site and going &ldquo;wait, that looks off.&rdquo; Ring color also changed (it was <code>currentColor<\/code> derived in v3, now it&rsquo;s a theme token), so check anywhere you&rsquo;d assumed the old behavior.<\/p>\n<p>A few other defaults that moved.<\/p>\n<p><code>space-x-*<\/code> and <code>space-y-*<\/code> use <code>:not(:last-child)<\/code> instead of the old <code>&gt; * + *<\/code> selector. If you had any flexed layout where <code>last-child<\/code> wasn&rsquo;t the visual last child (think <code>flex-row-reverse<\/code>), the spacing flips.<\/p>\n<p>The default container utility is gone. If you used <code>container mx-auto<\/code>, you now define one in <code>@theme<\/code> or write your own.<\/p>\n<p>Default border color is now <code>currentColor<\/code> instead of <code>gray-200<\/code>. Every <code>border<\/code> 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.<\/p>\n<p>I didn&rsquo;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&rsquo;t have visual regression for your design-heavy pages, it&rsquo;s worth standing up Playwright snapshots for the migration even if you tear them down after.<\/p>\n<h2 id=\"what-id-do-differently-next-time\">What I&rsquo;d do differently next time<\/h2>\n<p>Three things, in order of how badly I wish I&rsquo;d known them.<\/p>\n<p>First, run the upgrade tool on a branch and read the diff before you do anything else. The official <code>npx @tailwindcss\/upgrade<\/code> command got my project 80% of the way there. I ran it, eyeballed the diff, undid the parts I wasn&rsquo;t ready for, and committed the rest in chunks. That alone would have saved me half my time.<\/p>\n<p>Second, search your codebase for <code>@apply<\/code> and triage every file. Files inside scoped contexts need <code>@reference<\/code>. Files in your main entry don&rsquo;t. I should have done this in five minutes at the start. Instead I debugged a styleless component for forty.<\/p>\n<p>Third, if you&rsquo;re using TypeScript and have a <code>tailwind.config.ts<\/code> that&rsquo;s importing types from <code>tailwindcss<\/code>, those types still work, but the import path may have shifted. I had a typed plugin where <code>import type { Config } from 'tailwindcss'<\/code> started warning. Painless fix, but worth knowing before you panic about your CI failing on type-check.<\/p>\n<p>If you&rsquo;re upgrading a real production app, set aside an afternoon, not a Saturday. Branch the project, run the upgrade tool, fix <code>@apply<\/code>, re-test your forms (they&rsquo;re the most likely place for ring and border defaults to bite), and ship it behind a flag if you can. I&rsquo;ve been shipping under v4 in a couple of <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">client projects on my work page<\/a>, 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.<\/p>\n<p>The migration&rsquo;s friendly. The defaults aren&rsquo;t. Read the diff before you trust the diff.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I sat on the Tailwind v4 upgrade for three months. Here&#8217;s what actually broke when I migrated a production Next.js app, and what I&#8217;d do differently.<\/p>\n","protected":false},"author":2,"featured_media":216,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I sat on the Tailwind v4 upgrade for three months. Here's what actually broke when I migrated a production Next.js app, and what I'd do differently.","rank_math_focus_keyword":"tailwind css v4 migration guide","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[138,35],"tags":[37,38,40,139,36,134,39],"class_list":["post-217","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-frontend","category-web-development","tag-css","tag-frontend","tag-migration","tag-tailwind","tag-tailwind-css","tag-tailwind-v4","tag-web-development"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/217","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=217"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/217\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/216"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=217"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=217"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=217"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}