GetLaunchpad
Back to blog
5 min read

How to add dark mode to Next.js App Router (without the flash)

The naive dark mode approach causes a flash of the wrong theme. Here's the correct setup with next-themes, Tailwind v4, suppressHydrationWarning, and a hydration-safe toggle component.

Dark mode in Next.js App Router is trickier than it looks. The naive approach — toggling a class on mount — causes a flash of the wrong theme on page load. The correct approach requires a theme provider, a CSS variable strategy, and a specific way to avoid server/client hydration mismatches.

Here's the full setup that works reliably in Next.js App Router with Tailwind CSS.

Install next-themes

next-themes is the standard solution for Next.js. It handles the initial theme flash, system preference detection, and localStorage persistence correctly.

npm install next-themes

Wrap your app with ThemeProvider

Add the provider in your root layout. It must be a client component, so create a providers wrapper:

// components/providers.tsx
"use client";

import { ThemeProvider } from "next-themes";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
      {children}
    </ThemeProvider>
  );
}
// app/layout.tsx
import { Providers } from "@/components/providers";

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

The suppressHydrationWarning on the <html> tag is required — next-themes adds a class to <html>before React hydrates, so there's an intentional mismatch that you need to suppress.

Configure Tailwind for dark mode

Tailwind needs to know to use the dark class on the HTML element (which is what next-themes sets). In Tailwind v3, this goes in tailwind.config.ts:

// tailwind.config.ts
export default {
  darkMode: "class",
  // ...
}

In Tailwind v4 (no config file), add it to your CSS:

/* app/globals.css */
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));

This tells Tailwind's v4 engine to apply dark: variants when an ancestor element has the dark class — which is exactly what next-themes adds.

Build the theme toggle

Use the useTheme hook from next-themes in a client component:

"use client";

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

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

  // Avoid hydration mismatch: render nothing until mounted
  useEffect(() => setMounted(true), []);
  if (!mounted) return <div className="h-8 w-8" />;

  const isDark = resolvedTheme === "dark";

  return (
    <button
      type="button"
      aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
      onClick={() => setTheme(isDark ? "light" : "dark")}
      className="rounded-md p-1.5 text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
    >
      {isDark ? (
        // Sun icon
        <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
          <path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
        </svg>
      ) : (
        // Moon icon
        <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
          <path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
        </svg>
      )}
    </button>
  );
}

The mountedcheck is critical. Without it, the server renders without knowing the user's theme preference, and you get a React hydration error when the client renders a different icon.

Add dark: variants to your components

With the setup done, use Tailwind's dark: prefix to style elements for dark mode:

<div className="bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
  <p className="text-zinc-500 dark:text-zinc-400">
    This text is medium gray in both modes.
  </p>
  <div className="border border-zinc-200 dark:border-zinc-700">
    Bordered element that adapts to dark mode.
  </div>
</div>

System preference and persistence

With defaultTheme="system" and enableSystem, the initial theme matches the user's OS preference. Once the user manually toggles, their choice is saved in localStorage and restored on every visit.

If you want to default to light mode regardless of system preference, use defaultTheme="light".

Common mistakes

What GetLaunchpad includes

GetLaunchpad ships with dark mode pre-configured: next-themes installed and wrapped in the providers component, suppressHydrationWarning on the HTML element, the Tailwind v4 @custom-variant dark directive, and a ThemeToggle component in the navbar. Every UI element on the site uses dark: variants, so dark mode works on every page out of the box.

Share this article:Share on X

Ready to ship faster?

GetLaunchpad gives you everything covered in this guide — pre-configured, tested, and production-ready. Skip the setup and focus on your product.

Get the boilerplate →

More articles