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

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:
- The
<html>element. next-themes injects an inline script that runs while the page parses and writesclass="dark"andstyle="color-scheme: dark"on<html>, before React hydrates. React sees attributes the server never sent. - Anything you render from
useTheme(). AresolvedTheme === "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:
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:
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 whereThemeProviderrenders. Inside a page or a nested layout it can end up after content the browser has already painted. attributematches your CSS. next-themes writesdata-themeby 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-colorsonbodyor on every card turns a theme switch into a slow smear that reads as a flicker.disableTransitionOnChangeturns 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:
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:
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:
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:
$ npx wingo-ui@latest add theme-toggleThe 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:
$ npx wingo-ui@latest add theme-toggleInstall 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.
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


