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
- Missing suppressHydrationWarning on <html>: Causes a Next.js hydration warning in development. Always add it when using next-themes.
- Rendering the toggle icon without a mounted check: Causes a hydration mismatch error. Use the
mounted state pattern shown above. - Using useTheme in a server component:next-themes hooks only work in client components (marked with "use client").
- Forgetting darkMode: "class" in Tailwind v3 config: Without this,
dark:variants won't work even if the HTML class is set correctly.
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.