Next.js with Tailwind CSS — Setup, Configuration, and Best Practices

Sanjeev SharmaSanjeev Sharma
6 min read

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 content in tailwind.config.js — Tailwind cannot purge unused classes
  • Using @apply for 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 components for 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 --watch in development for fastest rebuild times

Key Takeaways

  • Tailwind CSS v3 is configured via tailwind.config.js; Tailwind v4 uses CSS-native @theme blocks
  • The content array must include all file paths that use Tailwind classes — otherwise classes get purged
  • darkMode: 'class' enables manual dark mode toggle; 'media' follows OS preference
  • twMerge + clsx combined as cn() resolves conflicting Tailwind class precedence correctly
  • @layer base, @layer components, @layer utilities let 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading