Tutorial12 min read · Updated September 10, 2026

How to Add Tailwind CSS v4 to Next.js (2026)

Tailwind v4 moved configuration out of JavaScript and into CSS. There is no `tailwind.config.js` any more, no `content` array to keep in sync, and no `@tailwind base/components/utilities` triple — a single `@import "tailwindcss"` and a `@theme` block replace all of it. This guide sets it up in a Next.js App Router project, then covers tokens, dark mode, custom utilities, and what breaks when you upgrade from v3.

What actually changed in v4

The headline change is that the config is CSS. A v3 project had a `tailwind.config.js` exporting a theme object, a `content` glob, and a plugins array; a v4 project has a CSS file with `@import "tailwindcss"` and, if it needs anything custom, a `@theme` block. The JavaScript config still works through the `@config` directive, but it is a compatibility path rather than the default.

Two consequences follow, and both are improvements. First, every design token you declare is also a real CSS custom property at runtime, so `var(--color-brand-500)` works in a plain stylesheet, in an inline style, and in a component library that knows nothing about Tailwind. Second, the `content` array is gone: v4 scans your project automatically, skipping anything in `.gitignore`, `node_modules`, binary files, CSS files and lockfiles, which removes an entire category of bug where a new folder silently produced no styles.

The engine was rewritten too, and the practical version of that is: v4 requires Safari 16.4, Chrome 111 or Firefox 128 as a floor, because it leans on cascade layers, `@property` and `color-mix()`. If you have to support older browsers than that, stay on v3 — this is a hard requirement, not a progressive enhancement.

Step 1 — Install it in a Next.js project

`npx create-next-app@latest` offers Tailwind during the prompts and wires all of this for you; if you said yes there, skip to the next section. For an existing project, `npm i tailwindcss @tailwindcss/postcss` — note that the PostCSS plugin is its own package in v4, which is the single most common reason a copied v3 setup fails.

Create `postcss.config.mjs` at the project root with `const config = { plugins: { "@tailwindcss/postcss": {} } }; export default config`. That is the whole file. `autoprefixer` and `postcss-import` are no longer needed — v4 handles vendor prefixing and `@import` resolution itself, and leaving them in the pipeline can produce duplicated or mangled output.

Create `src/app/globals.css` with a single line: `@import "tailwindcss";`. Then import it once, at the top of `src/app/layout.tsx`, with `import "./globals.css"`. Because the App Router root layout wraps every route, one import is all you need — importing the same stylesheet again from a page is harmless but pointless.

That is a working install. `<h1 className="text-3xl font-bold tracking-tight">` renders styled on the next request. If it does not, the cause is almost always one of three things: the PostCSS plugin package is missing, the CSS file is never imported by the root layout, or you still have the old `@tailwind base;` triple at the top of the stylesheet.

Step 2 — Design tokens with @theme

`@theme` is where you extend the default scale. Declaring `@theme { --color-brand-500: oklch(0.62 0.19 265); --font-display: "Satoshi", sans-serif; --breakpoint-3xl: 120rem; }` does two things at once: it generates the matching utilities (`bg-brand-500`, `text-brand-500`, `border-brand-500`, `font-display`, `3xl:grid-cols-4`) and it emits `--color-brand-500` as a custom property on the root element.

The namespace prefix is what decides which utilities appear, and it is worth learning rather than guessing: `--color-*` produces colour utilities, `--font-*` font families, `--text-*` font sizes, `--spacing-*` the spacing scale, `--breakpoint-*` responsive variants, `--radius-*` border radii, `--shadow-*` box shadows, `--ease-*` timing functions. A variable declared inside `@theme` with an unrecognised prefix is emitted as a plain custom property and generates no utility at all.

Use `oklch()` for colour ramps. It is perceptually uniform, so a lightness step of the same size looks like the same size difference across hues — the reason a naive HSL ramp always has one shade that looks wrong. If you are starting from a single brand colour, build the ramp first and paste the result in as `--color-brand-50` through `--color-brand-950`.

There is a variant worth knowing about: `@theme inline`. Use it when a token's value is itself a `var()` pointing at something you redefine later — a light/dark token, typically. The `inline` form substitutes the value where the utility is used instead of emitting an indirection, which is what makes `--color-background: var(--background)` behave correctly when `--background` changes under a dark class. Reach for it for exactly that case and plain `@theme` for everything else.

Step 3 — Dark mode

Out of the box, `dark:` follows the operating system via `prefers-color-scheme`. That is the right default and needs no configuration. What it will not do is respond to a toggle in your own UI, because there is no class for it to watch.

For a toggle, redefine the variant: `@custom-variant dark (&:is(.dark *));`. Now `dark:bg-neutral-900` applies to any element inside an element carrying the `dark` class, so putting that class on `<html>` flips the whole document. Pair it with `next-themes`, whose `ThemeProvider` writes the class and handles the flash-of-wrong-theme problem on first paint, which a hand-rolled `useEffect` toggle will not.

