Back to Blog

Tailwind CSS v3 → v4 on Next.js: Why Your Site Looks Broken After Upgrading (and the 3 Fixes)

June 25, 20266 min read

We upgraded one of our production apps from Tailwind CSS 3.4 to 4.3 recently. The stack is the one a lot of people are running in 2026: Next.js 16 with Turbopack, a PostgreSQL/Prisma backend, deployed behind Cloudflare. The migration "succeeded" — the build passed, no errors — and then the homepage loaded with every element jammed against the left edge, no spacing, no centering. Oddly, the colors, fonts, and dark theme all looked perfect.

It took longer than it should have to untangle, because there were actually three separate problems stacked on top of each other, and none of them fail the build. If your stack looks like ours, here are the three bugs the upgrade tool won't fix, the exact symptom that identifies each, and how to fix it.

Start with the official upgrade tool

Tailwind ships a codemod that does the mechanical part:

npx @tailwindcss/upgrade

It rewrites the old @tailwind base/components/utilities directives to a single @import "tailwindcss";, migrates your tailwind.config.js theme into the new CSS-first @theme block, swaps the PostCSS plugin to @tailwindcss/postcss, drops autoprefixer (v4 handles prefixing itself), and renames the few utilities that changed (shadow-smshadow-xs, and friends). It wants a clean git tree so you can review the diff; if the project has no git repo, add --force. The tool is good at what it does. The problem is the three things it doesn't do.

Bug #1: Your article/prose styling disappears

Symptom: Most of the site looks fine, but anywhere you used the Typography plugin — blog post bodies, docs, terms pages — the rich text goes flat: headings, lists, and links render as plain unstyled HTML.

Cause: In v3 you registered it in the JS config (plugins: [require('@tailwindcss/typography')]). The upgrade tool deletes tailwind.config.js and never re-registers the plugin, so your prose classes now generate nothing.

Fix: v4 loads plugins from the CSS. One line, right after the import:

@import "tailwindcss";
@plugin "@tailwindcss/typography";

Bug #2: Everything collapses to the left — but the colors are fine

This is the one that cost us the most time, and it's the most important thing in this article.

Symptom: The page looks like the stylesheet half-loaded — content crammed against the left edge, no padding or margin anywhere, nothing centered — but your text colors, fonts, and custom theme are all correct. That specific combination — layout broken, colors fine — is a fingerprint. Memorize it.

Cause: A global reset in your CSS that looks completely innocent:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

...sitting outside any @layer. Here's the part that bites everyone: Tailwind v4 puts its utilities inside native CSS cascade layers (@layer utilities). And cascade layers have a rule most developers have never had to think about — unlayered CSS always beats layered CSS, regardless of specificity.

So that bare * reset, which is unlayered, now overrides every single padding and margin utility — px-6, py-4, mx-auto, mt-8, all of it. The utilities are still in the compiled CSS; they just lose the cascade. Your spacing and centering evaporate, which collapses the entire layout. Colors survive only because the reset doesn't touch color.

In v3 this exact reset worked fine, because v3 didn't emit native layers — precedence came down to specificity, and a class selector like .px-6 (specificity 0-1-0) simply outranked the universal * (0-0-0). v4 changed the model, and your old reset quietly flipped from harmless to destructive.

Fix: Wrap your resets and base element styles in @layer base so they sit below the utilities layer in the cascade:

@layer base {
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }

  body {
    background: var(--bg);
    color: var(--text);
  }
}

Now px-6 and mx-auto win again and the layout comes back. One nuance: only your resets and bare element selectors (*, html, body, h1, and so on) need to move into @layer base. Leave your :root custom-property declarations and your own helper classes (.gradient-text, .glow) unlayered — those are supposed to win.

Bug #3: Some utilities never generate (the @source fix)

Symptom: Subtler than #2 — the page is mostly styled, but some utilities seem to be missing entirely, and the generated CSS file is noticeably smaller than you'd expect.

Cause: v4 dropped the explicit content: [...] globs in favor of automatic source detection. To decide what to scan, it looks for your project root — and it uses the .git directory to find it. On a deployed app that has no git repo (a build artifact rsynced to a server, for example), that detection can miss your template folders, so the classes you only use in those files never get generated.

Fix: Stop relying on auto-detection and declare your sources explicitly. Paths are relative to the CSS file:

@import "tailwindcss";
@plugin "@tailwindcss/typography";
@source "../app";
@source "../components";

It's cheap insurance — include it even if auto-detection happens to work, and especially in CI/CD pipelines where .git may not be present at build time.

The trap that makes your fixes look like they failed: caching

Here's what nearly sent us in circles. Every rebuild changes the hashed filename of your CSS bundle (something like 3xkc7q7i9ohyv.css). Two layers of caching will happily serve you a stale copy and make a working fix look broken:

  • Your browser cache, which holds the old HTML that points at an old CSS hash.
  • Your CDN edge cache (Cloudflare, and the like).

The critical gotcha: an incognito/private window bypasses your browser cache but NOT the CDN edge cache. So you can deploy the real fix, open a private window, and still see the broken page — and conclude, wrongly, that your fix didn't work.

How to verify correctly: check the origin first, bypassing the CDN entirely. Hit your app's server directly (the local port, not the public domain), look at which CSS file the HTML links, and confirm that file returns 200 with a sensible size — compare its byte count to your last known-good build. Only once the origin is confirmed correct should you purge the CDN cache and re-check the public URL. Judging the fix by the public, cached URL is how you waste an afternoon.

The five-minute checklist

  • Run npx @tailwindcss/upgrade (add --force if there is no git repo).
  • If you use the Typography plugin, re-add @plugin "@tailwindcss/typography"; after the import.
  • Move every reset and bare-element rule (*, html, body, element selectors) into @layer base. This is the layout-killer.
  • Add explicit @source lines for your template directories.
  • Rebuild, verify on the origin, then purge your CDN, then check the public site.
  • If the layout is broken but the colors are fine → it is an unlayered reset. Every time.

This isn't Tailwind's fault

None of these are bugs in v4. Moving to native cascade layers is a genuinely better architecture — it makes the relationship between your CSS and Tailwind's explicit instead of a specificity coin-flip. But it does change the precedence rules, and a few perfectly reasonable v3 habits (an unlayered global reset, a JS-registered plugin, implicit content paths) turn into silent breakage. Knowing the symptoms — especially the "colors fine, layout broken" signature of an unlayered reset — turns the whole migration from a frustrating afternoon into about five minutes of work.

Need help building this?

I turn ideas like these into production-ready software. Let's talk about your project.

Get a Free Quote