Tailwind CSS Handbook v4.3

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#

ProjectPackageConfig file
Vite + React@tailwindcss/vitevite.config.ts
Next.js (App or Pages Router)@tailwindcss/postcsspostcss.config.mjs
React Router / Remix (Vite-based)@tailwindcss/vitevite.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#

  1. Install. Two packages, no config file.
  2. Register the plugin. Before or after the React plugin, order does not matter.
  3. Import Tailwind in your CSS.
  4. Import that CSS from your entry module.
BASH
npm install tailwindcss @tailwindcss/vite
vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),
  ],
})
src/index.css
@import "tailwindcss";
src/main.jsx
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.

BASH
npm install tailwindcss @tailwindcss/postcss postcss
postcss.config.mjs
const config = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
}

export default config
app/globals.css
@import "tailwindcss";

Where to import the stylesheet#

Import it once, from the root layout. Next.js hoists it into every route that layout wraps.

app/layout.tsx
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:

pages/_app.tsx
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.

  1. Load the font with a variable option.
  2. Put the returned .variable class on <html>.
  3. Map the variable into @theme inline.
app/layout.tsx
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>
  )
}
app/globals.css
@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:

Generated CSS
/* @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:

JSX
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:

JSX
// 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.

PackageProblem it solves
clsxBuilding a class string from conditions without undefined and double spaces
tailwind-mergeTwo conflicting utilities in one string — last one wins by specificity, not source order
BASH
npm install clsx tailwind-merge

clsx flattens conditionals, arrays and objects into one string:

JSX
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:

JSX
// 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:

lib/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
JSX
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.

components/Button.jsx
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}
    />
  )
}
Loading preview…

Two rules keep this scanner-friendly:

  • Every value in VARIANTS and SIZES is 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.

A styled Server Component — no directive needed
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.

app/globals.css
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

With next-themes#

BASH
npm install next-themes
app/providers.tsx
'use client'

import { ThemeProvider } from 'next-themes'

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
      {children}
    </ThemeProvider>
  )
}
app/layout.tsx
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 is data-theme, which will not match the @custom-variant above. Either pass attribute="class" or write the variant against the attribute: @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
  • suppressHydrationWarning on <html> — next-themes mutates that element before React hydrates, and without it React logs a mismatch on every load.

The toggle itself is a Client Component:

components/ThemeToggle.tsx
'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.

app/layout.tsx
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:

.vscode/settings.json
{
  "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/vite in a Next.js app. Next.js has no Vite pipeline, so the plugin never runs and no CSS is generated. Use @tailwindcss/postcss with postcss.config.mjs.
  • Building class names by interpolation. ` bg-${color}-500 and text-${size} ` compile to nothing. Map props to complete strings.
  • Omitting inline on @theme when mapping next/font. --font-sans: var(--font-inter) without inline resolves at :root, where --font-inter is 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-themes on its default attribute. The default is data-theme; a @custom-variant dark written against .dark will never match.
  • Relying on clsx to resolve conflicting utilities. It concatenates; it does not de-duplicate. p-4 p-8 needs tailwind-merge.
  • Keeping autoprefixer in postcss.config.mjs. v4 prefixes internally, so the extra plugin is redundant.