React & Next.js
Set up Tailwind v4 in Vite + React and the Next.js App Router, then handle fonts, conditional class names, component variants, Server Components and dark mode.
React has no opinion about CSS, so Tailwind plugs in at the build-tool level. The setup differs between Vite and Next.js; everything after that — class composition, variants, fonts, dark mode — is identical in both.
Choosing your setup#
| Project | Package | Config file |
|---|---|---|
| Vite + React | @tailwindcss/vite | vite.config.ts |
| Next.js (App or Pages Router) | @tailwindcss/postcss | postcss.config.mjs |
| React Router / Remix (Vite-based) | @tailwindcss/vite | vite.config.ts |
Next.js does not build with Vite, so @tailwindcss/vite never runs there. Installing it in a Next.js app is the most common broken React install — see Common mistakes.
Vite + React#
- Install. Two packages, no config file.
- Register the plugin. Before or after the React plugin, order does not matter.
- Import Tailwind in your CSS.
- Import that CSS from your entry module.
npm install tailwindcss @tailwindcss/viteimport { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})@import "tailwindcss";import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)That import './index.css' line is what actually ships the stylesheet. Without it the build compiles nothing and every class is inert.
Next.js App Router#
Next.js runs your CSS through PostCSS, so use @tailwindcss/postcss.
npm install tailwindcss @tailwindcss/postcss postcssconst config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
export default config@import "tailwindcss";Where to import the stylesheet#
Import it once, from the root layout. Next.js hoists it into every route that layout wraps.
import './globals.css'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}Pages Router#
Same packages, same postcss.config.mjs. The stylesheet is imported from pages/_app.tsx instead:
import '@/styles/globals.css'
import type { AppProps } from 'next/app'
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}Turbopack#
Turbopack runs postcss.config.mjs the same way webpack does. No extra configuration is needed, and @tailwindcss/vite is still the wrong package.
Fonts#
next/font self-hosts fonts at build time and gives you a class or a CSS variable. To drive Tailwind's font-* utilities from it, ask for a variable and wire it into your theme.
- Load the font with a
variableoption. - Put the returned
.variableclass on<html>. - Map the variable into
@theme inline.
import { Inter, Roboto_Mono } from 'next/font/google'
import './globals.css'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body className="font-sans antialiased">{children}</body>
</html>
)
}@import "tailwindcss";
@theme inline {
--font-sans: var(--font-inter);
--font-mono: var(--font-roboto-mono);
}font-sans and font-mono now use the loaded fonts everywhere.
Why inline is required#
Without inline, Tailwind generates font-family: var(--font-sans), and --font-sans is defined in @theme — on :root. CSS resolves a variable's value where that variable is declared, not where it is used. At :root, --font-inter does not exist yet, because next/font sets it on the <html> element via the class you added. The lookup falls through to the fallback, and you get the wrong typeface with no error.
@theme inline substitutes the value at build time instead:
/* @theme → indirection, resolved at :root */
.font-sans { font-family: var(--font-sans); }
/* @theme inline → the value itself, resolved where it is used */
.font-sans { font-family: var(--font-inter); }The rule generalises: any theme token whose value is another variable that is set further down the tree needs inline. That covers next/font, runtime-swapped brand tokens, and per-tenant themes.
Conditional classes in JSX#
Tailwind scans your source files as plain text. It never executes your JavaScript, so it can only find class names that appear as complete, unbroken string literals.
The lookup-object pattern#
Map the prop to a complete class string:
const COLORS = {
blue: 'bg-blue-600 hover:bg-blue-500 text-white',
red: 'bg-red-600 hover:bg-red-500 text-white',
amber: 'bg-amber-300 hover:bg-amber-400 text-amber-950',
}
function Badge({ color = 'blue', children }) {
return (
<span className={`inline-flex rounded-full px-3 py-1 text-sm font-medium ${COLORS[color]}`}>
{children}
</span>
)
}Every class now exists verbatim in the file, so the scanner finds it. The pattern has a second benefit: each colour can pick its own shades — amber needs dark text, blue needs white — which a mechanical bg-${color}-600 could never express.
Interpolation is fine as long as the pieces are whole classes:
// Fine — both operands are complete class names.
<div className={`rounded-lg p-4 ${isActive ? 'bg-indigo-600' : 'bg-gray-100'}`} />clsx and tailwind-merge#
These solve two different problems. Reach for the one you actually have.
| Package | Problem it solves |
|---|---|
clsx | Building a class string from conditions without undefined and double spaces |
tailwind-merge | Two conflicting utilities in one string — last one wins by specificity, not source order |
npm install clsx tailwind-mergeclsx flattens conditionals, arrays and objects into one string:
import clsx from 'clsx'
<button
className={clsx(
'rounded-lg px-4 py-2 font-medium',
isPrimary && 'bg-indigo-600 text-white',
isDisabled && 'cursor-not-allowed opacity-50',
)}
/>clsx alone cannot resolve conflicts. "px-2 px-4" produces both rules; which one applies depends on their order in the generated stylesheet, not in your string. That bites when a component accepts a className prop:
// Component ships p-4; caller passes p-8. Result is a coin toss.
<Card className="p-8" />tailwind-merge understands which utilities collide and keeps the last:
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}function Card({ className, children }) {
return (
<div className={cn('rounded-xl bg-white p-4 shadow-sm', className)}>
{children}
</div>
)
}
// p-8 now reliably beats p-4
<Card className="p-8" />Use plain template strings for components with one or two conditions. Add clsx when the conditions multiply. Add tailwind-merge only for components that accept className from outside.
Component variants#
A lookup object scales to multi-axis variants without any library. This is the shape class-variance-authority generates for you — worth knowing before you add the dependency.
import { cn } from '@/lib/cn'
const BASE =
'inline-flex items-center justify-center rounded-lg font-medium transition-colors ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50'
const VARIANTS = {
primary: 'bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:outline-indigo-600',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:outline-gray-400',
ghost: 'text-indigo-700 hover:bg-indigo-50 focus-visible:outline-indigo-600',
}
const SIZES = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
}
export function Button({ variant = 'primary', size = 'md', className, ...props }) {
return (
<button
className={cn(BASE, VARIANTS[variant], SIZES[size], className)}
{...props}
/>
)
}<div class="flex flex-wrap items-center gap-3 p-6">
<button class="inline-flex h-10 items-center justify-center rounded-lg bg-indigo-600 px-4 text-sm font-medium text-white transition-colors hover:bg-indigo-500">Primary</button>
<button class="inline-flex h-10 items-center justify-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-900 transition-colors hover:bg-gray-200">Secondary</button>
<button class="inline-flex h-10 items-center justify-center rounded-lg px-4 text-sm font-medium text-indigo-700 transition-colors hover:bg-indigo-50">Ghost</button>
</div>Two rules keep this scanner-friendly:
- Every value in
VARIANTSandSIZESis a literal string. - The keys are prop values, never fragments of class names.
Adding class-variance-authority buys you typed variant props, compoundVariants and a defaultVariants block. It does not change what Tailwind can see — the strings still have to be complete.
Server Components#
Tailwind is a build-time CSS compiler with no runtime, which makes it the least troublesome styling option in the App Router.
- Utilities work in Server Components unchanged. There is no context, no provider, no
'use client'needed to render a styled element. - Source detection ignores the RSC boundary. Files are scanned as text; whether a component runs on the server or the client is irrelevant.
- Only interactivity forces
'use client'. A theme toggle or a disclosure button needs it; the styled markup around it does not. - CSS imports belong in the root layout, which is a Server Component. Do not add
'use client'to it just to import a stylesheet.
The one thing that does not change: class names must still exist at build time. Computing ` text-${row.status}-600 ` on the server is exactly as broken as computing it in the browser.
export default async function Invoices() {
const invoices = await getInvoices()
const STATUS = {
paid: 'bg-emerald-50 text-emerald-700 inset-ring-emerald-600/20',
overdue: 'bg-red-50 text-red-700 inset-ring-red-600/20',
draft: 'bg-gray-50 text-gray-600 inset-ring-gray-500/20',
}
return (
<ul className="divide-y divide-gray-100">
{invoices.map((invoice) => (
<li key={invoice.id} className="flex items-center justify-between py-4">
<span className="text-sm font-medium text-gray-900">{invoice.number}</span>
<span className={`rounded-md px-2 py-1 text-xs font-medium inset-ring ${STATUS[invoice.status]}`}>
{invoice.status}
</span>
</li>
))}
</ul>
)
}Dark mode#
v4 has no darkMode config option. Redefine the dark variant in CSS, then pick something to toggle the class.
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));With next-themes#
npm install next-themes'use client'
import { ThemeProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
)
}import './globals.css'
import { Providers } from './providers'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}Two details are not optional:
attribute="class"— the default isdata-theme, which will not match the@custom-variantabove. Either passattribute="class"or write the variant against the attribute:@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));suppressHydrationWarningon<html>—next-themesmutates that element before React hydrates, and without it React logs a mismatch on every load.
The toggle itself is a Client Component:
'use client'
import { useTheme } from 'next-themes'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="rounded-lg p-2 text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
Toggle theme
</button>
)
}Without a library — an inline head script#
A blocking script in <head> sets the class before first paint. This is what next-themes does internally, and it is about fifteen lines if you only need a toggle.
const themeScript = `
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
(!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches),
);
`
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta name="color-scheme" content="light dark" />
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body>{children}</body>
</html>
)
}Editor setup for helper functions#
Tailwind IntelliSense completes class names inside class and className attributes. It does not know that cn(...), clsx(...) or a VARIANTS object also contain classes. Teach it:
{
"editor.quickSuggestions": { "strings": "on" },
"tailwindCSS.classFunctions": ["cn", "clsx", "cva", "twMerge"],
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
],
"files.associations": { "*.css": "tailwindcss" }
}tailwindCSS.classFunctions is the documented setting and covers plain function calls and tagged template literals — start there. tailwindCSS.experimental.classRegex is the older, experimental escape hatch; it is worth keeping for nested structures like a cva config object, where a bare function name is not enough.
Neither affects the build. They only drive completion, hover previews and colour swatches — a class the extension happily autocompletes still produces no CSS if the file it lives in is outside source detection.
Common mistakes#
- Installing
@tailwindcss/vitein a Next.js app. Next.js has no Vite pipeline, so the plugin never runs and no CSS is generated. Use@tailwindcss/postcsswithpostcss.config.mjs. - Building class names by interpolation. `
bg-${color}-500andtext-${size}` compile to nothing. Map props to complete strings. - Omitting
inlineon@themewhen mappingnext/font.--font-sans: var(--font-inter)withoutinlineresolves at:root, where--font-interis undefined, and silently falls back. - Forgetting
import './globals.css'in the root layout. The PostCSS plugin compiles the file, but nothing links it. - Leaving
next-themeson its defaultattribute. The default isdata-theme; a@custom-variant darkwritten against.darkwill never match. - Relying on
clsxto resolve conflicting utilities. It concatenates; it does not de-duplicate.p-4 p-8needstailwind-merge. - Keeping
autoprefixerinpostcss.config.mjs. v4 prefixes internally, so the extra plugin is redundant.
Related topics#
- Installation — every install method side by side
- Vue & Nuxt — the same patterns in SFCs
- Theme & Design Tokens —
@theme inlineand@theme static - Dark Mode — the class strategy and the three-way toggle
- Project Setup — source detection and safelisting