Tailwind CSS v4 with Next.js 16: CSS-First Setup, @theme Tokens, and Design System Migration (2026)
Tailwind CSS v4 with Next.js 16 goes CSS-first: @import 'tailwindcss', delete the config file, and define tokens in @theme. Covers setup, dark mode gotchas, Turbopack HMR fixes for monorepos, and the parts of v3-to-v4 migration the codemod misses.
Tailwind CSS v4 with Next.js 16 is a CSS-first setup: install tailwindcss and @tailwindcss/postcss, add a single @import "tailwindcss" line to globals.css, delete tailwind.config.js, and define your design tokens with the new @theme directive. Automatic content detection replaces the old content array, and the Rust-based Oxide engine compiles the CSS in under 100 ms on typical projects. This guide walks through the setup, the @theme mental model, the dark-mode gotcha that traps most upgraders, and the Turbopack HMR patch you will absolutely need in a monorepo.
Tailwind v4 replaces tailwind.config.js with a CSS-first @theme block; every token you declare becomes both a CSS variable and a utility class.
Next.js 16 wires Tailwind v4 through @tailwindcss/postcss. Drop autoprefixer, since Lightning CSS now handles vendor prefixes.
The dark: variant follows prefers-color-scheme by default. Class-based dark mode requires an explicit @custom-variant dark (&:where(.dark, .dark *)) line.
Turbopack HMR misses updates in some monorepo layouts unless you declare @source paths for component files that live outside the app root.
The Oxide engine cuts full builds by ~5x and incremental rebuilds by ~100x, and typical Next.js CSS bundles drop from 20–30 KB gzipped to 6–12 KB.
The @tailwindcss/upgrade codemod handles class renames (shadow-sm → shadow-xs, rounded → rounded-sm) but not template literals, cn() calls, or custom plugin configs.
How do you set up Tailwind CSS v4 with Next.js 16?
The fastest path is create-next-app with the --tailwind flag, which wires v4 through PostCSS in about thirty seconds. For an existing Next.js 16 project the manual setup is three files, and there's no configuration ceremony after that.
The PostCSS plugin has moved from tailwindcss to @tailwindcss/postcss. If you leave the old name in your config, Next.js will throw "Module parse failed: Unexpected character '@'" the first time it touches globals.css, because PostCSS never runs and Webpack tries to parse the CSS as JavaScript. Fix postcss.config.mjs:
Then replace the three @tailwind directives in app/globals.css with a single import. This is the change that trips people who copy-paste a v3 starter into a v4 project. v4 does not accept the layered @tailwind base; @tailwind components; @tailwind utilities; block anymore.
/* app/globals.css */
@import "tailwindcss";
Delete tailwind.config.js or tailwind.config.ts. v4 does not read it, and leaving it around invites future you to edit a file that has no effect. Also drop autoprefixer: Tailwind v4 uses Lightning CSS internally for vendor prefixing, so keeping autoprefixer doubles work and occasionally produces conflicting output for gradient syntax. That's the entire install. No content array, no init command (which was removed), no plugin registration. The Oxide engine walks your source tree using .gitignore-aware heuristics.
The @theme directive: your design system, written in CSS
The @theme directive is the single most important concept in v4, and the thing that trips staff engineers coming from v3, because it looks like plain CSS custom properties but it isn't. Variables declared inside @theme generate utility classes; variables declared inside :root do not. That distinction is load-bearing, and the docs bury it.
Because these tokens are real CSS variables, they escape the framework. You can reference var(--color-brand-500) from a plain .module.css file, from a Framer Motion animate prop, from a canvas draw call. That was impossible in v3, where the theme lived inside a JavaScript object the browser never saw. For staff-eng readers, the practical win is that your design system stops being Tailwind-shaped and starts being CSS-shaped, which means every consumer (MDX, Chart.js, Recharts, Radix primitives) can read the same tokens.
Naming rules that actually matter
The token name is the utility name. --color-brand-500 produces bg-brand-500. --color-brand (no shade) produces bg-brand. A common mistake after migration is defining --brand-500 instead of --color-brand-500 and then wondering why bg-brand-500 isn't generated. The --color-, --font-, --spacing-, --radius-, --breakpoint-, and --shadow- prefixes are how the engine knows which utility family to emit. If you name a variable --tone-primary, you get a CSS variable and nothing else.
Overriding the default palette
To wipe out the default color palette and ship only your own tokens (a real design-system move), start the @theme block with --color-*: initial;. This nukes every default color utility and forces every reference to route through your palette, which is exactly what you want when auditing a design system for token discipline.
Why isn't my dark: variant working after upgrading to v4?
The dark: variant compiles fine in v4. It just changed defaults. In v3, most teams configured darkMode: "class" in tailwind.config.js. In v4 that config file isn't read, so the variant falls back to prefers-color-scheme. Every dark:bg-slate-900 in your app still emits CSS, but toggling a .dark class on <html> now does nothing until you explicitly opt in.
I hit this exact bug shipping a redesign last winter. The fix is one line in globals.css:
@import "tailwindcss";
/* Class-based dark mode (e.g., <html class="dark">) */
@custom-variant dark (&:where(.dark, .dark *));
/* OR: data-attribute variant (compatible with next-themes attribute="data-theme") */
/* @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); */
Pair it with next-themes for the toggle. In your root app/layout.tsx, wrap the tree in <ThemeProvider> with attribute="class" and defaultTheme="system". Add suppressHydrationWarning to the <html> element so React doesn't scream about the class attribute mismatch during hydration. (The reason: next-themes writes the class before React hydrates, which is intentional and produces a benign mismatch warning otherwise.)
Once the variant is wired, define your dark-mode tokens by re-declaring variables in a .dark selector. You aren't fighting the framework. @theme generates the utilities, and .dark { --color-surface: ... } overrides the values.
Turbopack HMR + Tailwind v4: the @source fix nobody talks about
Turbopack is the default bundler in Next.js 16, and the pairing with the Oxide engine is genuinely fast. I clock incremental rebuilds around 40 ms on our design-system package. The footgun is that Turbopack's file-watcher and Tailwind's automatic content detection sometimes disagree about which files live "inside" the project, especially in a Turborepo layout where packages/ui sits outside the Next.js app.
Symptom: you edit a component in packages/ui/src/Button.tsx, save, and the browser hot-reloads, but a class you added (like bg-brand-500) has no styles. Full page refresh doesn't fix it. Rebuild fixes it. This is Tailwind's content-detection missing the file, not Turbopack's fault.
The fix is the @source directive:
/* apps/web/app/globals.css */
@import "tailwindcss";
/* Force the Oxide engine to watch these paths */
@source "../../../packages/ui/src/**/*.{ts,tsx}";
@source "../../../packages/marketing/src/**/*.{ts,tsx}";
/* Same trick for a node_modules-shipped component library */
@source "../node_modules/@acme/design-system/dist/**/*.js";
Two things to know. First, paths are relative to the CSS file that declares them, not the project root (a subtle detail that will burn thirty minutes if you assume otherwise). Second, @source is additive; declaring it doesn't disable automatic detection, it just extends the scan. In a monorepo I always add explicit @source lines for every workspace package that ships Tailwind classes, even when detection seems to be working, because "seems to be working" isn't a state you want a design system to live in.
If you want the opposite (excluding a directory from the scan) use @source not "../legacy/**/*.tsx". That's useful when a legacy directory contains strings that look like Tailwind classes but aren't. Say, a documentation site rendering v3 syntax examples, which otherwise get compiled into your production CSS and inflate the bundle.
For deeper context on the Next.js 16 bundler, our complete Turbopack guide covers the migration path and the ecosystem gaps that still exist. If you're also chasing bundle size, the bundle analyzer walkthrough pairs well with the CSS-shrinkage discussion below.
Migrating from Tailwind v3 to v4: the parts the codemod misses
Run the official codemod first. It handles maybe 80% of the churn:
npx @tailwindcss/upgrade
What it does: bumps dependencies, converts @tailwind directives to @import "tailwindcss", translates your tailwind.config.js theme block into a @theme block in CSS, and renames legacy utility aliases in .tsx/.jsx/.html files. What it doesn't do (and where staff-eng review actually earns its keep) is the following list.
Class renames inside template literals and cn() helpers
The codemod uses a JSX-aware parser, which means anything inside a template literal, a clsx() call, or a cva() variant map is invisible to it. These renames still need to happen:
Category
v3
v4
Shadow (previous default)
shadow-sm
shadow-xs
Shadow (unqualified)
shadow
shadow-sm
Radius (previous default)
rounded-sm
rounded-xs
Radius (unqualified)
rounded
rounded-sm
Blur (previous default)
blur-sm
blur-xs
Ring width default
ring (3px)
ring (1px)
Outline width default
outline (2px)
outline (1px)
Gradient direction
bg-gradient-to-r
bg-linear-to-r
Run a grep over your cva and cn call sites. In our codebase that produced roughly four hundred replacements, most of them inside variant maps in the design-system package. The ring default change is the one that will silently break your focus rings, and (speaking from experience) design-system regressions from focus-ring width shifts are exactly the kind of thing that slips past PR review and lands in production.
@apply inside CSS Modules needs @reference
If you use @apply inside a Component.module.css file, v4 needs an explicit @reference at the top so it knows which theme to resolve utility names against. Without it, @apply text-brand-500 either silently emits nothing or throws depending on your PostCSS config.
Not every v3 plugin has a v4 build. The escape hatch is @config, which loads a legacy tailwind.config.js alongside the CSS-first setup. Useful if you depend on, say, an unmaintained typography plugin. Treat it as a temporary bridge, not a destination.
Sharing design tokens across a monorepo with @theme
In a Turborepo layout with a shared UI package, the pattern that works best is a single tokens.css file in the design-system package, imported by every consuming app. Because @theme is just CSS, it composes across imports without any bundler ceremony.
This layout gives you one canonical token source consumed by every app, and it makes theme drift visible in code review. A token change is a diff in one file, not a spelunking expedition through a JavaScript object. It's also how our Turborepo monorepo setup keeps design tokens synchronized across the web app, marketing site, and email templates without a build-time codegen step. Pair this with the next/font setup so --font-sans points at the CSS variable that next/font exposes, and font swaps happen in one file.
Runtime theming with data attributes
Because tokens are CSS variables, you can theme per-tenant, per-user, or per-route by scoping overrides to a data attribute. For a multi-tenant Next.js app, this is the pattern I ship:
Set data-tenant on the <html> element from a server component that reads the subdomain, and every bg-brand-500 in the tree re-resolves against the tenant palette at zero runtime cost. No React context, no CSS-in-JS, no server-computed style tag.
Where Tailwind v4 fits with the rest of the Next.js 16 stack
The v4 build is fast enough that Tailwind stops being a bundler concern. Full builds finish in the low hundreds of milliseconds; incremental rebuilds are effectively instant. In production, the CSS bundle for a mid-size Next.js app drops from 20–30 KB gzipped on v3 to 6–12 KB on v4, mostly because Oxide is aggressive about tree-shaking utilities that never render.
That said, the interaction with React Server Components is worth understanding. Tailwind classes are inert strings. They compile to CSS at build time and have no runtime cost, so they compose freely with server components, client components, and use cache boundaries alike. This is the opposite of most CSS-in-JS libraries, which need a runtime and often ship a serialization boundary that fights with RSC. If you're migrating off styled-components or emotion, Tailwind v4 is the lowest-friction landing spot because it deletes the runtime entirely.
The other integration point worth naming is Tailwind CSS IntelliSense in VS Code. The extension needs to be current, since anything older than the January 2025 release won't autocomplete @theme, @custom-variant, or the new class names. If autocomplete looks half-broken after an upgrade, that's where to check first.
Does Tailwind v4 still need a tailwind.config.js file?
No. Tailwind v4 is CSS-first: theme tokens live in a @theme block in your stylesheet and content detection is automatic. You should delete tailwind.config.js after migrating. The only reason to keep one is the temporary @config escape hatch for v3-only third-party plugins.
Where do I put custom colors in Tailwind v4?
Inside an @theme block in your global stylesheet, using the --color-* naming prefix. --color-brand-500: oklch(0.65 0.18 250); generates bg-brand-500, text-brand-500, border-brand-500, and every related utility automatically.
Why aren't my Tailwind classes generating in a monorepo package?
Tailwind's automatic content detection skips workspace packages that live outside the Next.js app. Add explicit @source directives in globals.css pointing to each package's source, e.g. @source "../../../packages/ui/src/**/*.{ts,tsx}". Paths are relative to the CSS file, not the project root.
Is Tailwind CSS v4 stable enough for production in 2026?
Yes. v4.0 shipped in January 2025, and the ecosystem (Next.js, VS Code IntelliSense, most component libraries) caught up over the following year. The Oxide engine is production-hardened, the CSS-first config is stable, and the class rename churn from the initial migration is behind us. New Next.js 16 projects should start on v4.
How do I use @apply inside a CSS Module in Tailwind v4?
Add @reference "../app/globals.css"; at the top of the module file. Without @reference, v4 doesn't know which theme to resolve utility class names against, and @apply either silently emits nothing or throws depending on your PostCSS setup.
Learn how to install Better Auth in Next.js 16, wire up Drizzle or Prisma, add email/password plus Google and GitHub social login, and lock down routes with proxy.ts and Server Action session checks.
Prefetch on the server, hydrate on the client. A 2026 guide to using TanStack Query v5 with the Next.js 16 App Router: HydrationBoundary, streaming, Server Actions, and the common hydration errors that break RSC setups.
Real effort estimates and a phase-by-phase playbook for migrating from Gatsby to Next.js 16 App Router: GraphQL to Server Components, plugin mapping, dynamic routes, redirects, and deployment.