50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 26, 2026·8 min read

Dark Mode in Next.js with next-themes: The Clean Approach

Stop fighting flash of wrong theme and hydration mismatches. Here's how to wire up next-themes correctly in the App Router.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Dark Mode in Next.js with next-themes: The Clean Approach

Dark mode sounds like a weekend feature. It takes about twenty minutes to implement and approximately three months to stop being annoyed by the bugs you introduced doing it quickly. We've been there — the flash of white before the dark theme kicks in, the hydration errors, the system preference that gets ignored, the toggle that resets on every page load. All of it, we've broken all of it.

The good news: `next-themes` solves 90% of this cleanly. The bad news: the App Router changed enough of Next.js's internals that most tutorials you'll find are either outdated, incomplete, or skip the parts that actually bite you in production. This post is the setup we actually use.

Why Dark Mode Is Harder Than It Looks

The core problem is that the server doesn't know what theme the user wants. Your server renders HTML, sends it to the browser, and React hydrates it. But the user's theme preference lives in localStorage (or a cookie), which the server can't access during that initial render. So you get a mismatch — the server says 'light mode', the browser says 'dark mode', and React screams about hydration errors.

There's also the Flash of Incorrect Theme (FOIT, sort of — the flash of white before dark kicks in). Even if you avoid the hydration error, users on dark mode get blinded by a white flash for a fraction of a second. It feels unpolished. Users notice even if they can't name what bothered them.

`next-themes` handles both problems with a clever trick: it injects a tiny script into the `<head>` that reads the stored preference before the page renders. No flash, no mismatch. Let's set it up properly.

Installation and Basic Setup

First, install the package:

pnpm add next-themes
# or npm install next-themes

Now, the App Router wrinkle: `ThemeProvider` uses React context, which means it needs to be a Client Component. But your root layout is a Server Component. You need a thin wrapper.

// components/theme-provider.tsx
'use client'

import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes'

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

Then wrap your root layout with it:

// app/layout.tsx
import { ThemeProvider } from '@/components/theme-provider'

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

Notice `suppressHydrationWarning` on the `<html>` element. This is important — `next-themes` modifies the `class` attribute on `<html>` before hydration, which would normally trigger a React warning. This prop tells React to chill about that specific element's attribute mismatches.

Don't put `suppressHydrationWarning` on `<body>` or anywhere else — just `<html>`. It suppresses warnings only for that element, not its children.

Wiring Up Tailwind

If you're using Tailwind (and you probably are), you need to tell it to use the class-based dark mode strategy instead of the default media query approach. The `attribute="class"` prop in `ThemeProvider` adds a `dark` class to `<html>` — Tailwind needs to know to look for that.

// tailwind.config.ts
import type { Config } from 'tailwindcss'

const config: Config = {
  darkMode: 'class', // This is the key line
  content: [
    './pages/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
    './app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

export default config

Now `dark:bg-slate-900` and all the other dark variants will work. When `next-themes` toggles the theme, it adds or removes the `dark` class on `<html>`, and Tailwind's CSS is already there waiting for it.

Building the Theme Toggle

Here's where a lot of tutorials trip you up. The `useTheme` hook can only run on the client, and during the initial render, the `theme` value might be `undefined` because it hasn't read from localStorage yet. If you render a toggle that shows 'light' icon on first paint and then switches to 'dark', you get a flicker.

The fix is to track whether the component has mounted:

// components/theme-toggle.tsx
'use client'

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

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

  // Only render the toggle after mounting to avoid hydration mismatch
  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) {
    // Render a placeholder with the same dimensions to avoid layout shift
    return <div className="w-9 h-9" />
  }

  return (
    <button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      className="rounded-md p-2 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
      aria-label="Toggle theme"
    >
      {theme === 'dark' ? (
        // Sun icon for dark mode (clicking switches to light)
        <svg
          className="w-5 h-5"
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
          />
        </svg>
      ) : (
        // Moon icon for light mode (clicking switches to dark)
        <svg
          className="w-5 h-5"
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
          />
        </svg>
      )}
    </button>
  )
}

The empty `div` placeholder during the unmounted state keeps your layout stable. Without it, the toggle area collapses and then pops in, which causes a layout shift. Not a huge deal, but it's the kind of thing that makes a polished product feel less polished.

System Preference and the Three-Way Toggle

The `defaultTheme="system"` and `enableSystem` props we set earlier mean the app respects the OS preference on first load. But if a user has explicitly chosen a theme, that choice should persist and override the system preference. `next-themes` handles this automatically — it stores the explicit choice in localStorage and uses it instead of the system preference.

Some apps want a three-way toggle: Light → Dark → System. That's easy to implement:

// components/theme-select.tsx
'use client'

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

const themes = ['light', 'dark', 'system'] as const

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

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

  if (!mounted) return null

  return (
    <div className="flex gap-1 rounded-lg border border-slate-200 dark:border-slate-700 p-1">
      {themes.map((t) => (
        <button
          key={t}
          onClick={() => setTheme(t)}
          className={`
            px-3 py-1 rounded-md text-sm capitalize transition-colors
            ${
              theme === t
                ? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
                : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100'
            }
          `}
        >
          {t}
        </button>
      ))}
    </div>
  )
}

The disableTransitionOnChange Gotcha

We set `disableTransitionOnChange` in the ThemeProvider, and you probably noticed it. Here's the deal: if you have CSS transitions on your background colors (like `transition-colors duration-200`), switching themes causes every element on the page to animate from its old color to its new color simultaneously. It looks like the entire page is flashing and morphing. It's gross.

`disableTransitionOnChange` temporarily disables all CSS transitions for the duration of the theme switch, so the change is instant. After the switch is done, transitions are re-enabled. Your hover effects and other micro-interactions all still work normally.

If you want a smooth theme transition for stylistic reasons, you can skip this prop. But test it thoroughly — on pages with lots of colored elements it often looks worse than an instant switch.

CSS Variables: The Cleaner Way to Handle Colors

For anything beyond a simple light/dark flip, CSS custom properties are your friend. Instead of scattering `dark:` variants everywhere, you can define semantic tokens that change based on theme:

/* app/globals.css */
:root {
  --background: 255 255 255;
  --foreground: 15 23 42;
  --card: 248 250 252;
  --card-foreground: 15 23 42;
  --border: 226 232 240;
  --muted: 241 245 249;
  --muted-foreground: 100 116 139;
  --primary: 99 102 241;
  --primary-foreground: 255 255 255;
}

.dark {
  --background: 2 6 23;
  --foreground: 248 250 252;
  --card: 15 23 42;
  --card-foreground: 248 250 252;
  --border: 30 41 59;
  --muted: 30 41 59;
  --muted-foreground: 148 163 184;
  --primary: 129 140 248;
  --primary-foreground: 15 23 42;
}

Then in your Tailwind config, reference these variables:

// tailwind.config.ts
const config: Config = {
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        background: 'rgb(var(--background) / <alpha-value>)',
        foreground: 'rgb(var(--foreground) / <alpha-value>)',
        card: 'rgb(var(--card) / <alpha-value>)',
        'card-foreground': 'rgb(var(--card-foreground) / <alpha-value>)',
        border: 'rgb(var(--border) / <alpha-value>)',
        muted: 'rgb(var(--muted) / <alpha-value>)',
        'muted-foreground': 'rgb(var(--muted-foreground) / <alpha-value>)',
        primary: 'rgb(var(--primary) / <alpha-value>)',
        'primary-foreground': 'rgb(var(--primary-foreground) / <alpha-value>)',
      },
    },
  },
}

Now you can write `bg-background text-foreground` and it just works in both themes. No `dark:` variants needed for your base colors. You only use `dark:` for exceptions and edge cases. This is the pattern we use in all our peal.dev templates — it makes theming a lot less tedious to maintain when you're building components that need to look good in both modes.

Common Mistakes and How to Avoid Them

  • Forgetting `suppressHydrationWarning` on `<html>` — you'll get console warnings about attribute mismatches on every page load
  • Using `useTheme` without the mounted check — your toggle will flicker on first render, which looks amateur
  • Putting ThemeProvider inside `<body>` instead of wrapping it — the script injection for flash prevention needs to be in `<head>`, which next-themes handles automatically when placed correctly
  • Using `darkMode: 'media'` in Tailwind when `attribute='class'` is set in next-themes — they'll fight each other; always use `darkMode: 'class'` in Tailwind with next-themes
  • Not testing system preference changes — open DevTools, go to Rendering, change 'Emulate CSS prefers-color-scheme' and make sure your app responds correctly
  • Hardcoding colors with `text-gray-900` instead of semantic tokens — you'll end up hunting down dark mode overrides across your entire codebase

Testing That It Actually Works

Don't just eyeball it. Do this checklist before shipping:

  • Open the site fresh (no localStorage). Does it match your OS preference? It should.
  • Toggle to dark. Refresh. Does it stay dark? (LocalStorage persistence check.)
  • Open a new tab. Does it match the toggled preference or go back to system? (next-themes syncs across tabs by default.)
  • In DevTools, throttle your CPU heavily. Does the theme flash on load? If yes, your ThemeProvider placement is wrong.
  • In DevTools console, look for hydration warnings. There should be none.
  • Check your page in both themes on mobile — sometimes mobile Safari renders colors differently.

The CPU throttle test is underrated. On a fast dev machine everything looks instant. On an older phone or budget laptop, you'll see any flash that exists. Test it before your users do.

Dark mode is one of those things where 'it works' and 'it works well' are very different bars. The flash of wrong theme and the hydration errors are what separate the two. Get both right.

The full setup takes maybe an hour if you're doing it from scratch — provider wrapper, Tailwind config, CSS variables, the toggle component with the mounted check. It's not glamorous work, but once it's done it's done. Everything after that is just writing `dark:` variants or leaning on your semantic color tokens, and that part is actually kind of satisfying.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders