Switch Language
Toggle Theme

Next.js + Tailwind CSS Best Practices: A Complete Guide from Configuration to Dark Mode (2025 Edition)

Staring at the button component in VS Code, I counted the classNames—twenty-three of them. From bg-blue-500 to dark:hover:bg-blue-800, they were packed densely in one line with a horizontal scrollbar stretching far to the right. A colleague walked past my desk, glanced at the screen: “Dude, what are you even writing?”

To be honest, I didn’t know how to answer that moment. I’ve been using Tailwind for almost two years. Sure, it’s fast, but the code has become increasingly cryptic. Copy-paste feels great in the moment, but it’s a nightmare when you need to make changes—wanting to update button border radius across the board means searching for rounded-lg file by file, changing them one by one.

This pain isn’t unique to me. It’s 2025, Tailwind CSS has upgraded to v4, Next.js is at 15, and everything—configuration, dark mode, performance optimization—has changed. Honestly, I was confused at first too. The config file disappeared? Where did darkMode: 'class' go? After hitting a bunch of snags, I finally figured things out.

This article shares my practical experience. It’s not one of those “official docs repeater” pieces, but real methods tested in actual projects: how to stop className explosions, how to elegantly handle dark mode, how to reduce CSS bundles from 500KB to 50KB. If you’ve also been tortured by Tailwind’s long classNames, or you’re wondering whether to upgrade to v4, keep reading.

The 2025 Changes: Tailwind CSS v4 + Next.js 15

Let’s start with v4’s biggest change—the config file is gone.

Yep, that familiar tailwind.config.js has become optional in v4. When I first heard this, I thought it was some random person’s joke. Opened a new Next.js 15.3 project—truly gone. The Tailwind team calls this the “zero-config philosophy”: automatic project file scanning, works out of the box.

But that doesn’t mean you can’t customize. On the contrary, v4 moved customization to a more intuitive place—global.css. Now your theme colors, spacing, fonts are all defined in the CSS file using variables:

@theme {
  --color-primary: #3b82f6;
  --color-secondary: #8b5cf6;
  --font-sans: 'Inter', sans-serif;
}

At first I wasn’t used to it, thinking “isn’t this a step backward?” After using it for two days, I found it’s actually faster to modify. Before, changing a theme color meant restarting the dev server and waiting for compilation; now changing CSS variables, hot reload kicks in instantly. Plus, designers can understand CSS variables—no more asking me “which blue is blue-500?”

Another tangible improvement is speed. v4 was rewritten in Rust at its core, officially claiming 5x faster. I ran a test—cold start dropped from 8 seconds to under 2 seconds. This isn’t just benchmark gaming—starting the dev server a dozen times a day, that time adds up to two extra cups of coffee.

On the Next.js 15 side, App Router is now the standard config. Combined with Tailwind, server component style isolation works well, no need to worry about style pollution. The only thing to watch out for: remember to add 'use client' to client components, otherwise dark mode switching will have issues (we’ll get into that later).

Oh, and there’s a small change many people haven’t noticed: v4’s default border-color changed to currentColor. This means borders without specified colors will follow the text color. Sounds like nothing, but if you upgrade directly from v3, you might find a bunch of borders “disappeared”—they actually just became the same color as the text. I fell into this trap and spent ages debugging before figuring it out.

In summary, v4’s changes are significant, but the direction is right: faster, simpler, more intuitive. Once you get past the initial adaptation period, you’ll find there’s no going back.

Solving Long ClassNames: The Right Way to Encapsulate Components

Back to the opening question: what to do about buttons with twenty-plus classNames?

Many people’s first instinct is to use @apply. Stuff Tailwind classes into a CSS file, give it a name like .btn-primary, and it looks cleaner. I used to do this too, until I saw Tailwind creator Adam Wathan say on Twitter: “If you’re heavily using @apply, you might be missing the point of Tailwind’s design philosophy.”

