Hydration Proof

Search documentation

Find a page or section

Fix the next-themes hydration error

The server cannot know the color scheme. Something has to give.

A next-themes hydration error has two sources. next-themes sets the theme class on <html> with a script before React hydrates, so React finds attributes it did not render; and components that render from useTheme() get undefined on the server. Add suppressHydrationWarning to <html> only, and render theme-dependent UI after mount.

Symptoms

Issue titles call it a next-themes hydration mismatch, a next-themes hydration warning, "next-themes hydration failed" or a Next ThemeProvider hydration error. A typical report reads "Using next-themes for dark mode generates hydration failed error". The messages:

Warning: Extra attributes from the server: class,style
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up.
Warning: Prop `className` did not match. Server: "theme-light" Client: "theme-dark"
Hydration failed because the server rendered HTML didn't match the client.

The first two come from <html>, where the pre-hydration script added class="dark" and style="color-scheme: dark". The last two come from a component that renders a different class or icon once it knows the theme.

hydration-proof reports class differences as HP1004 and other attributes (an SVG fill, a data-theme) as HP1002, with the cause Theme preference (dark/light mode). It recognizes theme-like class names (dark, light, theme-*) and prefers-color-scheme in the code; in the package's test suite it names the cause with 99% confidence for a class and 91% for an SVG attribute.

Why the next-themes hydration error happens

The server renders HTML before it knows anything about the visitor's color scheme. There are two ways to fill that gap, and each fails in its own way:

  1. Set the class before React hydrates. next-themes injects a small blocking script that reads the stored theme (or the system preference) and updates <html> before the page paints, so there is no flash. React then hydrates an <html> whose attributes differ from what it rendered.
  2. Decide in a component. A toggle that shows a sun or a moon from useTheme() renders the server's guess first. The next-themes README says it directly: "Because we cannot know the theme on the server, many of the values returned from useTheme will be undefined until mounted on the client."

Hand-written dark mode has the same problem when it reads matchMedia("(prefers-color-scheme: dark)") or localStorage during render.

How to fix it

The only fix with no suppression and no flash: store the choice in a cookie and render it on the server. Both renders then agree from the start:

app/layout.tsx
import { cookies } from "next/headers";
 
export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const theme =
    (await cookies()).get("theme")?.value === "dark" ? "dark" : "light";
  return (
    <html lang="en" className={theme}>
      <body>{children}</body>
    </html>
  );
}
app/theme-toggle.tsx
"use client";
 
export function ThemeToggle({ theme }: { theme: "light" | "dark" }) {
  const toggle = () => {
    const next = theme === "dark" ? "light" : "dark";
    document.cookie = `theme=${next}; path=/; max-age=31536000; samesite=lax`;
    document.documentElement.className = next;
  };
  return (
    <button onClick={toggle}>
      {theme === "dark" ? "Light mode" : "Dark mode"}
    </button>
  );
}

A "system" setting cannot be rendered on the server this way, because the server does not know the operating system's preference. Style that case with a CSS prefers-color-scheme media query instead of a class.

suppressHydrationWarning: next-themes needs it on <html>

With next-themes, mark the one element its script changes:

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">{children}</ThemeProvider>
      </body>
    </html>
  );
}

From the next-themes README: "If you do not add suppressHydrationWarning to your <html> you will get warnings because next-themes updates that element. This property only applies one level deep, so it won't block hydration warnings on other elements." The default attribute is data-theme; use attribute="class" for class-based dark mode such as Tailwind's.

Do not move suppressHydrationWarning to <body> or a wrapper <div> to silence component warnings. It covers only that element's own attributes and text, and hides real bugs there. See when suppressHydrationWarning is safe.

Render theme-dependent UI after mount

For components that read useTheme(), the README recommends waiting for the mount:

app/theme-switch.tsx
"use client";
 
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
 
export function ThemeSwitch() {
  const [mounted, setMounted] = useState(false);
  const { resolvedTheme, setTheme } = useTheme();
 
  useEffect(() => {
    setMounted(true);
  }, []);
 
  // Same size as the real button, so nothing shifts when it appears.
  if (!mounted) return <span className="inline-block size-9" />;
 
  return (
    <button
      onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
    >
      {resolvedTheme === "dark" ? "Light mode" : "Dark mode"}
    </button>
  );
}

When only the look differs, CSS avoids the extra render: render both icons and hide one with a dark: variant (dark:hidden on the sun, hidden dark:block on the moon). The class on <html> decides which one shows, before any JavaScript runs.

Fix a Next.js dark mode hydration error without next-themes

Hand-written dark mode usually reads the preference in render:

theme-preview.tsx
"use client";
 
export function ThemePreview() {
  // Before: false on the server, true in a dark browser
  const dark =
    typeof window !== "undefined" &&
    window.matchMedia("(prefers-color-scheme: dark)").matches;
  return (
    <div className={dark ? "theme-dark" : "theme-light"}>Theme preview</div>
  );
}

Replace the read with CSS (@media (prefers-color-scheme: dark)), with the cookie approach above, or with the same pattern next-themes uses: an inline script that sets the class before hydration, and suppressHydrationWarning on <html>:

app/layout.tsx
const setTheme = `
  try {
    const stored = localStorage.getItem("theme");
    const dark = stored ? stored === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
    document.documentElement.classList.toggle("dark", dark);
  } catch {}
`;
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script dangerouslySetInnerHTML={{ __html: setTheme }} />
      </head>
      <body>{children}</body>
    </html>
  );
}

Components then style themselves with dark: classes or CSS variables, and never read the theme during render.

Catch it with ESLint

audit-suppress-hydration-warning accepts suppressHydrationWarning on <html> and <body> (its allowOn option), and reports it where it does nothing or hides too much: on an element with element children, on an element with only static content, and on a component. It also checks Server Components, since the root layout is one. no-match-media-in-render and no-storage-in-initial-render catch hand-written theme reads.

npm install -D eslint-plugin-hydration-proof

Catch it in CI

Test both color schemes:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  matrix: {
    colorScheme: ["light", "dark"],
  },
});

Theme bugs show as HP1004 or HP1002. A suppressed <html> shows as HP6001, an info finding that lists what the attribute hid, so you can check it only covers the theme class. With --probe, hydration-proof reloads the page with only the color scheme swapped between light and dark; if the value follows it, the theme is the proven cause. See probes.

npx hydration-proof test --probe