Next.js with Tailwind CSS — Setup, Configuration, and Best Practices
Advertisement
Introduction
Tailwind CSS is the most popular styling solution for Next.js applications. It ships zero CSS by default — only the classes you use are included in the production bundle. With Next.js's built-in PostCSS support, Tailwind integrates seamlessly and produces optimized stylesheets. Tailwind v4, released in 2025, brings CSS-native configuration and dramatically faster build times.
Why This Matters
Traditional CSS approaches in large React apps suffer from specificity conflicts, naming collisions, and dead code accumulation. Tailwind's utility-first approach eliminates these problems: every class does one thing, there are no cascading conflicts, and unused classes are automatically removed at build time.
Tailwind also enforces design consistency. Your text-gray-500, rounded-xl, and shadow-md classes always map to the same design token values — no one-off hex codes or magic pixel values scattered across files.
Installation in Next.js (v3)
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
}/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;Tailwind v4 Setup (CSS-Native)
Tailwind v4 removes tailwind.config.js in favor of CSS-native configuration:
npm install tailwindcss@next @tailwindcss/vite/* app/globals.css */
@import "tailwindcss";
@theme {
--color-brand: oklch(0.65 0.2 250);
--font-sans: 'Inter', sans-serif;
--radius-xl: 1rem;
}No JavaScript configuration file needed. All customization lives in CSS.
Custom Design Tokens
Extend Tailwind's theme with your brand colors and spacing:
// tailwind.config.js (v3)
module.exports = {
theme: {
extend: {
colors: {
brand: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
900: '#1e3a8a',
},
surface: {
DEFAULT: '#ffffff',
secondary: '#f8fafc',
tertiary: '#f1f5f9',
},
},
fontFamily: {
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
mono: ['var(--font-mono)', 'Menlo', 'monospace'],
},
borderRadius: {
'4xl': '2rem',
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-up': 'slideUp 0.4s ease-out',
},
keyframes: {
fadeIn: { from: { opacity: '0' }, to: { opacity: '1' } },
slideUp: { from: { transform: 'translateY(16px)', opacity: '0' }, to: { transform: 'translateY(0)', opacity: '1' } },
},
},
},
}Dark Mode
Configure class-based dark mode for user-controlled theme switching:
// tailwind.config.js
module.exports = {
darkMode: 'class', // or 'media' for OS preference
theme: { extend: {} },
}// components/ThemeToggle.tsx
'use client'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const [dark, setDark] = useState(false)
useEffect(() => {
setDark(document.documentElement.classList.contains('dark'))
}, [])
function toggle() {
const isDark = !dark
setDark(isDark)
document.documentElement.classList.toggle('dark', isDark)
localStorage.setItem('theme', isDark ? 'dark' : 'light')
}
return (
<button
onClick={toggle}
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
>
{dark ? 'Light' : 'Dark'}
</button>
)
}Use dark mode classes throughout:
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<p class="text-gray-600 dark:text-gray-400">Content</p>
</div>Component Class Patterns with cn()
Use clsx + tailwind-merge for conditional and merged Tailwind classes:
npm install clsx tailwind-merge// lib/utils.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}// components/Button.tsx
import { cn } from '@/lib/utils'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
}
export function Button({
variant = 'primary',
size = 'md',
className,
...props
}: ButtonProps) {
return (
<button
className={cn(
'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2',
{
'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500': variant === 'primary',
'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-300': variant === 'secondary',
'text-gray-700 hover:bg-gray-100 focus:ring-gray-300': variant === 'ghost',
'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500': variant === 'danger',
},
{
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-sm': size === 'md',
'px-6 py-3 text-base': size === 'lg',
},
className
)}
{...props}
/>
)
}Custom Utilities with @layer
Add custom utility classes without conflicting with Tailwind:
/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@layer components {
.card {
@apply bg-white rounded-xl border border-gray-200 shadow-sm p-6;
}
.input {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}Responsive Design
Tailwind uses mobile-first breakpoints:
<!-- Mobile: full width, MD: half, LG: one-third -->
<div class="w-full md:w-1/2 lg:w-1/3">
<!-- Mobile: stacked, MD: side by side -->
<div class="flex flex-col md:flex-row gap-6">
<!-- Hidden on mobile, visible on desktop -->
<nav class="hidden lg:flex items-center gap-4">Breakpoints: sm (640px), md (768px), lg (1024px), xl (1280px), 2xl (1536px).
Common Mistakes
- Not configuring
contentintailwind.config.js— Tailwind cannot purge unused classes - Using
@applyfor complex components instead of a React component — defeats co-location benefits - Applying conflicting Tailwind classes without
twMerge— last one does not always win - Hardcoding colors with arbitrary values
text-[#3b82f6]instead of design tokens - Not using
dark:prefix consistently — some elements styled for dark, others not
Best Practices
- Use the
cn()helper for all conditional class strings — prevents merge conflicts - Define all brand colors as design tokens in
tailwind.config.js— no arbitrary hex values - Use
@layer componentsfor repeating patterns (card, input, badge) to keep JSX clean - Set
darkMode: 'class'and test every component in both light and dark modes - Run
npx tailwindcss --watchin development for fastest rebuild times
Key Takeaways
- Tailwind CSS v3 is configured via
tailwind.config.js; Tailwind v4 uses CSS-native@themeblocks - The
contentarray must include all file paths that use Tailwind classes — otherwise classes get purged darkMode: 'class'enables manual dark mode toggle;'media'follows OS preferencetwMerge+clsxcombined ascn()resolves conflicting Tailwind class precedence correctly@layer base,@layer components,@layer utilitieslet you add custom CSS without specificity conflicts- Responsive classes use mobile-first prefixes:
sm:,md:,lg:,xl:,2xl: - Arbitrary values like
w-[357px]are supported but should be avoided in favor of design tokens - Tailwind generates 0 CSS at build time for classes not found in
content— bundle size stays minimal
Advertisement