Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Next.js and Tailwind CSS
  4. next-themes Hydration Error: Fix the Flash in Next.js
Auf Deutsch lesen
  1. Blog
  2. Next.js and Tailwind CSS
  3. next-themes Hydration Error: Fix the Flash in Next.js
Next.js and Tailwind CSS
Next.js and Tailwind CSS

next-themes Hydration Error: Fix the Flash in Next.js

SR
Serban Rusu · Founder of Wingo UI
Oct 13, 2026 · 8 min read

On this page

0%
  1. Why does next-themes cause a hydration error?
  2. Is suppressHydrationWarning on html enough?
  3. Why does my Next.js dark mode flicker?
  4. What does "Encountered a script tag while rendering React component" mean?
  5. How do you render a theme toggle that is correct on the first paint?
    1. When do you need a mounted flag?
    2. What the Wingo UI Theme Toggle does
  6. What should you do next?
  7. FAQ
    1. Why does next-themes cause a hydration error?
    2. Is suppressHydrationWarning on the html tag safe with next-themes?
    3. How do I stop the dark mode flash in Next.js?
    4. What does Encountered a script tag while rendering React component mean with next-themes?
    5. Should I use a mounted state for a theme toggle?

TL;DR

A next-themes hydration error happens because the server cannot read the theme, which lives in localStorage: next-themes' inline script changes the html element before React hydrates, and any text, label or class you render from useTheme() differs between the server and the browser. Put suppressHydrationWarning on html only, keep theme-dependent markup out of the first render, and draw the toggle's icon with dark: classes so the server HTML is already right. The flash goes away when the provider sits at the top of the root layout with attribute="class" matching your CSS.

Published Oct 13, 2026

You add next-themes to a Next.js App Router project and the console fills up: "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", and sometimes "Encountered a script tag while rendering React component". Or the console is quiet and the page flashes white before it turns dark. Every one of them is a next-themes hydration error with the same root cause. This tutorial fixes each symptom, then builds a theme toggle that is already correct in the server HTML.

Why does next-themes cause a hydration error?

Because the theme lives in localStorage, and the server cannot read it. The next-themes source (0.4.6) makes this explicit: its initial state reads localStorage only when window exists, so on the server theme and resolvedTheme are undefined. In the browser the first render already has the saved value.

Two things then differ between the server HTML and the hydration render:

  1. The <html> element. next-themes injects an inline script that runs while the page parses and writes class="dark" and style="color-scheme: dark" on <html>, before React hydrates. React sees attributes the server never sent.
  2. Anything you render from useTheme(). A resolvedTheme === "dark" ? <Moon /> : <Sun /> renders the sun on the server, which has no theme, and the moon in a dark browser.

We reproduced every common pattern with React 19.3's renderToString and hydrateRoot(document), next-themes 0.4.6 and a dark color scheme, in Chromium through Playwright, in development and production builds:

What the page rendersDevelopment consoleProductionWhat the visitor gets
<html> without suppressHydrationWarning"A tree hydrated but some attributes ... didn't match"NothingCorrect page
Text or an icon chosen from resolvedTheme"Hydration failed because the server rendered text (or HTML) didn't match the client", plus the script tag errorMinified React error #418The whole document rebuilt on the client
className or aria-label from resolvedThemeThe attribute warningNothingA stale value: is-light on a dark page, never corrected
The theme behind a mounted flagNothingNothingAn empty button until hydration

The third row is the dangerous one. Production logs nothing, and React does not patch attributes after a mismatch, so the button keeps the server's light class (or a screen reader hears the wrong label) until the value changes again.

Is suppressHydrationWarning on html enough?

It is the right fix for row one and nothing else. The suppressHydrationWarning next-themes asks for in its README goes on the <html> element in the 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>
);
}

The React docs on hydration mismatches (opens in a new tab) describe the prop as an escape hatch that only works one level deep. That is what makes it safe here: it covers the attributes next-themes changes on <html> and nothing inside <body>. In our test, adding it removed the warning in row one and left rows two and three exactly as they were. Do not spread it onto components to quiet the other rows; in the class case it would hide a bug that ships.