It sounds harsh, but he’s right. @apply pre-compiles utility classes into the CSS file, and Tailwind’s on-demand generation advantage is lost. You think you’re “encapsulating”, but you’re actually manually inflating the CSS bundle—in one of my projects, overusing @apply caused production CSS to balloon from 30KB to 120KB.

So what’s the right approach? Component encapsulation.

Wrap commonly used style combinations into React components. The classNames stay however long they need to be, but you only write them once:

// ❌ Before: writing it everywhere
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg shadow-md transition duration-200">
  Submit
</button>

// ✅ Now: encapsulated as a component
<Button variant="primary">Submit</Button>

Inside the Button component, you still write it the same way, but everywhere else is clean. And it’s easier to modify—want to adjust button styles globally? Change one file and you’re done.

But that’s not enough. Buttons have different states: primary, secondary, danger actions… do we write a separate component for each? This is where cva (class-variance-authority) comes in.

This library is specifically for managing component variants and pairs beautifully with Tailwind:

import { cva, type VariantProps } from 'class-variance-authority'

const buttonStyles = cva(
  // Base styles
  'font-bold rounded-lg transition duration-200',
  {
    variants: {
      variant: {
        primary: 'bg-blue-500 hover:bg-blue-700 text-white',
        secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800',
        danger: 'bg-red-500 hover:bg-red-700 text-white'
      },
      size: {
        sm: 'py-1 px-3 text-sm',
        md: 'py-2 px-4',
        lg: 'py-3 px-6 text-lg'
      }
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md'
    }
  }
)

export function Button({
  variant,
  size,
  children,
  ...props
}: VariantProps<typeof buttonStyles> & React.ButtonHTMLAttributes<HTMLButtonElement>) {
  return (
    <button className={buttonStyles({ variant, size })} {...props}>
      {children}
    </button>
  )
}

Now it’s much nicer to use:

<Button variant="primary">Save</Button>
<Button variant="danger" size="lg">Delete</Button>
<Button variant="secondary" size="sm">Cancel</Button>

And TypeScript helps you check—typo a variant name and it errors immediately. This is how shadcn/ui does it, which is why their component library code looks so clean.

That said, @apply isn’t completely unusable. When styling third-party libraries, you really can’t encapsulate components, and using @apply to override is fine. But for your own components, encapsulate when you can—don’t be lazy.

Custom Theme Configuration: Building Your Design System

After encapsulating components, the next question arises: how do you ensure design consistency across the entire project?

I used to use five or six different blues in projects: blue-400, blue-500, #3B82F6, rgb(59, 130, 246)… Designers would shake their heads: “What spec is this even following?” Later I understood—you need a design system that locks down colors, fonts, spacing, everything.

V4 makes this super convenient. Remember @theme from earlier? Define it there:

/* app/globals.css */
@import 'tailwindcss';

@theme {
  /* Brand colors */
  --color-brand-primary: #3b82f6;
  --color-brand-secondary: #8b5cf6;

  /* Semantic colors */
  --color-success: #10b981;
  --color-warning: #f59e0b;
  --color-error: #ef4444;

  /* Neutral colors (light to dark) */
  --color-neutral-50: #f9fafb;
  --color-neutral-100: #f3f4f6;
  --color-neutral-500: #6b7280;
  --color-neutral-900: #111827;

  /* Font families */
  --font-sans: 'Inter', system-ui, sans-serif;
  --font-mono: 'Fira Code', monospace;

  /* Spacing (8px grid from design) */
  --spacing-unit: 0.5rem; /* 8px */

  /* Border radius */
  --radius-sm: 0.25rem;
  --radius-md: 0.5rem;
  --radius-lg: 1rem;
}

After defining, use directly in Tailwind classes:

<div className="bg-brand-primary text-neutral-50 rounded-md">
  Brand color background
</div>

Notice, I didn’t use bg-blue-500, but bg-brand-primary. This makes changes so much easier—want to change the brand color? Change one variable, site-wide effect. No need to grep for blue-500 and change it a hundred times.

