How to set up dark mode in a Tailwind + Next.js app

Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: Class-based dark mode with system-preference detection and a toggle. ~30 minutes.


Goal

A working dark mode that:

  • Respects the user’s system preference on first load
  • Lets the user override with a toggle
  • Persists the choice across page reloads
  • Doesn’t flash light mode for a moment when dark is selected (the dreaded FOUC — flash of unstyled content)

Prerequisites

  • Next.js app with Tailwind CSS
  • Tailwind CSS v3 or later (this guide uses v3+ conventions)

Steps

1. Configure Tailwind to use class-based dark mode

In tailwind.config.ts:

import type { Config } from "tailwindcss";
 
const config: Config = {
  darkMode: "class",  // ← critical
  content: [
    "./app/**/*.{ts,tsx}",
    "./components/**/*.{ts,tsx}",
  ],
  theme: {
    extend: {}
  },
  plugins: [],
};
 
export default config;

"class" mode means: dark styles activate when the <html> element (or any ancestor) has the dark class. This is the most flexible option — your code controls when dark is on.

The alternative "media" only follows system preference and gives no user toggle. Stick with "class".

2. Install next-themes (the modern default)

npm install next-themes

next-themes handles all the tricky bits: detecting system preference, persisting the choice in localStorage, syncing across tabs, avoiding the FOUC.

3. Wrap your app in the theme provider

Create app/providers.tsx:

"use client";
 
import { ThemeProvider } from "next-themes";
 
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider
      attribute="class"
      defaultTheme="system"
      enableSystem
      disableTransitionOnChange
    >
      {children}
    </ThemeProvider>
  );
}

Then in app/layout.tsx:

import { Providers } from "./providers";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

The suppressHydrationWarning on <html> is necessary because the theme provider modifies the class on the client immediately, which would otherwise cause a hydration mismatch warning. This is documented and expected.

4. Build the toggle

Create components/ThemeToggle.tsx:

"use client";
 
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
 
export function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);
 
  // Avoid hydration mismatch by rendering nothing until mounted
  useEffect(() => setMounted(true), []);
  if (!mounted) return <button aria-label="Toggle theme" />;
 
  const isDark = theme === "dark";
  return (
    <button
      onClick={() => setTheme(isDark ? "light" : "dark")}
      aria-label="Toggle theme"
    >
      {isDark ? "☀️" : "🌙"}
    </button>
  );
}

The mounted check prevents a hydration mismatch: the server renders without knowing the user’s theme; the client knows it. By rendering a placeholder until mounted, the markup matches across server/client.

5. Add the toggle to your layout (e.g. in the navbar)

import { ThemeToggle } from "@/components/ThemeToggle";
 
export function Navbar() {
  return (
    <header>
      <h1>My Site</h1>
      <nav>
        <a href="/">Home</a>
        <ThemeToggle />
      </nav>
    </header>
  );
}

6. Use dark: variants in your Tailwind classes

<div className="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">
  Hello
</div>
 
<button className="rounded bg-blue-500 px-4 py-2 text-white dark:bg-blue-700">
  Click me
</button>

Wherever you have a color, write the dark variant too. Tailwind’s dark: prefix applies the style only when dark mode is active.

For larger projects, define semantic color tokens instead of repeating dark variants everywhere:

// tailwind.config.ts
theme: {
  extend: {
    colors: {
      surface: {
        DEFAULT: "rgb(255 255 255)",
        dark: "rgb(15 23 42)",  // slate-900
      },
      ink: {
        DEFAULT: "rgb(15 23 42)",
        dark: "rgb(241 245 249)",  // slate-100
      }
    }
  }
}

Then use bg-surface dark:bg-surface-dark text-ink dark:text-ink-dark everywhere. Change the palette once, get a coherent redesign for free.

(More modern alternative in Tailwind v4: CSS custom properties + @custom-variant dark. Sticking with v3 conventions here for compatibility.)

7. Optional: prevent the FOUC manually

next-themes already handles this via a <script> it injects. If you see a brief flash anyway:

  • Make sure <html lang="en" suppressHydrationWarning> is set
  • Make sure defaultTheme="system" is on the provider
  • For ultra-strict cases, use next-themesforcedTheme prop to lock a page

8. Test

  • Reload with system in dark mode → app starts in dark
  • Click the toggle → switches to light
  • Reload → still light (persistence)
  • Switch system to light → app stays light (your toggle wins until you reset)
  • Click “system” if your toggle has a third option → respects system again

Verification

  • ✅ Toggle works
  • ✅ Choice persists across reloads
  • ✅ Initial render matches user’s saved preference (no FOUC)
  • ✅ Colors look right in both modes (sufficient contrast — see accessibility)
  • ✅ No hydration warnings in the console

Common failures

Toggle flickers light → dark on reload

The mounted-check pattern in step 4 prevents this. Make sure your toggle doesn’t render the actual icon until mounted is true.

”Hydration failed” warnings

Either:

  • Missing suppressHydrationWarning on <html>
  • Component using theme without the mounted check
  • A child component reading theme and rendering different things server vs client

Dark mode works but colors look terrible

Color choices, not config. Pick a palette with sufficient contrast. Test with the WebAIM contrast checker. Aim for WCAG AA (4.5:1 for normal text).

Toggle changes theme but page styles don’t change

You’re using Tailwind utilities without dark: variants. Tailwind isn’t psychic — you have to write dark:bg-slate-900 etc.

Works locally, broken in production

Vercel build cache. Force a clean deploy: in Vercel Deployments → … → Redeploy with “Use existing Build Cache” unchecked.

System preference detection is wrong

The browser exposes prefers-color-scheme: dark via CSS media query and JS matchMedia. If it’s wrong, the browser/OS is misreporting. Test in a different browser.


Bonus: a “system” option

next-themes supports defaultTheme="system" — meaning “follow OS preference unless user has explicitly chosen.” With a third toggle option for “system” you can let users pick: Light, Dark, or Auto.

const { theme, setTheme } = useTheme();
 
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
  <option value="light">Light</option>
  <option value="dark">Dark</option>
  <option value="system">Auto</option>
</select>

What you’ve just built

A production-quality dark mode:

  • Respects system preference initially
  • User-overridable with a persistent choice
  • No FOUC
  • Accessible (keyboard-toggleable, screen-reader-labeled)
  • Works on every page automatically (via the provider in layout.tsx)

See also

Sources