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 usually comes from one of two places: its script changes <html> before React hydrates, or a component renders from useTheme() before the theme is known. Put suppressHydrationWarning on <html> for the script's change. Delay theme-dependent UI until mount, or render it with CSS.

Symptoms

You may see this called a next-themes hydration mismatch, a next-themes hydration warning, or a Next ThemeProvider hydration error. Searches for "next-themes hydration failed" and "Using next-themes for dark mode generates hydration failed error" describe the same problem. React's message depends on what changed:

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 <html> warnings can come from the pre-hydration theme script. A changed icon or class deeper in the page usually comes from rendering with useTheme() too early.

hydration-proof reports a changed class as HP1004 and other attributes as HP1002. It checks theme-like classes and prefers-color-scheme for a likely cause.

Why the next-themes hydration error happens

Unless you store the preference in a cookie, the server may not know the visitor's theme. Two common approaches can then cause different mismatches:

  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 can do the same thing when it reads matchMedia or localStorage during render.

How to fix it

If the user chose a theme, store it in a cookie and read it on the server. Both renders can start from that choice:

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";
 
import { useState } from "react";
 
export function ThemeToggle({ theme }: { theme: "light" | "dark" }) {
  const [currentTheme, setCurrentTheme] = useState(theme);
 
  const toggle = () => {
    const next = currentTheme === "dark" ? "light" : "dark";
    document.cookie = `theme=${next}; path=/; max-age=31536000; samesite=lax`;
    document.documentElement.classList.remove("dark", "light");
    document.documentElement.classList.add(next);
    setCurrentTheme(next);
  };
  return (
    <button onClick={toggle}>
      {currentTheme === "dark" ? "Light mode" : "Dark mode"}
    </button>
  );
}

The server cannot read a visitor's operating-system theme on a first request. For a "system" option, use CSS prefers-color-scheme where possible.

suppressHydrationWarning: next-themes needs it on <html>

With next-themes, suppress only the expected change on <html>:

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>
  );
}

The next-themes README recommends this placement. The prop covers only <html> itself. Use attribute="class" if your CSS relies on a class such as dark.

Do not add the prop to more elements just to hide other warnings. Fix those differences separately. See when suppression is safe.

Render theme-dependent UI after mount

For a toggle that reads useTheme(), render a stable placeholder until 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>
  );
}

If only the icon changes, render both icons and use CSS to show the right one. The theme class can then decide before JavaScript runs.

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

This browser read during render causes the same problem without next-themes:

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>
  );
}

Prefer CSS or the cookie approach above. If you use an inline script to set the theme before hydration, place 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 can then use dark: classes or CSS variables without reading the theme during render.

Catch it with ESLint

audit-suppress-hydration-warning flags misplaced suppression props. It allows <html> and <body> by default. no-match-media-in-render and no-storage-in-initial-render catch browser-only 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 differences may appear as HP1004 or HP1002. A suppressed <html> appears as HP6001, so you can see what the prop hid. Use --probe to check whether swapping the browser color scheme changes the finding. See probes.

npx hydration-proof test --probe