If you still want to keep the v3 config file approach, you can create tailwind.config.ts:

import type { Config } from 'tailwindcss'

export default {
  content: [
    './app/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      // Extend default theme (recommended)
      colors: {
        brand: {
          primary: '#3b82f6',
          secondary: '#8b5cf6',
        },
      },
      fontFamily: {
        sans: ['Inter', 'sans-serif'],
      },
    },
  },
} satisfies Config

extend is crucial. If you directly write theme.colors, you’ll override all of Tailwind’s default colors—bg-red-500 and friends will stop working. extend is append, not replace.

Here’s another trick: combining CSS variables with Tailwind config enables runtime theme switching:

:root {
  --color-primary: #3b82f6;
}

[data-theme='purple'] {
  --color-primary: #8b5cf6;
}
// tailwind.config.ts
colors: {
  primary: 'var(--color-primary)',
}

This way, when switching themes, you don’t need to recompile CSS—just change a DOM attribute. This trick is especially useful in SaaS products, letting users pick their own theme colors.

After setting up a design system, team collaboration becomes smoother too. New team members arrive, glance at globals.css, and immediately know which colors to use. No more “I just picked this blue randomly” situations.

Dark Mode Implementation: The Flicker-Free Solution

Dark mode is where I fell the hardest.

The first time I tried, following a v3 tutorial, I wrote a toggle button. Click it—the page flashes white, then goes black. Users complained: “It’s blinding!” Later I learned this is called “flash of unstyled content” (FOUC), a problem with Next.js server-side rendering.

In v4, dark mode configuration changed. Remember v3’s darkMode: 'class'? Gone. Now it defaults to the class strategy, no configuration needed. But the flash problem remains—you need the next-themes library to solve it.

First, install it:

npm install next-themes

Then wrap a ThemeProvider in the root layout:

// app/layout.tsx
import { ThemeProvider } from 'next-themes'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

suppressHydrationWarning is crucial—don’t forget it. next-themes adds class="dark" to <html> on the client side, which doesn’t match server-rendered content. React will warn, but adding this attribute silences it.

Write the toggle button in a client component:

'use client'

import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'

export function ThemeToggle() {
  const [mounted, setMounted] = useState(false)
  const { theme, setTheme } = useTheme()

  useEffect(() => setMounted(true), [])

  if (!mounted) return null // Avoid server render mismatch

  return (
    <button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      className="rounded-lg p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800"
    >
      {theme === 'dark' ? '🌞' : '🌙'}
    </button>
  )
}

CSS variables also need to adapt for dark mode:

@theme {
  --color-bg-primary: #ffffff;
  --color-text-primary: #111827;
}

.dark {
  --color-bg-primary: #111827;
  --color-text-primary: #f9fafb;
}

Or use the dark: prefix directly in Tailwind classes:

<div className="bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-50">
  Adaptive dark mode
</div>

Speaking of color design, dark mode isn’t just inverting black and white. Pure black (#000000) is too harsh—use dark gray (#111827 or #1a1a1a); pure white text is also too bright—tone it down to #f9fafb for comfort. Another gotcha: shadows need to be inverted in dark mode, otherwise you lose depth:

// Light mode: downward shadow
<div className="shadow-lg dark:shadow-none dark:ring-1 dark:ring-neutral-800">

In dark mode, use ring (border) instead of shadows—works better.

Also, images can get overexposed in dark mode. Add a filter to reduce brightness:

.dark img {
  filter: brightness(0.9);
}

After nailing these details, dark mode becomes truly usable. It’s not just about implementing the feature—it’s about making it comfortable for users.

Performance Optimization: Making Your CSS Small and Fast

The CSS bundle reduction from 500KB to 50KB mentioned at the start? Not bragging—actually did it.

V4’s JIT mode is enabled by default, generating styles on-demand, which is already fast. But there’s still room for optimization, and the key is in content configuration.

Many people write it like this:

// ❌ Scan range too broad
content: [
  './**/*.{js,ts,jsx,tsx}',
]

This scans the entire project, including node_modules, .next build artifacts—wasting time. Be precise:

// ✅ Only scan necessary directories
content: [
  './app/**/*.{js,ts,jsx,tsx,mdx}',
  './components/**/*.{js,ts,jsx,tsx}',
  './lib/**/*.{js,ts}',
]

I tested this before—after this change, dev server startup sped up 40%.

Another common issue: dynamic classNames. Like writing this:

// ❌ These classes will get purged
const colors = ['red', 'blue', 'green']
<div className={`bg-${colors[0]}-500`}>

Tailwind can’t scan the complete bg-red-500, so production builds delete it, leaving pages unstyled. Either write full classNames:

// ✅ Write full classNames
const colorMap = {
  red: 'bg-red-500',
  blue: 'bg-blue-500',
  green: 'bg-green-500',
}
<div className={colorMap[color]}>

Or use safelist to force retention:

// tailwind.config.ts
safelist: [
  {
    pattern: /bg-(red|blue|green)-500/,
  },
]

But don’t abuse safelist—adding too many makes CSS big again. Write full classNames in code when possible; only use safelist as a last resort.

V4 has built-in CSS minification, automatically enabled in production builds. If you want further optimization, configure cssnano:

// postcss.config.js
module.exports = {
  plugins: {
    tailwindcss: {},
    ...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {}),
  },
}

An easily overlooked point: monitor bundle size. I use @next/bundle-analyzer for regular checks:

npm install --save-dev @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({
  // Your other config
})

Then run ANALYZE=true npm run build to generate a visual report—see which packages are too large at a glance.

Netflix’s case is interesting: their Top 10 page has only 6.5KB of CSS. How’d they do it? Trimmed to the extreme, keeping only styles actually used. We don’t necessarily need to go that extreme, but the mindset is worth learning: don’t throw everything into the project—good enough is enough.

Once during code review, I found a colleague imported the entire @heroicons/react, but only used two icons. Changed to on-demand import—package size dropped by 200KB immediately. Small thing, but adds up to a lot.

Performance optimization isn’t a one-time job—it’s an ongoing process. Run bundle analysis before each release, make it a habit, and CSS won’t spiral out of control.

Migrating from v3 to v4: A Smooth Upgrade Guide

If you’re still on v3, is now a good time to upgrade? Depends.

New projects, go straight to v4—no debate needed. For old projects, evaluate the modification cost. V4 has some breaking changes—it’s not just npm install and done.

The biggest change is the config file. V3’s tailwind.config.js configs need to move to global.css:

/* Before in tailwind.config.js */
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: '#3b82f6',
      },
    },
  },
}

/* Now in globals.css */
@theme {
  --color-primary: #3b82f6;
}

Custom utility classes also changed. Before you used @layer utilities, now use @utility:

/* v3 */
@layer utilities {
  .text-balance {
    text-wrap: balance;
  }
}

/* v4 */
@utility text-balance {
  text-wrap: balance;
}

There’s a sneaky gotcha: component classNames no longer support variants. What does that mean? Before you could do this:

/* v3 worked */
@layer components {
  .btn {
    @apply px-4 py-2 rounded;
  }
}

/* Then use hover:btn, dark:btn variants */

In v4, nope—hover:btn throws an error. You’ll need to change it to a utility or encapsulate a React component.

Then there’s the border color gotcha. As mentioned earlier, v4 defaults border-color: currentColor, and many borders will “disappear”. Solution: globally search for border, add border-neutral-300 to those without colors:

// v3: border automatically gray
<div className="border"></div>

// v4: border follows text color, needs explicit specification
<div className="border border-neutral-300"></div>

I suggest migrating in steps:

  1. Step 1: Install v4, run dev environment once, check for obvious style issues
  2. Step 2: Globally search @layer, change to @utility or @theme
  3. Step 3: Search border, add colors where missing
  4. Step 4: Move config file theme to CSS, test gradually
  5. Step 5: If dynamic classNames got purged, use safelist to fix