Why does my Next.js dark mode flicker?

A light frame before a dark page means the class arrived after the first paint. To stop a next-themes flash, its script has to run before the content it styles. Check these, in order:

  • The provider is in the root layout, at the top of <body>. The script renders where ThemeProvider renders. Inside a page or a nested layout it can end up after content the browser has already painted.
  • attribute matches your CSS. next-themes writes data-theme by default. If your Tailwind variant looks for .dark, the script runs on time and your CSS ignores it, so the page stays light until something else sets the class. The Tailwind v4 dark mode post covers the variant.
  • No dark values in a media block. A @media (prefers-color-scheme: dark) block left from the starter paints the OS theme first, then your class overrides it: a flash on every visitor whose choice differs from their OS.
  • No theme code in useEffect. Adding the class yourself in an effect runs after hydration, which is too late by definition.
  • Color transitions. A transition-colors on body or on every card turns a theme switch into a slow smear that reads as a flicker. disableTransitionOnChange turns transitions off while next-themes changes the theme.

If what flashes is an icon (the sun appears, then the moon), the cause is the second row of the table: a render that depends on the theme. The toggle section below fixes it.

What does "Encountered a script tag while rendering React component" mean?

React 19 logs it in development when it creates a <script> element on the client instead of hydrating one from the server HTML. Such a script never runs, so React warns. The check in react-dom skips data blocks, a script whose type is not JavaScript, such as application/json.

next-themes 0.4.6 renders its script inside ThemeProvider, so the warning appears whenever the provider mounts on the client. In our test it appeared right after the text mismatch in row two: React gave up on hydration, rebuilt the document on the client, and created the script from scratch. The next-themes issue about it (opens in a new tab) was opened in March 2026 by a developer who hit it after upgrading to Next.js 16.2.0, and it is still open as of October 2026.

So fix it in two steps. First remove any text mismatch, because that is often what triggers it. Then, if the provider legitimately mounts on the client (a layout that a client navigation renders for the first time), mark the client copy as a data block. The server copy still runs before paint, and the script element already carries suppressHydrationWarning, so the different type causes no warning:

tsx
// app/providers.tsx
"use client";
import type { ReactNode } from "react";
import { ThemeProvider } from "next-themes";
// the server copy runs before paint; a copy created on the client is inert data, so react does not warn
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>
);
}

Our own site's provider uses the same scriptProps line.

How do you render a theme toggle that is correct on the first paint?

Split the toggle into what CSS can show and what only JavaScript knows. The class next-themes sets before paint already tells CSS whether the page is dark, so the icon can come from dark: classes with no theme in the render at all:

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="Toggle theme"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
>
<Sun className="dark:hidden" />
<Moon className="hidden dark:block" />
</Button>
);
}

resolvedTheme is read in the click handler, which only runs after hydration, so the server and the browser render identical markup. The label names the action ("Toggle theme"), so it does not depend on the state either. Read resolvedTheme there, not theme: on a System page theme is "system", so theme === "dark" ? "light" : "dark" picks dark on a page that is already dark and the first click does nothing. The Button here is free; any button works.

When do you need a mounted flag?

When the state cannot be seen in CSS. Theme toggle hydration gets harder with a System option: a dark page looks the same whether the visitor picked Dark or picked System on a dark device, so no CSS rule can tell which segment of a Light / Dark / System control to select. That part has to wait for the browser, the same problem our SSR-safe React use mobile hook solves for screen size. Use useSyncExternalStore for the flag instead of the README's useState and useEffect pair:

ts
// hooks/use-mounted.ts
import { useSyncExternalStore } from "react";
const subscribe = () => () => {};
// false on the server and during hydration, true right after; a component mounted later reads true at once
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>
);
}

Before hydration no option is pressed, which is honest: the server does not know. The buttons keep their size, so nothing shifts when the selection appears. The hook also beats the useEffect version for anything that mounts later, such as these options inside a settings dialog: React uses the client snapshot for components mounted after hydration, so they render the selection on their first commit instead of one empty render first.

