Skip to content

Fix: next-themes Not Working — Hydration Mismatch, Tailwind Dark Mode, FOUC, and System Preference

FixDevs ·

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 render

Why 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 the class (or data-theme) on <html> before React hydrates.
  • Return undefined from useTheme() 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 if ThemeProvider is mounted in layout.tsx (App Router) or _app.tsx (Pages Router).
  • Tailwind dark mode strategy not set. v3 defaults to media (CSS-only). With next-themes you need class or selector mode so the toggle class drives the variant.
  • Rendering theme-dependent UI before mount. If you read theme during the first render and use it in JSX, you’ll get a mismatch (server saw undefined, 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 to prefers-color-scheme changes.

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. If theme === "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 OS

For 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>. Returns undefined everywhere. 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 :root and .dark, not light in :root and dark in @media. The class-based override is instant; media-based isn’t.
  • enableColorScheme={false} needed. When true (the default), next-themes also sets color-scheme: dark on 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles