# Fix the next-themes hydration error

> A next-themes hydration error comes from the class set on html before hydration or useTheme() in render. Suppress it on html; render theme UI after mount.

Source: https://hydration.jscrate.dev/docs/causes/theme
Last updated: 2026-09-18

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:

```text
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](https://hydration.jscrate.dev/docs/issues/hp1004) and
other attributes (an SVG `fill`, a `data-theme`) as
[HP1002](https://hydration.jscrate.dev/docs/issues/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

### 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:

```tsx title="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>
  );
}
```

```tsx title="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:

```tsx title="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](https://github.com/pacocoursey/next-themes): "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](https://hydration.jscrate.dev/docs/guides/suppresshydrationwarning).

### Render theme-dependent UI after mount

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

```tsx title="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:

```tsx title="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>`:

```tsx title="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`](https://hydration.jscrate.dev/docs/rules/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`](https://hydration.jscrate.dev/docs/rules/no-match-media-in-render) and
[`no-storage-in-initial-render`](https://hydration.jscrate.dev/docs/rules/no-storage-in-initial-render)
catch hand-written theme reads.

```bash
npm install -D eslint-plugin-hydration-proof
```

## Catch it in CI

Test both color schemes:

```ts title="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](https://hydration.jscrate.dev/docs/issues/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](https://hydration.jscrate.dev/docs/probes).

```bash
npx hydration-proof test --probe
```

## Related

- [suppressHydrationWarning: when it is safe](https://hydration.jscrate.dev/docs/guides/suppresshydrationwarning)
- [shadcn/ui and dark mode](https://hydration.jscrate.dev/docs/guides/shadcn-hydration-error)
- [localStorage in render](https://hydration.jscrate.dev/docs/causes/storage)
- [Intentional differences](https://hydration.jscrate.dev/docs/causes/suppressed)
- [Extra attributes from the server](https://hydration.jscrate.dev/docs/errors/extra-attributes-from-the-server)