The whole process takes half a day to a day, depending on project size. Don’t rush to change everything at once—do it in batches, easier to roll back if issues arise.

Oh, and if your project uses shadcn/ui or other component libraries, first check if they support v4. I fell into this trap before—the component library hadn’t adapted yet, but I upgraded first, and a bunch of component styles went haywire.

V4 is overall a good thing, but it’s not mandatory to upgrade. If v3 works fine, no rush to tinker. Technical debt always needs to be repaid, but you can pick the right moment.

Conclusion

From that 23-className button at the start to now’s <Button variant="primary">, this path took me almost two years.

The Tailwind and Next.js combo is indeed powerful, but it’s not something you can just pick up and use well. V4’s changes look scary, but they’re actually moving in the right direction: simpler configuration, better performance, smoother development experience.

The techniques this article covers—component encapsulation, theme configuration, dark mode, performance optimization—you don’t have to use them all. Pick a few that solve your current pain points and try them out. Don’t think about refactoring the entire project all at once—that’s exhausting and error-prone.

My suggestion: start with component encapsulation. Spend an afternoon encapsulating the most commonly used components like Button, Card, Input, paired with cva for managing variants. This change is low-risk, high-reward, immediate impact. Then consider dark mode and theme customization.

As for whether to upgrade to v4, don’t rush. See if the community ecosystem is mature, if your component libraries support it, if you have time to fiddle. Technology isn’t about newer being better—it’s about more suitable being better.

If you’ve encountered similar issues using Tailwind, or have better practices, feel free to comment and discuss. Maybe your methods are more elegant than mine.

Lastly, I’ve put all the code examples mentioned in this article on GitHub (link at the end). Feel free to use them. If you have questions, see you in Issues.

Don’t just read—try it hands-on. Code never truly learned until it runs.

FAQ

What changed in Tailwind CSS v4?
Major changes:
• Config file (tailwind.config.js) is now optional
• Customization moved to global.css using @theme
• Automatic file scanning, zero-config philosophy
• Better performance and smaller bundle size

The old config file approach still works, but v4 encourages CSS-based configuration.
How do I stop className explosions?
Use component encapsulation:
• Create reusable components (Button, Card, Input)
• Use cva (class-variance-authority) for variants
• Define styles in components, not inline

Example:
const button = cva(['base-styles'], {
variants: {
size: { sm: 'text-sm', lg: 'text-lg' }
}
})

This reduces long className strings significantly.
How do I configure dark mode in v4?
Two methods:
1) CSS-based: Use @media (prefers-color-scheme) in global.css
2) Class-based: Use next-themes for manual switching

v4 doesn't require darkMode: 'class' config anymore.
How do I reduce Tailwind bundle size?
Methods:
• Use JIT mode (default in v4)
• Properly configure content paths
• Use @layer for organization
• Remove unused styles with purge

With proper configuration, can reduce from 500KB to 50KB.
Should I upgrade to Tailwind v4?
Consider:
• Is your project stable and working well?
• Do you have time to migrate?
• Are your component libraries compatible?

Don't rush. v4 is still evolving, wait for ecosystem maturity if your current setup works.
How do I customize themes in v4?
Use @theme in global.css:

Example:
@theme {
--color-primary: #3b82f6;
--spacing-4: 1rem;
}

Then use in components: bg-primary, p-4, etc.

This replaces the old config file approach.
What's the best practice for organizing Tailwind classes?
Best practices:
• Use component encapsulation
• Group related classes
• Use variants instead of conditional classes
• Keep components small and focused

Avoid:
• Long inline className strings
• Duplicate styles across components
• Mixing utility classes with custom CSS

12 min read · Published on: Dec 20, 2025 · Modified on: Jan 15, 2026

Comments

Sign in with GitHub to leave a comment

Related Posts