Tailwind CSS in Next.js : Install and Wire-up
Covers every supported combination of Tailwind version (v3.4 vs v4.0+) and Next.js router (App Router vs Pages Router), plus next/font integration and the documented Turbopack quirk.
Companion skills :
tailwind-impl-config-v4: v4 CSS-first config surface (@theme, @source)tailwind-impl-config-v3: v3 JS config surface (tailwind.config.js)tailwind-impl-build-vite: same install topic for the Vite plugintailwind-core-v3-vs-v4: version-wide API differences
Decision : Which Combination Do You Have
| Tailwind version | Router | Entry CSS lives in | Imported from |
|---|---|---|---|
| v4 | App Router | app/globals.css |
app/layout.tsx |
| v4 | Pages Router | styles/globals.css |
pages/_app.tsx |
| v3 | App Router | app/globals.css |
app/layout.tsx |
| v3 | Pages Router | styles/globals.css |
pages/_app.tsx |
The CSS file location is identical across versions. Only the file
contents differ : @import "tailwindcss"; for v4, three @tailwind
directives for v3.
Minimum Setup : Tailwind v4 + Next.js App Router
npm install tailwindcss @tailwindcss/postcss postcss
postcss.config.mjs (project root) :
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
app/globals.css :
@import "tailwindcss";
app/layout.tsx :
import "./globals.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
That is the complete v4 + App Router install. No tailwind.config.js, no
@tailwind directives, no autoprefixer, no postcss-import. The
@tailwindcss/postcss plugin runs the Oxide engine which bundles all
three.
Minimum Setup : Tailwind v4 + Next.js Pages Router
Install and PostCSS config are identical to the App Router setup above. Only the import location moves :
styles/globals.css :
@import "tailwindcss";
pages/_app.tsx :
import "@/styles/globals.css";
import type { AppProps } from "next/app";
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
Minimum Setup : Tailwind v3 + Next.js App Router
npm install -D tailwindcss@3 postcss autoprefixer
npx tailwindcss init -p
tailwindcss init -p generates two files :
tailwind.config.js :
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: { extend: {} },
plugins: [],
};
postcss.config.js :
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
app/globals.css :
@tailwind base;
@tailwind components;
@tailwind utilities;
app/layout.tsx : identical to the v4 case, just import "./globals.css";.
Minimum Setup : Tailwind v3 + Next.js Pages Router
Same install, same tailwind.config.js, same postcss.config.js. The CSS
file lives at styles/globals.css (same three @tailwind directives),
imported once in pages/_app.tsx :
import "@/styles/globals.css";
import type { AppProps } from "next/app";
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
Wire next/font/google Into the Tailwind Theme
next/font emits a CSS variable that you opt into by applying a className.
Tailwind reads that variable as a token via @theme (v4) or
fontFamily.extend (v3).
v4 pattern
app/layout.tsx :
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body className="font-sans">{children}</body>
</html>
);
}
app/globals.css :
@import "tailwindcss";
@theme inline {
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
ALWAYS use @theme inline { ... } (not bare @theme { ... }) when the
token value contains a var(...) reference to another CSS variable that
exists at runtime. Bare @theme resolves the variable at build time and
emits the resolved literal, which is empty before next/font runs and
strips the family.
v3 pattern
pages/_app.tsx (or app/layout.tsx) wires the className the same way.
The config file declares the family :
// tailwind.config.js
module.exports = {
content: [/* unchanged */],
theme: {
extend: {
fontFamily: {
sans: ["var(--font-inter)", "ui-sans-serif", "system-ui", "sans-serif"],
},
},
},
};
React Server Components : Zero Runtime Cost
Tailwind is a build-time compiler. It scans source files for class
tokens, generates a single static CSS bundle, and injects that bundle via
the same <link rel="stylesheet"> Next.js uses for every other CSS
import. There is no client runtime, no Server Component restriction, no
"use client" boundary needed for styling. RSC, Client Components, and
Server Actions all consume the same CSS.
Consequences :
- NEVER move a Server Component to Client just to use Tailwind classes.
- ALWAYS apply Tailwind classes directly on RSC output ; no wrapper.
- Bundle size scales with the number of utilities used, not with the number of components. Adding a hundred RSC components that share the same utilities adds zero CSS bytes.
Turbopack Arbitrary-Value Miss (Issue 19825)
Affects Next.js 16.1.6 + Tailwind v4.2.1 + next dev --turbo (and
next dev once Turbopack becomes the default). Classes with arbitrary
square-bracket values render in the DOM but the corresponding CSS rule
is missing from the injected stylesheet.
Reproduced tokens : aspect-[12/5], z-[100], h-[80vh]. The same
classes compile correctly under webpack (next dev without --turbo).
Workarounds
- Inline style for layout-critical values :
<div style={{ aspectRatio: "12/5" }} className="w-full bg-zinc-900" /> - Safelist the exact token via
@source inlineso the scanner is forced to emit the rule regardless of Turbopack's incremental scan :@source inline("aspect-[12/5] z-[100] h-[80vh]"); - Disable Turbopack for the affected build : remove
--turbofromnext devuntil the upstream fix lands.
NEVER ship aspect-[12/5] style arbitrary values to a Turbopack
production build without first verifying the rule appears in
.next/static/css. The DOM showing the class is not proof the CSS
exists.
Catch-All Route Glob Trap (Tailwind v3)
The default v3 content glob ./app/**/*.{js,ts,jsx,tsx,mdx} treats
square brackets as a character class. Files inside a folder named
[...slug] or [id] are silently excluded from the scan. Symptom :
classes on dynamic-route pages produce no CSS.
Fix (v3) : add a second content entry that escapes the brackets, or list the catch-all folder explicitly :
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./app/[[]**[]]/*.{js,ts,jsx,tsx,mdx}",
],
Fix (v4) : add an explicit @source :
@import "tailwindcss";
@source "./app/[[]**[]]/**/*.{js,ts,jsx,tsx,mdx}";
Verification Checklist
- CSS file imports exactly once (root layout for App Router,
_app.tsxfor Pages Router). Importing twice doubles the bundle. npm run devshows utilities applying within one second on hot reload.- Dynamic-route pages (any folder with
[id],[...slug],[[...slug]]) render utilities correctly. If not, the bracket-glob trap is active. - If next/font is wired : a token like
text-3xlrenders with the loaded family, not the browser fallback. Inspect computedfont-familyin DevTools. - Production :
npm run buildfinishes withoutPostCSS plugin tailwindcss requires PostCSS 8(v3 only ; installpostcss@8). - Turbopack : if
next dev --turbo, confirm arbitrary-value classes actually emit CSS, not just DOM attributes.
References
references/methods.md: full per-router install walkthroughs with every file and exact contentsreferences/examples.md: workingapp/layout.tsx,pages/_app.tsx, fonts wiring, mixed-mode setupsreferences/anti-patterns.md: the eight Next.js-specific traps with symptoms, root cause, fix, and verification