What the Wingo UI Theme Toggle does

Our Theme Toggle uses both techniques. Its glyph has an auto state for the server and the hydration render, drawn entirely with dark: classes: the rays fold away and the disc becomes a crescent under .dark. Once mounted, an animated version takes over under a new React key, so the handover does not animate. aria-pressed stays unset until the theme is known.

To check it, we loaded the toggle's preview with every JavaScript bundle blocked and only the inline theme script allowed. The server HTML showed the moon on a dark device and the sun on a light one. Press it here to see the cycle through System:

Press the button: it cycles System, the theme your device is not showing, then your device's theme, and a small monitor badge marks System
$ npx wingo-ui@latest add theme-toggle
ProTheme Toggle docs

The segmented and dropdown variants follow the System rule above. The Segmented Control has no selected segment until the theme is known and its thumb fades in once it is, and the Dropdown Menu checks the current row only after mount:

Open the menu: Light, Dark and System with a check on the current one, shown as a bottom sheet on a phone
$ npx wingo-ui@latest add theme-toggle
ProTheme Toggle docs

Install it with npx wingo-ui@latest add theme-toggle. Theme Toggle is part of Pro (see pricing); the ThemeButton above is the free route.

What should you do next?

Put suppressHydrationWarning on <html> and nowhere else, then search your client components for useTheme() and sort each use: icons and colors move to dark: classes, selection state goes behind useMounted(), and handlers keep reading resolvedTheme. Then reload a dark page with JavaScript blocked and check that it is already dark and already shows the moon. More posts on this stack are in Next.js and Tailwind CSS, starting with the Tailwind v4 design tokens pillar. The prefers reduced motion guide fixes the same kind of mismatch for animations.

Components in this post

  • Theme Toggle

    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.

    Pro

FAQ

Why does next-themes cause a hydration error?

The server has no access to localStorage, so useTheme() returns undefined there, while the browser reads the saved theme on its first render. Anything rendered from the theme, such as a sun or moon icon or an aria-label, then differs between the server HTML and the hydration render.

Is suppressHydrationWarning on the html tag safe with next-themes?

Yes. It only silences attribute differences on that one element, which next-themes changes on purpose before React hydrates. It does not reach children, so real mismatches in your pages still show up.

How do I stop the dark mode flash in Next.js?

Render the next-themes ThemeProvider at the top of the body in the root layout, pass the attribute your CSS expects (attribute="class" for a .dark variant), and keep dark colors out of a prefers-color-scheme media block. The inline script then sets the theme before anything paints.

What does Encountered a script tag while rendering React component mean with next-themes?

React 19 logs it in development when it creates a script element on the client instead of hydrating one from the server HTML, and that script never runs. With next-themes it shows up when the provider mounts on the client, for example after a hydration error made React rebuild the page.

Should I use a mounted state for a theme toggle?

Only for parts CSS cannot show, such as which of Light, Dark or System is selected. The icon itself can be drawn with dark: classes, so it is correct before hydration instead of blank.

  • Next.js
  • Dark mode
  • Hydration
  • React

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Next.js and Tailwind CSS

Next.js Component Library Setup for the App Router

Next.js component library setup for the App Router, tested on Next.js 16.4: use client boundaries, tokens in globals.css, dark mode without a flash, updates.

SRSerban Rusu·Oct 13, 2026·8 min read
Next.js and Tailwind CSS

Tailwind v4 Dark Mode: Class Toggle, Tokens, next-themes

Tailwind v4 dark mode with a class: the @custom-variant line, next-themes wiring, why dark: ignores your toggle, and tokens that keep both themes designed.

SRSerban Rusu·Oct 13, 2026·9 min read
Next.js and Tailwind CSS

Nextjs Cookie Consent: GDPR Banner and Consent Mode v2

Build nextjs cookie consent that holds up under GDPR: equal Accept and Reject buttons, per-category switches, Consent Mode v2 and embeds that wait for consent.

SRSerban Rusu·Oct 16, 2026·12 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI