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:
- 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 has the same problem when it reads
matchMedia("(prefers-color-scheme: dark)") or localStorage during render.
How to fix it
Render the theme on the server from a cookie
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:
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";
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:
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:
"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:
"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>:
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-proofCatch it in CI
Test both color schemes:
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