Tailwind CSS with React and Next.js
Best practices for using Tailwind CSS in React and Next.js projects: component patterns, conditional classes, server components, and common pitfalls.
What you'll learn
- ✓How to set up Tailwind in a Next.js App Router project
- ✓Patterns for conditional and dynamic class names in React
- ✓How to build reusable Tailwind-powered React components
- ✓How Tailwind works with server and client components
- ✓Common mistakes and how to avoid them
Prerequisites
- •Familiarity with React components and JSX
- •Basic Next.js knowledge (App Router preferred)
- •Understanding of Tailwind utility classes
React and Next.js are the most popular environments for Tailwind CSS. The utility-first approach maps naturally to component-based architecture: styles stay co-located with markup, refactoring is safe, and the CSS bundle stays small. But there are patterns and pitfalls specific to React that you need to know.
Setup in Next.js
Next.js has first-class Tailwind support. When you create a new project with create-next-app, selecting Tailwind sets everything up automatically. For existing projects:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Configure the content paths:
// tailwind.config.js
export default {
content: [
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx}',
'./src/lib/**/*.{js,ts,jsx,tsx}',
],
theme: { extend: {} },
plugins: [],
};
Import Tailwind in your global CSS:
/* src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
Import the global CSS in your root layout:
// src/app/layout.tsx
import './globals.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-white text-gray-900 antialiased">
{children}
</body>
</html>
);
}
Conditional Classes with clsx
In React, you frequently need to apply classes based on props or state. String concatenation gets messy fast. Use clsx (or classnames):
npm install clsx
import clsx from 'clsx';
function Button({ variant = 'primary', size = 'md', disabled, children }) {
return (
<button
disabled={disabled}
className={clsx(
'inline-flex items-center justify-center font-semibold rounded-md transition-colors',
// Size variants
{
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
},
// Color variants
{
'bg-blue-600 text-white hover:bg-blue-700': variant === 'primary',
'bg-gray-100 text-gray-800 hover:bg-gray-200': variant === 'secondary',
'bg-red-600 text-white hover:bg-red-700': variant === 'destructive',
},
// Disabled state
disabled && 'opacity-50 cursor-not-allowed',
)}
>
{children}
</button>
);
}
This keeps all class logic in one readable block. Each class string is complete, so the Tailwind scanner finds them all.
The tailwind-merge Library
When composing components, you sometimes need to override a parent’s classes. Plain clsx does not handle conflicts — both px-4 and px-6 end up in the class list, and the one that wins depends on CSS source order, not the order in your className string.
tailwind-merge solves this:
npm install tailwind-merge
import { twMerge } from 'tailwind-merge';
import clsx from 'clsx';
// Utility function used across your project
export function cn(...inputs: (string | undefined | null | false)[]) {
return twMerge(clsx(inputs));
}
// In a component
function Card({ className, children }) {
return (
<div className={cn('rounded-lg border bg-white p-6 shadow-sm', className)}>
{children}
</div>
);
}
// Usage: the consumer's px-8 overrides the default p-6
<Card className="px-8 bg-gray-50">Custom card</Card>
// Result: "rounded-lg border bg-gray-50 px-8 py-6 shadow-sm"
tailwind-merge understands which classes conflict and resolves them correctly. p-6 gets split, the px part is overridden by px-8, and py-6 remains.
Component Patterns
Variant Pattern with Props
The most common pattern: a component with variant and size props.
import { cn } from '@/lib/utils';
const badgeVariants = {
default: 'bg-blue-100 text-blue-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
error: 'bg-red-100 text-red-800',
};
function Badge({ variant = 'default', className, children }) {
return (
<span className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
badgeVariants[variant],
className,
)}>
{children}
</span>
);
}
Compound Components
For complex UI elements with multiple parts:
function Card({ className, children }) {
return (
<div className={cn('rounded-xl border bg-white shadow-sm', className)}>
{children}
</div>
);
}
function CardHeader({ className, children }) {
return (
<div className={cn('border-b px-6 py-4', className)}>
{children}
</div>
);
}
function CardBody({ className, children }) {
return (
<div className={cn('px-6 py-4', className)}>
{children}
</div>
);
}
function CardFooter({ className, children }) {
return (
<div className={cn('border-t px-6 py-4 flex justify-end gap-2', className)}>
{children}
</div>
);
}
// Usage
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">Settings</h2>
</CardHeader>
<CardBody>
<p>Update your preferences below.</p>
</CardBody>
<CardFooter>
<Button variant="secondary">Cancel</Button>
<Button variant="primary">Save</Button>
</CardFooter>
</Card>
Class Variance Authority (CVA)
For components with many variants, CVA provides a structured API:
npm install class-variance-authority
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2',
{
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
ghost: 'hover:bg-gray-100 focus:ring-gray-500',
destructive: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
function Button({ variant, size, className, ...props }: ButtonProps) {
return (
<button className={cn(buttonVariants({ variant, size }), className)} {...props} />
);
}
Server Components and Client Components
Tailwind works identically in both. Classes are just strings — there is no JavaScript runtime involved in applying them. A server component renders the HTML with class attributes, and the browser applies the pre-built CSS.
// This is a server component (default in Next.js App Router)
// Tailwind works without "use client"
export default function ProductCard({ product }) {
return (
<div className="rounded-lg border p-4 hover:shadow-md transition-shadow">
<img
src={product.image}
alt={product.name}
className="w-full h-48 object-cover rounded-md"
/>
<h3 className="mt-3 font-semibold text-gray-900">{product.name}</h3>
<p className="text-gray-500 text-sm mt-1">${product.price}</p>
</div>
);
}
Interactive states like hover: and focus: work because they compile to CSS pseudo-selectors, not JavaScript event handlers. The only reason to use "use client" is if the component needs React state or browser APIs — Tailwind has nothing to do with that decision.
Dark Mode in Next.js
Use the class strategy with a provider:
// src/components/ThemeProvider.tsx
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext({ theme: 'light', toggle: () => {} });
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
useEffect(() => {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initial = saved || (prefersDark ? 'dark' : 'light');
setTheme(initial);
document.documentElement.classList.toggle('dark', initial === 'dark');
}, []);
const toggle = () => {
const next = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
localStorage.setItem('theme', next);
document.documentElement.classList.toggle('dark', next === 'dark');
};
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
To prevent the flash of wrong theme, add an inline script in your root layout’s <head>:
// src/app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{
__html: `
(function() {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
`,
}} />
</head>
<body className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
Common Pitfalls
Dynamic class construction
// BROKEN: Tailwind cannot find the class
<div className={`bg-${color}-500`}>...</div>
// WORKS: Complete strings
const bgColors = {
red: 'bg-red-500',
blue: 'bg-blue-500',
green: 'bg-green-500',
};
<div className={bgColors[color]}>...</div>
Forgetting content paths
If you add a new directory (like src/features/), add it to the content array or classes in those files will be purged.
Overusing inline styles
React makes it easy to use the style prop for everything. Prefer Tailwind utilities. Use inline styles only for truly dynamic values that cannot be expressed as utility classes.
Not using the cn() utility
Without tailwind-merge, class conflicts lead to unpredictable styling. Establish the cn() helper early and use it everywhere components accept a className prop.
Importing CSS in the wrong order
If you import component CSS after globals.css, the component styles may override Tailwind utilities. Keep globals.css (with the Tailwind directives) as the first import.
Performance Tips
- No runtime cost. Tailwind classes are resolved at build time. There is no CSS-in-JS runtime penalty.
- Single CSS file. Tailwind generates one stylesheet. No per-component CSS injection or style tag overhead.
- Tree-shaking is automatic. The JIT engine only generates CSS for classes found in your content files.
- Avoid style prop for static values. Using
className="mt-4"is faster than inline styles because the browser can cache and reuse the CSS rule.
Wrap-Up
Tailwind in React and Next.js is straightforward: set up the content paths, use clsx and tailwind-merge for conditional classes, build variant-driven components with props, and let the cn() utility handle class conflicts. Tailwind works identically in server and client components because it compiles to plain CSS. Avoid dynamic class construction, establish component patterns early, and use CVA for complex variant matrices.
Related articles
- Tailwind Tailwind Arbitrary Values and the JIT Engine
How Tailwind's JIT engine generates classes on demand, when arbitrary values are the right tool, and how to keep your design system tidy.
- Tailwind Tailwind Dark Mode Strategies: Class, Media, and CSS Variables
Compare the class-based, media-based, and variable-driven approaches to dark mode in Tailwind, with code and the trade-offs of each.
- Tailwind Tailwind Design System Patterns That Scale
Build a design system on top of Tailwind that stays consistent as the app grows. Tokens, components, variants, and the cva pattern explained.
- Tailwind Tailwind vs Bootstrap: A Practical Comparison
A pragmatic comparison of Tailwind CSS and Bootstrap covering philosophy, bundle size, customization, and the right use cases for each framework.