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:
- 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. - 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 thethemeon the server, many of the values returned fromuseThemewill beundefineduntil 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
Render the theme on the server from a cookie
If the user chose a theme, store it in a cookie and read it on the server. Both renders can start from that choice:
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>
);
}"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>:
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:
"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:
"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>:
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-proofCatch it in CI
Test both color schemes:
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