Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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).

There’s a deeper architectural reason these issues cluster together. The browser paints the page in a fixed order: HTML parse, head scripts run synchronously, body renders, then React hydrates. next-themes injects its theme-resolving script high enough in <head> that the <html class="dark"> mutation lands before the first paint. That’s why the page doesn’t flash. But React, when it later hydrates, compares the server-rendered HTML to the client tree it would have produced. The server never wrote class="dark" — that came from the inline script — so React sees a mismatch and warns. suppressHydrationWarning tells React “this is expected on this one element.”

The App Router introduces another wrinkle. Server Components can’t import client-only libraries like next-themes directly. You need a "use client" wrapper sitting between your Server Component layout and the provider. Forgetting the wrapper produces a confusing build error or a silently broken provider. The shadcn/ui pattern of a dedicated components/theme-provider.tsx file isn’t decoration — it’s the only way to put a client boundary at the right level without making your whole layout client-side.

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.

next-themes vs Other Theme Solutions

next-themes isn’t the only theme switcher in React land. Each library makes different trade-offs around SSR safety, system preference, and styling integration. Picking the wrong one — or porting between them — is a common source of “it used to work” bugs.

next-themes. SSR-first. Injects an inline script in <head> to set the class before paint, returns undefined from useTheme() on first render to avoid hydration mismatches, and persists to localStorage by default. Best fit when you use Tailwind’s class strategy or CSS variables driven by a class on <html>. Lightweight (no styling opinions).

Theme UI / Emotion / styled-components themes. CSS-in-JS theme objects passed through <ThemeProvider>. The theme is consumed by useTheme() and applied to styled components at render time. No inline script — first paint is unstyled or styled with the default theme, then re-renders if the user’s saved preference differs. This causes a noticeable FOUC under SSR unless you write your own pre-hydration script.

Mantine ColorSchemeProvider / useMantineColorScheme. Integrated with Mantine’s styling system. Mantine v7 ships a ColorSchemeScript component you must put in <head> — it’s effectively the same inline-script pattern as next-themes, just Mantine-specific. The persistence default is localStorage. If you forget the ColorSchemeScript, you get the same FOUC next-themes guards against.

Chakra UI useColorMode / ColorModeScript. Same architecture as Mantine: drop <ColorModeScript> in your HTML head, then useColorMode() gives you colorMode and toggleColorMode. Chakra applies a chakra-ui-light / chakra-ui-dark class. Tightly coupled to Chakra’s component library — not a fit if you don’t use Chakra.

Radix Themes <Theme> and appearance. Radix’s Theme provider accepts appearance="dark" | "light" | "inherit". No built-in toggle; you wire your own state and feed it to appearance. Radix expects you to combine it with next-themes (or your own SSR-safe persistence) for production apps. The Radix docs literally show next-themes as the recommended companion.

Plain CSS prefers-color-scheme. No JavaScript needed. Cannot honor user override (the OS decides). Cannot persist a manual choice. Fine for content sites where “follow the OS” is enough; insufficient for apps that offer a Light/Dark/System picker.

A practical mapping: use next-themes for any Tailwind or CSS-variable app where you want SSR safety without buying into a component library. Use Mantine/Chakra’s built-in scripts if you’re already in those ecosystems — adding next-themes on top usually fights with their own color-mode state. Use Radix Themes + next-themes together when you want Radix’s primitives plus correct SSR theming. Use plain prefers-color-scheme when you don’t need a manual toggle and want zero JS.

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>.
  • Mixing next-themes with Mantine’s or Chakra’s color-mode state. Both libraries inject their own pre-hydration scripts and write to localStorage. Running them side by side gives you two competing sources of truth — toggle one, the other reverts on next paint. Pick one system per app.
  • Theme flicker only on the first navigation in App Router. The inline script runs on every full document load, but client-side navigations don’t re-run it (the document is already there). If you’re seeing flicker on <Link> navigations, you’re probably re-rendering theme-aware children before the provider rehydrates. Move the toggle gate higher in the tree.
  • storageKey changes break existing users. Renaming the storage key migrates everyone back to defaultTheme because the old key is ignored. If you must rename, write a one-time migration that reads the old key and copies its value into the new one.

For related React SSR and styling issues, see React hydration error, Next.js hydration failed, Tailwind v4 not working, and shadcn ui 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