The pattern that scales past a handful of components is semantic tokens rather than literal colours. Define `--background`, `--foreground`, `--card`, `--border`, `--primary` as custom properties under `:root`, redefine the same names under `.dark`, and map them into Tailwind with `@theme inline { --color-background: var(--background); ... }`. Components then say `bg-background text-foreground` once and are correct in both themes, instead of carrying a `dark:` variant on every colour utility.

Dark palettes fail accessibility far more often than light ones, usually because a muted foreground that reads fine on white is too dim on near-black. Before you ship, put each foreground and background pair through a contrast check — body text wants a ratio of at least 4.5 to 1, and large text at least 3 to 1.

Step 4 — Custom utilities and variants

When you need a utility Tailwind does not ship, `@utility` is the supported route: `@utility tab-4 { tab-size: 4; }`. The important difference from writing a plain CSS class is that a `@utility` participates in the variant system, so `md:tab-4` and `hover:tab-4` work, and it is sorted into the utilities cascade layer so specificity behaves predictably.

`@custom-variant` defines a new variant. `@custom-variant pointer-coarse (@media (pointer: coarse));` gives you `pointer-coarse:p-4` for touch targets; `@custom-variant sidebar-open (&:is([data-sidebar=open] *));` gives you a variant driven by a data attribute your own code sets. This is how you express state-driven styling without a class-name-building helper.

Going the other way, `@variant` applies a Tailwind variant inside a regular CSS rule: `.legacy-widget { background: white; @variant dark { background: black; } }`. That is the escape hatch for third-party markup you cannot add classes to.

`@apply` still exists and is still mostly a trap. It moves styling out of the markup, which is the one thing utility CSS is for, and it produces rules that are harder to override than the utilities they inline. The legitimate uses are narrow: styling HTML you do not control, and component `<style>` blocks — and in a scoped `<style>` block you will need `@reference "../app.css"` first so the compiler can see your theme without duplicating it into the output.

Step 5 — When automatic source detection is not enough

v4 scans the project automatically, and the default exclusions are the ones you want — but they also mean a design system living in `node_modules` is invisible. Register it explicitly: `@source "../node_modules/@acmecorp/ui-lib";`. The same directive takes an exclusion form, `@source not "../src/components/legacy";`, for a folder full of class names you no longer want compiled.

Dynamically constructed class names are the other gap, and the fix is not to construct them. Tailwind reads your source as plain text, so `` `bg-${color}-500` `` produces nothing, because the literal string never appears. Write the full class names in a lookup object — `const tone = { danger: "bg-red-500", ok: "bg-green-500" }` — and index into that. When the strings genuinely cannot exist in source (they come from a database, say), `@source inline("bg-red-{50,{100..900..100},950}")` safelists them.

You can also move the scan root with `@import "tailwindcss" source("../src");`, or turn automatic detection off entirely with `source(none)` and list every path by hand. Both are for monorepos where the default root guesses wrong. In a single Next.js app, neither should be necessary — if you find yourself reaching for them, check first that the file really is being scanned and is not sitting in `.gitignore`.

Upgrading an existing v3 project

Run `npx @tailwindcss/upgrade` on a clean branch, with Node 20 or newer. It rewrites the imports, migrates `tailwind.config.js` into a `@theme` block, and renames the utilities that changed. It does a genuinely good job, and it is still worth reading the diff rather than trusting it — it cannot see class names your code builds at runtime, and it will not touch strings inside a database or a CMS.

Two default changes will alter how your existing pages look, and neither is a bug. The default border colour moved from `gray-200` to `currentColor`, so a bare `border` now draws in the text colour — add the colour explicitly. And `ring` went from a 3px ring to a 1px one, so anywhere you relied on the old default, swap it for `ring-3`.

The renames follow one rule with one exception. The scale shifted down a step, so what was `shadow-sm` is `shadow-xs`, what was `shadow` is `shadow-sm`, and the same pattern applies to `blur`, `backdrop-blur`, `drop-shadow` and `rounded`. The exception is `outline-none`, which became `outline-hidden` — the new `outline-none` really does set `outline-style: none`, which is a different thing and will remove focus rings if you use it by mistake.

The opacity utilities are gone for good: `bg-opacity-50` is now the slash modifier `bg-black/50`, and the same for text, border, divide, ring and placeholder. `flex-shrink-*` and `flex-grow-*` became `shrink-*` and `grow-*`. If you would rather start from a project that is already on v4 than migrate one, describe the site and InBuild generates the Next.js and Tailwind code — a paid plan is required to generate.

Common mistakes

Keeping `autoprefixer` and `postcss-import` in `postcss.config.mjs`. v4 does both jobs internally. Leaving them installed is usually harmless; leaving them in the plugin chain produces duplicated rules and occasionally broken output.

Installing `tailwindcss` but not `@tailwindcss/postcss`. The build runs, no error is printed, and no utility classes are generated. This is the failure that sends people to Stack Overflow most often.

Declaring theme tokens outside `@theme`. A custom property under `:root` is a perfectly good CSS variable, but it generates no utilities — `--color-brand-500` in `:root` will not give you `bg-brand-500`. It has to be inside the `@theme` block.

Expecting `@theme` to replace the default scale. It extends it. If you want your ramp to be the only one available, clear the namespace first with `--color-*: initial;` inside the block, then declare yours.

Adding a `dark` class to `<html>` without redefining the variant. With the stock configuration `dark:` watches `prefers-color-scheme` and ignores your class entirely, so the toggle appears to do nothing. You need the `@custom-variant dark` line.

Building class names by string concatenation. Covered above, and worth repeating because it is the number one cause of styles that work in development and vanish in the production build — in development the class may still be present from another file that spells it out.

How to do it

  1. 1

    Install the packages

    npm i tailwindcss @tailwindcss/postcss. The PostCSS plugin is a separate package in v4; autoprefixer and postcss-import are no longer needed.

  2. 2

    Configure PostCSS

    postcss.config.mjs exporting { plugins: { "@tailwindcss/postcss": {} } }. That is the entire file.

  3. 3

    Create the stylesheet

    src/app/globals.css containing @import "tailwindcss";. The three @tailwind base/components/utilities directives from v3 are gone.

  4. 4

    Import it in the root layout

    import "./globals.css" at the top of src/app/layout.tsx. One import covers every route, because the root layout wraps them all.

  5. 5

    Declare your tokens

    Add a @theme block with --color-*, --font-*, --breakpoint-* and --radius-* variables. Each one generates the matching utilities and a real CSS custom property.

  6. 6

    Set up dark mode

    Add @custom-variant dark (&:is(.dark *)); if you want a toggle rather than the operating system preference, and drive the class with next-themes.

  7. 7

    Check what is being scanned

    Source detection is automatic and skips .gitignore, node_modules, binaries, CSS files and lockfiles. Add @source for anything outside that, and never build class names by concatenation.

Frequently asked questions

Where did tailwind.config.js go?

It is optional in v4 and no longer auto-detected. Configuration lives in CSS: @theme for design tokens, @utility for custom utilities, @custom-variant for variants, @source for extra scan paths. If you have an existing JavaScript config you want to keep, load it explicitly with @config "../../tailwind.config.js" — but corePlugins, safelist and separator from that file are not supported, and safelisting is done with @source inline() instead.

Why are none of my classes working after I installed it?

In order of likelihood: @tailwindcss/postcss is not installed or not listed in postcss.config.mjs; the stylesheet is never imported by the root layout; the stylesheet still starts with the v3 @tailwind base/components/utilities triple instead of @import "tailwindcss"; or the file using the classes is excluded from source detection because it is listed in .gitignore. Check them in that order before anything else.

Do I still need clsx and tailwind-merge?

Yes, and for unchanged reasons. clsx composes conditional class strings; tailwind-merge resolves conflicts so a prop-supplied px-6 actually beats a component's built-in px-4 rather than depending on source order. The usual helper is a cn() function that pipes clsx output through twMerge. Neither is affected by the v4 configuration change.

Does Tailwind v4 work with Turbopack?

Yes. The PostCSS plugin path described here is the supported setup for Next.js and works under both the webpack and Turbopack development servers. The separate @tailwindcss/vite plugin is for Vite projects and is not the one to install in a Next.js app.

Is @apply still a good idea?

Rarely. It pulls styling back out of the markup, which removes the main benefit of utility CSS, and it creates rules that are awkward to override later. The defensible uses are markup you do not control — content from a CMS, a third-party widget — and framework component style blocks, where you also need @reference to point at your stylesheet so the theme is visible without being duplicated into the output.

How do I use a theme colour outside Tailwind, in a chart library or an inline style?

Every token declared in @theme is emitted as a CSS custom property, so read it with var(--color-brand-500) anywhere CSS is accepted. For a JavaScript API that wants a resolved string rather than a var() reference, getComputedStyle(document.documentElement).getPropertyValue("--color-brand-500") returns the current value — and it follows the active theme, which a hardcoded hex will not.

Free tools used in this guide

No signup — each runs in the browser or as a single server-side fetch.

Ready to build?

See a real InBuild template rendered live, with the exact exported code, before you pick a plan — no account needed.

Or skip the tutorial: describe it and InBuild generates the Next.js code for you. A paid plan is required to generate.

Build this with InBuild

More Next.js guides