Fix: next-themes Not Working — Hydration Mismatch, Tailwind Dark Mode, FOUC, and System Preference
Quick Answer
How to fix next-themes errors — hydration mismatch on mount, FOUC flash before theme applies, Tailwind dark: classes not switching, ThemeProvider in App Router, defaultTheme system not respected, and TypeScript types.
The Error
You add next-themes and get a hydration warning the moment you mount:
Warning: Prop `className` did not match.
Server: ""
Client: "dark"Or there’s a brief flash of light theme before the dark theme applies (FOUC):
[Page loads with white background]
[~100ms later: dark theme applies, page goes dark]Or Tailwind’s dark: classes don’t react to the theme toggle:
<div className="bg-white dark:bg-gray-900">Hello</div>
// Stays white even when theme is "dark".Or useTheme() returns undefined for the theme on first render:
const { theme } = useTheme();
console.log(theme); // undefined, then "dark" on next renderWhy This Happens
next-themes solves a hard SSR problem: the user’s preferred theme is only known on the client (from localStorage or prefers-color-scheme), but the server renders before the client tells it. The library’s strategy:
- Inject a synchronous
<script>in<head>that reads the saved theme and sets theclass(ordata-theme) on<html>before React hydrates. - Return
undefinedfromuseTheme()on the initial client render to prevent React from rendering a hydration-mismatching value.
Three places this breaks:
<ThemeProvider>not at the root. The script tag is only injected ifThemeProvideris mounted inlayout.tsx(App Router) or_app.tsx(Pages Router).- Tailwind dark mode strategy not set. v3 defaults to
media(CSS-only). With next-themes you needclassorselectormode so the toggle class drives the variant. - Rendering theme-dependent UI before mount. If you read
themeduring the first render and use it in JSX, you’ll get a mismatch (server sawundefined, client sees the actual value).
Fix 1: Mount <ThemeProvider> at the Root
App Router:
// 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
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}Pages Router:
// pages/_app.tsx
import { ThemeProvider } from "next-themes";
export default function App({ Component, pageProps }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<Component {...pageProps} />
</ThemeProvider>
);
}Three required props:
attribute="class"— sets<html class="dark">when the theme is dark. Use"data-theme"if you prefer<html data-theme="dark">.defaultTheme="system"— follow the OS setting if nothing is saved.enableSystem— listen toprefers-color-schemechanges.
Pro Tip: Use disableTransitionOnChange to suppress CSS transitions during theme switches. Without it, every color animates between themes for ~200ms — looks broken if you have lots of transitions defined.
Fix 2: Add suppressHydrationWarning to <html>
The script tag mutates <html>’s class before React hydrates, so React’s hydration check sees a mismatch — it’ll log a warning unless you tell it to suppress for that one element:
<html lang="en" suppressHydrationWarning>suppressHydrationWarning is one-level: it only suppresses warnings for the <html> element itself, not its descendants. Other hydration issues elsewhere still surface.
Common Mistake: Adding suppressHydrationWarning to <body> or other elements to “fix” warnings. The script only touches <html>. Suppressing elsewhere hides real bugs.
Fix 3: Configure Tailwind for class Strategy
Tailwind v3:
// tailwind.config.js
module.exports = {
darkMode: "class", // or "selector" — same behavior
content: ["./src/**/*.{ts,tsx}"],
// ...
};Tailwind v4:
/* app/globals.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));With attribute="class" on ThemeProvider, next-themes toggles <html class="dark">. Tailwind’s class strategy (or v4’s @custom-variant) makes dark: variants react to that class.
For data-theme attribute mode:
// tailwind.config.js (v3)
module.exports = {
darkMode: ['selector', '[data-theme="dark"]'],
};/* Tailwind v4 */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));Common Mistake: Setting darkMode: "media" (v3 default) and then wondering why your toggle doesn’t work. media ignores classes and uses only prefers-color-scheme.
Fix 4: Don’t Render Theme-Dependent UI Before Mount
useTheme() returns undefined for theme until the client mounts. Any UI that branches on the theme will mismatch:
"use client";
import { useTheme } from "next-themes";
export function Logo() {
const { theme } = useTheme();
return <img src={theme === "dark" ? "/logo-dark.svg" : "/logo-light.svg"} />;
// Hydration mismatch: server rendered "/logo-light.svg", client maybe "/logo-dark.svg".
}Fix with a “mounted” gate:
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
export function Logo() {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <img src="/logo-light.svg" alt="" />; // Safe default
}
return <img src={resolvedTheme === "dark" ? "/logo-dark.svg" : "/logo-light.svg"} alt="" />;
}The mounted gate guarantees server and client render the same thing initially, then swap to the theme-aware version after the first paint. The flash is brief (single frame in most cases).
Pro Tip: For most theme-dependent styling, use CSS variables instead of JS branches. CSS variables update instantly when the theme class changes, with no React re-render and no hydration concerns:
:root { --logo-fill: black; }
.dark { --logo-fill: white; }
.logo { fill: var(--logo-fill); }Fix 5: Use resolvedTheme, Not theme
useTheme() exposes two values:
theme— the user’s selection:"light","dark", or"system".resolvedTheme— what’s actually active. Iftheme === "system", this is"light"or"dark"based on the OS.
For rendering decisions, you almost always want resolvedTheme:
const { theme, resolvedTheme, setTheme } = useTheme();
// What's actually being shown:
console.log(resolvedTheme); // "light" or "dark"
// What the user picked:
console.log(theme); // "system" if they're following the OS
// Setting:
setTheme("dark"); // forces dark
setTheme("system"); // follows OSFor a 3-state toggle (light / system / dark), use theme. For “what color is the UI right now” decisions, use resolvedTheme.
Fix 6: shadcn/ui and Other Component Library Setup
shadcn/ui’s quick-start assumes next-themes. The boilerplate is:
// components/theme-provider.tsx
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}// app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider";
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>The wrapper is needed because next-themes exports a Client Component but layouts in App Router are Server Components — you need a “use client” boundary somewhere.
For a theme toggle button:
"use client";
import { useTheme } from "next-themes";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<div>
<button onClick={() => setTheme("light")}>Light</button>
<button onClick={() => setTheme("dark")}>Dark</button>
<button onClick={() => setTheme("system")}>System</button>
</div>
);
}Fix 7: Force a Theme Per Route
For marketing pages that should always be light regardless of user preference:
// app/pricing/layout.tsx
"use client";
import { useEffect } from "react";
import { useTheme } from "next-themes";
export default function PricingLayout({ children }) {
const { setTheme, theme: previousTheme } = useTheme();
useEffect(() => {
setTheme("light");
return () => setTheme(previousTheme ?? "system");
}, []);
return <>{children}</>;
}Or pass forcedTheme on a nested <ThemeProvider>:
import { ThemeProvider } from "next-themes";
<ThemeProvider forcedTheme="light">
<PricingPage />
</ThemeProvider>forcedTheme overrides user preference for that subtree. The user’s theme value is preserved — they go back to their setting on a route without forcedTheme.
Fix 8: Cookies for Pre-Render Awareness
By default, next-themes stores the choice in localStorage, which the server can’t see. The first server render is theme-agnostic; the script tag fixes the class before hydration. For genuinely server-aware theming (e.g. setting different OG images per theme), use the cookie storage:
import { cookies } from "next/headers";
// In a Server Component:
const cookieStore = await cookies();
const themeCookie = cookieStore.get("theme")?.value;
// "light", "dark", or undefined.To make next-themes write cookies, you currently need a small wrapper (the package supports localStorage by default; cookie writes are typically a custom adapter or done manually):
"use client";
import { useEffect } from "react";
import { useTheme } from "next-themes";
export function PersistTheme() {
const { resolvedTheme } = useTheme();
useEffect(() => {
if (resolvedTheme) {
document.cookie = `theme=${resolvedTheme}; path=/; max-age=31536000; SameSite=Lax`;
}
}, [resolvedTheme]);
return null;
}Mount <PersistTheme /> once inside <ThemeProvider> so the cookie always mirrors the active theme.
Still Not Working?
A few less-obvious failures:
- Theme works in dev, breaks in prod. Production might tree-shake or minify the inline script differently. Check the
<script>tag is still in<head>in production HTML. useTheme()outside of<ThemeProvider>. Returnsundefinedeverywhere. Make sure the consumer is rendered inside the provider tree.- Tailwind
dark:works but custom CSS doesn’t. Custom rules need to target the class too:.dark .my-class { color: white }rather than@media (prefers-color-scheme: dark). - CSS variables flash to wrong values. Define both light and dark values in
:rootand.dark, not light in:rootand dark in@media. The class-based override is instant; media-based isn’t. enableColorScheme={false}needed. When true (the default), next-themes also setscolor-scheme: darkon the html. If you have custom scrollbars or form controls, this affects their default styling.- Theme not persisting across page navigations. localStorage is per-origin and persists. If it’s not persisting, check browser settings (private browsing erases it) and CSP (some setups block storage).
- Multiple ThemeProviders nested unintentionally. Each ThemeProvider runs its own script tag and reads its own storage key. Use exactly one at the root unless you genuinely need scoped theming.
- Storage key collision with another library. Override with
storageKey="my-app-theme"on<ThemeProvider>.
For related React SSR and styling issues, see React hydration error, Next.js hydration failed, Tailwind v4 not working, and CSS variable not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: React 19 Actions Not Working — useActionState, useFormStatus, useOptimistic, and form action
How to fix React 19 actions errors — useActionState signature, form action vs onSubmit, useFormStatus must be in child, useOptimistic state desync, Server Actions in client components, and error handling.
Fix: Next.js 'params should be awaited before using its properties'
How to fix Next.js 15 async params and searchParams errors — await in Server Components, React.use in Client Components, generateMetadata, generateStaticParams, and the codemod migration path.
Fix: React Compiler Not Working — ESLint Plugin, Babel Setup, Bail-Outs, and Vite/Next.js Config
How to fix React Compiler issues — eslint-plugin-react-compiler not flagging, babel-plugin-react-compiler not running, 'Function contains a code construct that prevents compilation', Next.js 15 config, and removing useMemo/useCallback safely.
Fix: React Router 7 Not Working — Framework Mode, Loaders, Type Safety, and Remix Migration
How to fix React Router v7 errors — framework mode vs library mode setup, loader/action data type narrowing, route module exports missing, single-fetch revalidation, hydration mismatch, and Remix v2 migration paths.