# next-themes Hydration Error: Flackern in Next.js beheben

> Behebe den next-themes Hydration Error und das Theme-Flackern im Next.js App Router. Plus: ein Theme Toggle, der schon beim ersten Paint richtig aussieht.

- Autor: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Veröffentlicht: 13. Okt. 2026
- Kategorie: [Next.js und Tailwind CSS](https://wingo-ui.com/de/blog/category/nextjs-tailwind)
- Lesezeit: 8 Min.
- Kanonische URL: https://wingo-ui.com/de/blog/next-themes-hydration-error
- Englische Fassung: https://wingo-ui.com/blog/next-themes-hydration-error

## Kurz gesagt

Ein next-themes Hydration Error entsteht, weil der Server das Theme nicht lesen kann, denn es liegt in localStorage: Das Inline-Skript von next-themes ändert das html-Element, bevor React hydriert, und jeder Text, jedes Label und jede Klasse, die du aus useTheme() renderst, weicht zwischen Server und Browser ab. Setz suppressHydrationWarning nur auf html, halte theme-abhängiges Markup aus dem ersten Render heraus und zeichne das Icon des Toggles mit dark:-Klassen, damit schon das Server-HTML stimmt. Das Flackern verschwindet, wenn der Provider oben im Root Layout sitzt und attribute="class" zu deinem CSS passt.

Du fügst next-themes zu einem Next.js-Projekt mit App Router hinzu, und die Konsole füllt sich: „A tree hydrated but some attributes of the server rendered HTML didn't match“, „Hydration failed because the server rendered text didn't match the client“ und manchmal „Encountered a script tag while rendering React component“. Oder die Konsole bleibt still, und die Seite blitzt weiß auf, bevor sie dunkel wird. Jedes dieser Symptome ist ein next-themes Hydration Error mit derselben Ursache. Dieses Tutorial behebt jedes Symptom und baut dann einen Theme Toggle, der schon im Server-HTML stimmt.

## Warum verursacht next-themes einen Hydration Error?

Weil das Theme in `localStorage` liegt und der Server es nicht lesen kann. Der Quellcode von next-themes (0.4.6) macht das deutlich: Der initiale State liest `localStorage` nur, wenn `window` existiert, also sind `theme` und `resolvedTheme` auf dem Server `undefined`. Im Browser hat schon der erste Render den gespeicherten Wert.

Danach unterscheiden sich zwei Dinge zwischen Server-HTML und Hydration-Render:

1. **Das `<html>`-Element.** next-themes fügt ein Inline-Skript ein, das während des Parsens läuft und `class="dark"` sowie `style="color-scheme: dark"` auf `<html>` schreibt, bevor React hydriert. React sieht Attribute, die der Server nie geschickt hat.
2. **Alles, was du aus `useTheme()` renderst.** Ein `resolvedTheme === "dark" ? <Moon /> : <Sun />` rendert auf dem Server, der kein Theme kennt, die Sonne und in einem dunklen Browser den Mond.

Wir haben jedes gängige Muster mit `renderToString` und `hydrateRoot(document)` aus React 19.3, next-themes 0.4.6 und einem dunklen Farbschema nachgestellt, in Chromium über Playwright, in Development- und Production-Builds:

| Was die Seite rendert | Konsole in Development | Production | Was Besucher bekommen |
| --- | --- | --- | --- |
| `<html>` ohne `suppressHydrationWarning` | „A tree hydrated but some attributes ... didn't match“ | Nichts | Korrekte Seite |
| Text oder ein Icon, gewählt aus `resolvedTheme` | „Hydration failed because the server rendered text (or HTML) didn't match the client“, dazu der Script-Tag-Fehler | Minifizierter React-Fehler #418 | Das ganze Dokument wird auf dem Client neu gebaut |
| `className` oder `aria-label` aus `resolvedTheme` | Die Attribut-Warnung | Nichts | Ein veralteter Wert: `is-light` auf einer dunklen Seite, nie korrigiert |
| Das Theme hinter einem mounted-Flag | Nichts | Nichts | Ein leerer Button bis zur Hydration |

Die dritte Zeile ist die gefährliche. Production loggt nichts, und React korrigiert Attribute nach einem Mismatch nicht. Der Button behält also die helle Klasse vom Server (oder ein Screenreader liest das falsche Label vor), bis sich der Wert wieder ändert.

## Reicht suppressHydrationWarning auf html?

Es ist die richtige Lösung für Zeile eins und für nichts sonst. Setz suppressHydrationWarning next-themes-konform so, wie es das README verlangt, auf das `<html>`-Element im Root Layout:

```tsx
// app/layout.tsx
import type { ReactNode } from "react";
import { Providers } from "./providers";
import "./globals.css";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className="bg-background text-foreground">
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
```

```tsx
// app/providers.tsx
"use client";

import type { ReactNode } from "react";
import { ThemeProvider } from "next-themes";

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
      {children}
    </ThemeProvider>
  );
}
```

Die [React-Doku zu Hydration-Mismatches](https://react.dev/reference/react-dom/client/hydrateRoot#suppressing-unavoidable-hydration-mismatch-errors) beschreibt das Prop als Notausgang, der nur eine Ebene tief wirkt. Genau das macht es hier sicher: Es deckt die Attribute ab, die next-themes auf `<html>` ändert, und nichts innerhalb von `<body>`. In unserem Test entfernte es die Warnung aus Zeile eins und ließ Zeile zwei und drei genau so, wie sie waren. Verteil es nicht auf Komponenten, um die anderen Zeilen ruhigzustellen. Im Fall der Klasse würde es einen Bug verstecken, der so in Production landet.

## Warum flackert mein Dark Mode in Next.js?

Ein heller Frame vor einer dunklen Seite heißt: Die Klasse kam nach dem ersten Paint. Genau das ist das Next.js Dark Mode Flackern. Damit der next-themes Flash verschwindet, muss das Skript von next-themes vor dem Inhalt laufen, den es stylt. Prüf diese Punkte der Reihe nach:

- **Der Provider steht im Root Layout, oben im `<body>`.** Das Skript wird dort gerendert, wo `ThemeProvider` gerendert wird. In einer Page oder einem verschachtelten Layout kann es hinter Inhalt landen, den der Browser schon gezeichnet hat.
- **`attribute` passt zu deinem CSS.** next-themes schreibt standardmäßig `data-theme`. Sucht deine Tailwind-Variante nach `.dark`, läuft das Skript zwar rechtzeitig, aber dein CSS ignoriert es, und die Seite bleibt hell, bis etwas anderes die Klasse setzt. Der Beitrag zum [Tailwind v4 Dark Mode](https://wingo-ui.com/de/blog/tailwind-v4-dark-mode) erklärt die Variante.
- **Keine dunklen Werte in einem Media-Block.** Ein übrig gebliebener `@media (prefers-color-scheme: dark)`-Block aus dem Starter zeichnet zuerst das Theme des Betriebssystems, danach überschreibt deine Klasse es: ein Flackern für alle, deren Wahl vom Betriebssystem abweicht.
- **Kein Theme-Code in `useEffect`.** Setzt du die Klasse selbst in einem Effect, läuft das nach der Hydration und ist damit per Definition zu spät.
- **Farb-Transitions.** Ein `transition-colors` auf `body` oder auf jeder Card macht aus einem Theme-Wechsel ein langsames Verschmieren, das wie Flackern wirkt. `disableTransitionOnChange` schaltet Transitions ab, während next-themes das Theme wechselt.

Wenn ein Icon flackert (erst erscheint die Sonne, dann der Mond), liegt die Ursache in Zeile zwei der Tabelle: ein Render, der vom Theme abhängt. Der Abschnitt zum Toggle weiter unten behebt das.

## Was bedeutet „Encountered a script tag while rendering React component“?

React 19 loggt das im Development-Modus, wenn es ein `<script>`-Element auf dem Client erzeugt, statt eines aus dem Server-HTML zu hydrieren. So ein Skript läuft nie, deshalb warnt React. Die Prüfung in `react-dom` überspringt Datenblöcke, also Skripte, deren `type` kein JavaScript ist, etwa `application/json`.

next-themes 0.4.6 rendert sein Skript innerhalb von `ThemeProvider`, die Warnung erscheint also immer, wenn der Provider auf dem Client mountet. In unserem Test kam sie direkt nach dem Text-Mismatch aus Zeile zwei: React gab die Hydration auf, baute das Dokument auf dem Client neu und erzeugte das Skript von Grund auf. Das [Issue dazu bei next-themes](https://github.com/pacocoursey/next-themes/issues/385) hat im März 2026 ein Entwickler eröffnet, der nach dem Upgrade auf Next.js 16.2.0 darauf gestoßen ist, und es ist Stand Oktober 2026 noch offen.

Behebe es deshalb in zwei Schritten. Entferne zuerst jeden Text-Mismatch, denn der löst es oft aus. Mountet der Provider danach ganz regulär auf dem Client (ein Layout, das eine Client-Navigation zum ersten Mal rendert), markier die Client-Kopie als Datenblock. Die Server-Kopie läuft weiterhin vor dem Paint, und das Script-Element trägt schon `suppressHydrationWarning`, also löst der andere `type` keine Warnung aus:

```tsx
// app/providers.tsx
"use client";

import type { ReactNode } from "react";
import { ThemeProvider } from "next-themes";

// die server-kopie läuft vor dem paint; eine auf dem client erzeugte kopie ist ein inerter datenblock, react warnt also nicht
const scriptProps = typeof window === "undefined" ? undefined : { type: "application/json" };

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange scriptProps={scriptProps}>
      {children}
    </ThemeProvider>
  );
}
```

Der Provider unserer eigenen Website nutzt dieselbe `scriptProps`-Zeile.

## Wie renderst du einen Theme Toggle, der beim ersten Paint stimmt?

Teil den Toggle in das, was CSS zeigen kann, und das, was nur JavaScript weiß. Die Klasse, die next-themes vor dem Paint setzt, sagt dem CSS schon, ob die Seite dunkel ist. Das Icon kann also aus `dark:`-Klassen kommen, ganz ohne Theme im Render:

```tsx
// components/theme-button.tsx
"use client";

import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";

export function ThemeButton() {
  const { resolvedTheme, setTheme } = useTheme();

  return (
    <Button
      variant="ghost"
      size="icon"
      aria-label="Theme umschalten"
      onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
    >
      <Sun className="dark:hidden" />
      <Moon className="hidden dark:block" />
    </Button>
  );
}
```

`resolvedTheme` wird im Click-Handler gelesen, der erst nach der Hydration läuft, also rendern Server und Browser identisches Markup. Das Label benennt die Aktion („Theme umschalten“) und hängt damit auch nicht vom State ab. Lies dort `resolvedTheme`, nicht `theme`: Auf einer Seite mit System ist `theme` gleich `"system"`, also wählt `theme === "dark" ? "light" : "dark"` Dark auf einer Seite, die schon dunkel ist, und der erste Klick tut nichts. Der [Button](https://wingo-ui.com/components/button) hier ist kostenlos, aber jeder andere Button tut es auch.

### Wann brauchst du ein mounted-Flag?

Wenn CSS den State nicht sehen kann. Theme Toggle Hydration wird mit einer System-Option schwieriger: Eine dunkle Seite sieht gleich aus, ob jemand Dark gewählt hat oder System auf einem dunklen Gerät. Keine CSS-Regel kann also entscheiden, welches Segment eines Light / Dark / System-Controls ausgewählt sein soll. Dieser Teil muss auf den Browser warten. Es ist dasselbe Problem, das unser [SSR-sicherer React use mobile Hook](https://wingo-ui.com/de/blog/react-use-mobile-hook-ssr) für die Bildschirmgröße löst. Nimm für das Flag `useSyncExternalStore` statt des Paars aus `useState` und `useEffect` aus dem README:

```ts
// hooks/use-mounted.ts
import { useSyncExternalStore } from "react";

const subscribe = () => () => {};

// false auf dem server und während der hydration, direkt danach true; eine später gemountete komponente liest sofort true
export function useMounted() {
  return useSyncExternalStore(subscribe, () => true, () => false);
}
```

```tsx
// components/theme-options.tsx
"use client";

import { useTheme } from "next-themes";
import { useMounted } from "@/hooks/use-mounted";

const OPTIONS = ["light", "dark", "system"] as const;

export function ThemeOptions() {
  const mounted = useMounted();
  const { theme, setTheme } = useTheme();

  return (
    <div role="group" aria-label="Theme" className="inline-flex gap-1 rounded-full bg-foreground/5 p-1">
      {OPTIONS.map((option) => (
        <button
          key={option}
          type="button"
          aria-pressed={mounted ? theme === option : undefined}
          onClick={() => setTheme(option)}
          className="min-h-11 rounded-full px-3 text-sm capitalize aria-pressed:bg-surface aria-pressed:shadow-raised"
        >
          {option}
        </button>
      ))}
    </div>
  );
}
```

Vor der Hydration ist keine Option gedrückt, und das ist ehrlich: Der Server weiß es nicht. Die Buttons behalten ihre Größe, also verschiebt sich nichts, wenn die Auswahl erscheint. Gegenüber der `useEffect`-Variante gewinnt der Hook auch bei allem, was später mountet, etwa bei diesen Optionen in einem Einstellungsdialog: Für Komponenten, die nach der Hydration mounten, nutzt React den Client-Snapshot. Sie rendern die Auswahl also schon beim ersten Commit statt erst nach einem leeren Render.

### Was der Theme Toggle von Wingo UI macht

Unser [Theme Toggle](https://wingo-ui.com/components/theme-toggle) nutzt beide Techniken. Sein Symbol hat einen `auto`-Zustand für den Server und den Hydration-Render, komplett mit `dark:`-Klassen gezeichnet: Unter `.dark` klappen die Strahlen weg, und die Scheibe wird zur Sichel. Nach dem Mount übernimmt eine animierte Version unter einem neuen React-Key, deshalb ist die Übergabe selbst nicht animiert. `aria-pressed` bleibt ungesetzt, bis das Theme bekannt ist.

Zur Prüfung haben wir die Vorschau des Toggles geladen. Alle JavaScript-Bundles waren blockiert, nur das Inline-Theme-Skript durfte laufen. Das Server-HTML zeigte auf einem dunklen Gerät den Mond und auf einem hellen die Sonne. Drück den Button hier, um den Zyklus über System zu sehen:

> Live-Demo (Theme Toggle): Drück den Button: Er wechselt zu System, dann zu dem Theme, das dein Gerät gerade nicht zeigt, dann zum Theme deines Geräts. Ein kleines Monitor-Badge markiert System. Probier sie unter [Theme Toggle](https://wingo-ui.com/components/theme-toggle) aus und installier sie mit `npx wingo-ui@latest add theme-toggle`.

Die Varianten Segmented und Dropdown folgen der System-Regel von oben. Das [Segmented Control](https://wingo-ui.com/components/segmented-control) hat kein ausgewähltes Segment, bis das Theme bekannt ist, und sein Thumb blendet sich dann ein. Das [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) setzt den Haken an der aktuellen Zeile erst nach dem Mount:

> Live-Demo (Theme Toggle): Öffne das Menü: Light, Dark und System mit einem Haken an der aktuellen Option, auf dem Smartphone als Bottom Sheet. Probier sie unter [Theme Toggle](https://wingo-ui.com/components/theme-toggle) aus und installier sie mit `npx wingo-ui@latest add theme-toggle`.

Installier ihn mit `npx wingo-ui@latest add theme-toggle`. Theme Toggle gehört zu Pro (siehe [Preise](https://wingo-ui.com/de/pricing)); der `ThemeButton` von oben ist der kostenlose Weg.

## Was solltest du als Nächstes tun?

Setz `suppressHydrationWarning` auf `<html>` und nirgendwo sonst. Durchsuch dann deine Client-Komponenten nach `useTheme()` und sortier jede Verwendung ein: Icons und Farben wandern in `dark:`-Klassen, der Auswahl-State kommt hinter `useMounted()`, und Handler lesen weiterhin `resolvedTheme`. Lade danach eine dunkle Seite mit blockiertem JavaScript neu und prüf, ob sie schon dunkel ist und schon den Mond zeigt. Mehr Beiträge zu diesem Stack findest du unter [Next.js und Tailwind CSS](https://wingo-ui.com/de/blog/category/nextjs-tailwind), angefangen beim Pillar-Beitrag zu [Tailwind v4 Design Tokens](https://wingo-ui.com/de/blog/tailwind-v4-design-tokens). Der [Guide zu prefers reduced motion](https://wingo-ui.com/de/blog/prefers-reduced-motion-react) behebt dieselbe Art von Mismatch für Animationen.

## Komponenten in diesem Artikel

- [Theme Toggle](https://wingo-ui.com/components/theme-toggle) (Pro): A React theme toggle for light, dark and system mode on next-themes: an animated sun and moon button, a segmented control or a menu. Installation: `npx wingo-ui@latest add theme-toggle`

## Häufige Fragen

### Warum verursacht next-themes einen Hydration Error?

Der Server hat keinen Zugriff auf localStorage, also liefert useTheme() dort undefined, während der Browser das gespeicherte Theme schon beim ersten Render liest. Alles, was du aus dem Theme renderst, etwa ein Sonnen- oder Mond-Icon oder ein aria-label, unterscheidet sich dann zwischen Server-HTML und Hydration-Render.

### Ist suppressHydrationWarning auf dem html-Tag mit next-themes sicher?

Ja. Es unterdrückt nur Attribut-Abweichungen an genau diesem einen Element, das next-themes absichtlich vor der Hydration ändert. Auf Kinder wirkt es nicht, echte Mismatches in deinen Seiten tauchen also weiterhin auf.

### Wie stoppe ich das Dark-Mode-Flackern in Next.js?

Rendere den ThemeProvider von next-themes oben im body des Root Layouts, gib das Attribut mit, das dein CSS erwartet (attribute="class" für eine .dark-Variante), und halte dunkle Farben aus einem prefers-color-scheme-Media-Block heraus. Dann setzt das Inline-Skript das Theme, bevor irgendetwas gezeichnet wird.

### Was bedeutet Encountered a script tag while rendering React component bei next-themes?

React 19 loggt das im Development-Modus, wenn es ein script-Element auf dem Client erzeugt, statt eines aus dem Server-HTML zu hydrieren, und dieses Skript läuft nie. Bei next-themes erscheint es, wenn der Provider auf dem Client mountet, zum Beispiel nachdem ein Hydration Error React dazu gebracht hat, die Seite neu aufzubauen.

### Brauche ich für einen Theme Toggle einen mounted-State?

Nur für Teile, die CSS nicht zeigen kann, etwa welche Option von Light, Dark oder System ausgewählt ist. Das Icon selbst lässt sich mit dark:-Klassen zeichnen, es stimmt also schon vor der Hydration, statt leer zu bleiben.

---

Quelle: https://wingo-ui.com/de/blog/next-themes-hydration-error
