A real migration story from Next.js 15.1 with Tailwind v3.4 to Next.js 16 with Tailwind v4. Covers the CSS-first config system, OKLCH color conversion, Turbopack landmines, and every class name that changed.
Tyler McDaniel
AI Engineer & IBM Business Partner
I migrated this site — [tostupidtooquit.com](https://tostupidtooquit.com) — from Next.js 15.1 with Tailwind CSS v3.4 to Next.js 16 with Tailwind CSS v4 in February 2026. The Next.js 16 + Tailwind CSS v4 migration guide you'll find in the official docs covers the happy path. This post covers everything else: the runtime behavior changes that broke production, the config restructuring that invalidated half my muscle memory, and the Turbopack-only gotchas that don't show up until you deploy.
I'm writing this because every "migration guide" I found was either a changelog summary or a tutorial written by someone who hadn't actually shipped the migration. This is what happened when I ran the upgrade on a real production site with middleware, dynamic imports, server components, and a CSP policy.
Before I get into specifics, here's the high-level scope of what moved:
| Area | Next.js 15 + Tailwind v3 | Next.js 16 + Tailwind v4 | Breaking? |
|------|--------------------------|--------------------------|-----------|
| CSS config | tailwind.config.ts + postcss.config.mjs | CSS-first @import "tailwindcss" | Yes |
| Theme definition | theme.extend in JS config | @theme block in CSS | Yes |
| Color system | theme.colors with hex/rgb | OKLCH by default, P3 gamut | Visually breaking |
| Content scanning | content: ["./src//*.tsx"] | Automatic source detection | Config removal |
| Bundler | Webpack or Turbopack (opt-in) | Turbopack (default, Webpack deprecated) | Behavioral changes |
| next/dynamic | Works in Server Components with ssr: false | ssr: false banned in Server Components | Build error |
| Middleware | middleware.ts | Renamed to proxy.ts (deprecated warning) | Warning only |
| next.config | next.config.mjs | next.config.ts (native TypeScript) | Optional |
| Font loading | next/font/google | Same API, different internal caching | Subtle |
| Image optimization | next/image with webpack loader | next/image with Turbopack loader | Different sharp behavior |
pnpm add next@16 react@19 react-dom@19
pnpm add -D tailwindcss@4 @tailwindcss/postcss
pnpm remove autoprefixer
Tailwind v4 handles vendor prefixing internally. Autoprefixer is dead weight now.
If you're using @tailwindcss/typography or @tailwindcss/forms, check compatibility. Typography was folded into core in v4.1. Forms still exists as a separate package but the API changed.
This is the biggest mental shift. Tailwind v4 is [CSS-first configuration](https://tailwindcss.com/docs/v4-beta). Your tailwind.config.ts is replaced by directives in your CSS file.
Before — tailwind.config.ts:
import type { Config } from "tailwindcss";const config: Config = {
content: [
"./app//*.{ts,tsx}",
"./components//*.{ts,tsx}",
],
theme: {
extend: {
colors: {
primary: "#0f62fe",
accent: "#6929c4",
background: "#060810",
foreground: "#e5e5e5",
},
fontFamily: {
sans: ["var(--font-space-grotesk)", "system-ui", "sans-serif"],
mono: ["var(--font-jetbrains-mono)", "monospace"],
},
},
},
plugins: [],
};
export default config;
After — app/globals.css:
@import "tailwindcss";@theme inline {
--color-primary: oklch(0.55 0.24 264);
--color-accent: oklch(0.42 0.19 293);
--color-background: oklch(0.12 0.02 264);
--color-foreground: oklch(0.91 0 0);
--font-sans: var(--font-space-grotesk), system-ui, sans-serif;
--font-mono: var(--font-jetbrains-mono), monospace;
--animate-fade-in-up: fade-in-up 0.6s ease-out both;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
A few things that caught me:
@theme inline means the generated CSS variables are inlined directly, not wrapped in a :root selector. If you omit inline, Tailwind generates a @layer theme { :root { ... } } block, which changes cascade order.content array. Tailwind v4 scans your source files automatically. It finds .tsx, .ts, .jsx, .js, .html, .vue, and .svelte files in your project. If you need to include non-standard paths (like a component library in node_modules), use @source:@source "../node_modules/@my-design-system/components";
extend concept is gone. In v3, theme.extend.colors.primary added to the defaults. In v4, defining --color-primary in @theme just... sets it. There's no distinction between extending and overriding.module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
After — postcss.config.mjs:
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
That's it. One plugin. Autoprefixer is bundled.
Tailwind v4 renamed or restructured several utilities. This table covers the ones that actually hit me:
| v3 Class | v4 Class | Notes |
|----------|----------|-------|
| bg-opacity-50 | bg-black/50 | Opacity modifiers are now the only way |
| text-opacity-75 | text-white/75 | Same — slash syntax replaces -opacity- |
| decoration-2 | decoration-2 | Unchanged, but decoration-solid is now decoration-line-solid |
| ring-offset-2 | ring-offset-2 | Unchanged |
| blur-sm | blur-xs / blur-sm | blur-sm radius decreased; old blur-sm ≈ new blur-md |
| shadow-sm | shadow-xs / shadow-sm | Same shift — sizes shifted down |
| rounded-sm | rounded-xs / rounded-sm | Border radius scale shifted |
| p-[10px] | p-[10px] | Arbitrary values unchanged |
| space-x-4 | gap-x-4 with flex | space- still works but gap- is preferred |
The blur-sm → blur-xs rename caused the most visual regressions for me. I had subtle glass effects throughout the site using blur-sm and blur-3xl. The blur radii all shifted, so what was a gentle frosted glass became either too sharp or too diffuse. I had to visually audit every blur usage.
In v3, custom animations lived in tailwind.config.ts:
theme: {
extend: {
animation: {
"fade-in-up": "fade-in-up 0.6s ease-out both",
},
keyframes: {
"fade-in-up": {
from: { opacity: "0", transform: "translateY(12px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
},
},
},
In v4, they go in CSS:
@theme inline {
--animate-fade-in-up: fade-in-up 0.6s ease-out both;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Then className="animate-fade-in-up" works as before. The convention is --animate-{name} in @theme.
This is where the Next.js 16 + Tailwind CSS v4 migration gets interesting. Tailwind v4 was straightforward — tedious but predictable. The Next.js 16 changes were behavioral and often didn't surface until build time or production.
next/dynamic with ssr: false in Server ComponentsThis one killed my deploy pipeline for two hours. In Next.js 15, this was fine in a Server Component:
// app/page.tsx — Server Component
import dynamic from "next/dynamic";
const P5Background = dynamic(
() => import("@/components/p5-background"),
{ ssr: false }
);
In Next.js 16 with Turbopack, this throws a build error:
Error: ssr: false is not allowed with next/dynamic in Server Components.
Move it to a Client Component.
The fix: create a client wrapper component.
// components/p5-background-lazy.tsx
"use client";
import dynamic from "next/dynamic";
export const P5BackgroundLazy = dynamic(
() => import("@/components/p5-background"),
{ ssr: false }
);
// app/page.tsx — Server Component
import { P5BackgroundLazy } from "@/components/p5-background-lazy";
export default function Home() {
return (
<>
<P5BackgroundLazy />
{/ rest of page /}
</>
);
}
This is documented in the [Next.js 16 upgrade guide](https://nextjs.org/docs/app/building-your-application/upgrading), but it's buried under "Breaking Changes" in paragraph form. If you have next/dynamic with ssr: false anywhere in a Server Component, it will break. grep -r "ssr: false" app/ before you deploy.
Next.js 16 started a rename from middleware.ts to proxy.ts. As of 16.2, both work, but you'll see a console deprecation warning on every request:
⚠ middleware.ts is deprecated. Rename to proxy.ts.
It's cosmetic. I haven't renamed mine because some deployment platforms and CDN configs reference middleware explicitly, and I don't want to chase edge cases on a cosmetic change. The function signature and behavior are identical.
Next.js 16 supports next.config.ts natively — no more .mjs with @ts-check hacks. The migration:
Before — next.config.mjs:
/ @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
turbo: {},
},
};
export default nextConfig;
After — next.config.ts:
import type { NextConfig } from "next";const nextConfig: NextConfig = {
// Turbopack is now default — no experimental flag needed
};
export default nextConfig;
sizes Attribute Behavioral ChangeTurbopack's image optimization pipeline handles next/image sizes differently than Webpack did. In Next.js 15, omitting sizes on a responsive image generated a reasonable default srcset. In Next.js 16, omitting sizes generates a full-width srcset that ignores your layout constraints. Always specify sizes explicitly now:
<Image
src="/og-image.png"
alt="Open Graph preview"
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, 50vw"
/>
This one doesn't show up in any migration guide I've found, and it cost me three hours of debugging.
Tailwind v3 injected utilities at the end of the stylesheet, giving them high specificity by default. Tailwind v4 uses [CSS Cascade Layers](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Cascade_layers):
@layer theme, base, components, utilities;
This means any unlayered CSS you have automatically wins over Tailwind utilities, because unlayered styles beat layered styles in cascade order. If you have a global stylesheet with:
.hero-title {
color: white;
}
This will override text-primary in v4, where it wouldn't have in v3. The fix: put your custom CSS in a layer:
@layer components {
.hero-title {
color: white;
}
}
Or better, convert it to Tailwind utilities and delete the custom CSS entirely. I chose the latter for everything except third-party embed styles (Calendly) that I couldn't control.
If you're running a Content Security Policy (I am — strict-dynamic with nonces), Tailwind v4's build output changed in a way that affects CSP. In v3, Tailwind generated a single CSS file with all utilities. In v4 with Turbopack, CSS is code-split per route, and the CSS module loading uses inline injection during development.
In production, this is fine — Turbopack outputs static .css files. But in development, the inline style injection violates style-src CSP if you're not allowing 'unsafe-inline' or using nonces on style tags. I conditionally relax CSP in development:
// middleware.ts
const isDev = process.env.NODE_ENV === "development";
const stylePolicy = isDev ? "'unsafe-inline'" : 'nonce-${nonce}';
Not elegant, but it's the reality of running strict CSP with modern frontend tooling.
Here's my actual checklist, in order:
pnpm add next@16 react@19 react-dom@19 tailwindcss@4 @tailwindcss/postcsspnpm remove autoprefixer @tailwindcss/typography (typography is in core now)tailwind.config.tspostcss.config.mjs to use @tailwindcss/postcssglobals.css to use @import "tailwindcss" + @themegrep -rn "bg-opacity\|text-opacity" --include="*.tsx" — convert to slash syntaxgrep -rn "blur-sm\|shadow-sm\|rounded-sm" --include="*.tsx" — audit size scale shiftsgrep -rn "ssr: false" app/ — move all next/dynamic with ssr: false to client componentsnext.config.mjs → next.config.ts (optional, but do it)sizes to every next/image that doesn't have thempnpm build — Turbopack will surface errors Webpack silently acceptedThe build is faster. Turbopack's dev server starts in under 400ms for this site compared to 2-3 seconds with Webpack. Hot reload is instant — not "fast," actually instant. The CSS-first configuration in Tailwind v4 is genuinely better once you're past the learning curve; having your theme live next to your CSS instead of in a JavaScript file makes more sense.
But the migration itself was a solid two days for a medium-complexity site. The Tailwind upgrade was a few hours. Fixing the Next.js 16 behavioral changes — especially the ssr: false restriction and cascade layer interactions — took the rest. The [Tailwind v4 upgrade guide](https://tailwindcss.com/docs/upgrade-guide) was comprehensive. The [Next.js 16 upgrade docs](https://nextjs.org/docs/app/building-your-application/upgrading) were not.
If you're doing this migration, my honest recommendation: upgrade Tailwind first, get it stable, then upgrade Next.js. Don't do both at once. I did both at once. Learn from my mistakes.
For more on navigating React's server/client boundary — which Next.js 16 makes even more important — see [React Server Components: The Full Mental Model](https://tostupidtooquit.com/blog/react-server-components-mental-model). And if you're rebuilding your design tokens during this migration, [Building a Design Token System That Survives Team Scaling](https://tostupidtooquit.com/blog/building-design-token-system) covers the architecture that pairs well with Tailwind v4's @theme.
---
A three-tier token architecture (primitive, semantic, component) with CSS custom properties, Style Dictionary transforms, Tailwind v4 integration, and dark mode that's a token swap instead of a stylesheet rewrite.
Production TypeScript patterns: const type parameters, satisfies operator, template literal types for API routes, branded types for domain safety, discriminated unions, and the builder pattern with full type inference.
A practical mental model for React Server Components. Covers the two-runtime architecture, serialization boundary rules, composition patterns, streaming with Suspense, caching layers, and common mistakes that break production apps.