TL;DR The server can't read localStorage, so it renders light HTML and the page flips after React loads. Put a small inline script in <head> that sets the dark class on <html> before the first paint, add suppressHydrationWarning to <html>, and switch Tailwind v4's dark variant to a class with @custom-variant.
The dark mode flash in Next.js happens because the server renders the page before it knows which theme the visitor picked. The HTML arrives light, the browser paints it, and only when React runs does your code read localStorage and switch to dark. The fix is a tiny inline script in <head> that sets the dark class on <html> before the first paint, plus suppressHydrationWarning on <html> so React doesn't complain about the class it didn't render.
That's what next-themes does under the hood. Below is the same thing in about 15 lines with Tailwind CSS v4, so you can see each part and drop the dependency if you want. I tested it on a Next.js production build with the JavaScript bundles blocked, to see exactly what the browser paints before React runs.
Say a visitor picked dark mode last week and you saved "dark" in localStorage. On the next visit:
The server sends HTML. It can't read localStorage, so there's no dark class on <html>.
The browser paints that HTML with your light colors.
React loads, hydrates, and a useEffect reads localStorage and adds the class.
The page turns dark.
Steps 2 to 4 are the flash. On a fast laptop it's a blink, on a slow phone it can last long enough to notice. Moving the theme read into a client component doesn't help, because client components still render on the server first, and any code inside them runs after the first paint.
Day / Night Toggle running live. A theme switch with a sun that sets into a starry night.
Tailwind v4's dark: variant follows the operating system (prefers-color-scheme) by default. To let users override it, switch it to a class. In app/globals.css:
css
@import "tailwindcss";@custom-variant dark (&:where(.dark, .dark *));
Now dark:bg-zinc-950 applies whenever an ancestor has the dark class. Wrapping the selector in :where() means it adds no specificity, so dark utilities don't win fights with your other classes.
The script has to run before the browser paints the body, so it goes inline in <head>. Not in a useEffect, and not as a next/script with afterInteractive. In app/layout.tsx:
tsx
import "./globals.css";import type { ReactNode } from "react";const themeScript = `(function () { try { var stored = localStorage.getItem("theme"); var dark = stored ? stored === "dark" : matchMedia("(prefers-color-scheme: dark)").matches; var root = document.documentElement; root.classList.toggle("dark", dark); root.style.colorScheme = dark ? "dark" : "light"; } catch (e) {}})();`;export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="en" suppressHydrationWarning> <head> <script dangerouslySetInnerHTML={{ __html: themeScript }} /> </head> <body className="bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">{children}</body> </html> );}
What the script does, in order:
Reads the saved choice. If there is one, it wins.
If there's no saved choice, it follows the operating system with matchMedia("(prefers-color-scheme: dark)").
Sets color-scheme too, so native parts like scrollbars and form controls match the page.
Wraps everything in try, because localStorage can throw (some privacy modes, sandboxed iframes). If it throws, the page falls back to light instead of breaking.
It's plain ES5 in a string because it runs before any bundle, so nothing in it gets compiled.
The server rendered <html lang="en">. By the time React hydrates, the script has made it <html lang="en" class="dark" style="color-scheme: dark">. React sees the mismatch. I checked this in next dev: without suppressHydrationWarning, the console shows "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties." With it, the console was clean.
The prop only covers attributes of that one element. It doesn't hide mismatches anywhere else in your tree, so it's safe to leave on <html>. For mismatches inside the page, like dates or random numbers, there's a separate write-up on fixing hydration errors in Next.js.
The toggle has the same problem as the page: on the server it can't know the theme, so if it renders "Light" and the page is dark, you get a wrong label and another hydration mismatch. The fix is to read the theme from the <html> class with useSyncExternalStore, and give the server a null snapshot. In components/ThemeToggle.tsx:
tsx
"use client";import { useSyncExternalStore } from "react";type Theme = "light" | "dark";function subscribe(onChange: () => void) { const observer = new MutationObserver(onChange); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); return () => observer.disconnect();}const getTheme = (): Theme => document.documentElement.classList.contains("dark") ? "dark" : "light";export function setTheme(theme: Theme) { const root = document.documentElement; root.classList.toggle("dark", theme === "dark"); root.style.colorScheme = theme; localStorage.setItem("theme", theme);}export function ThemeToggle() { // null on the server and during hydration, the real theme right after const theme = useSyncExternalStore<Theme | null>(subscribe, getTheme, () => null); const dark = theme === "dark"; return ( <button type="button" onClick={() => setTheme(dark ? "light" : "dark")} aria-pressed={theme === null ? undefined : dark} aria-label="Dark mode" className="rounded-full border border-zinc-300 px-4 py-2 text-sm dark:border-zinc-700" > {theme === null ? "Theme" : dark ? "Dark" : "Light"} </button> );}
The <html> class is the single source of truth. A MutationObserver tells React when it changes, so if anything else flips the class (another toggle, the system sync below, a keyboard shortcut), every toggle on the page updates. During hydration the button says "Theme" and has no aria-pressed, then switches to the real state right after. If you'd rather show nothing, render an empty placeholder of the same size so the layout doesn't jump. There's more on this pattern in useSyncExternalStore and getServerSnapshot in Next.js.
If the user hasn't chosen a theme, it's nice to follow the operating system when it switches at sunset. Put this anywhere in the tree, for example in the layout:
tsx
"use client";import { useEffect } from "react";// Follow OS changes while the user hasn't picked a theme themselvesexport function SystemThemeSync() { useEffect(() => { const media = matchMedia("(prefers-color-scheme: dark)"); const onChange = () => { if (localStorage.getItem("theme")) return; document.documentElement.classList.toggle("dark", media.matches); document.documentElement.style.colorScheme = media.matches ? "dark" : "light"; }; media.addEventListener("change", onChange); return () => media.removeEventListener("change", onChange); }, []); return null;}
Once the user clicks the toggle, localStorage has a value and this listener stops overriding them.
I built this with next build (Next.js 15.5, React 19.1, Tailwind CSS v4.3) and ran it with next start. With Playwright in headless Chromium I blocked every request to /_next/static/chunks/, so React never loaded, and read the page as the browser first painted it:
Saved "dark", system light: <html class="dark">, color-scheme: dark, dark body background.
Nothing saved, system dark: dark.
Nothing saved, system light: no class, white background.
Then with JavaScript loading normally:
The toggle showed "Dark" with aria-pressed="true" after hydration.
Clicking it removed the class and saved "light".
A reload kept the saved choice.
With nothing saved, switching the emulated system theme from light to dark added the class without a reload.
If you need several themes (not just light and dark), a data-theme attribute instead of a class, or theme switching across many apps, next-themes is a good choice and handles more edge cases. Remember the same two pieces there: it injects the script for you, and you still need suppressHydrationWarning on <html>. For one light/dark switch, the code above is small enough to own.
Two other options you'll see:
Storing the theme in a cookie and reading it with cookies() in the layout. The server then renders the right class and you need no script, but reading cookies in the root layout makes every route under it dynamic, so you lose static rendering. That's a bad trade for a blog or landing page.
CSS only, with no toggle. If you just follow the system, skip all of this and use Tailwind's default dark: variant. There's no flash because nothing waits on JavaScript.
One more thing: if your site sends a strict Content Security Policy, an inline script needs a nonce or a hash, or the browser will block it and you're back to the flash.
The toggle above is a plain button on purpose. If you want one with some life in it, the Day / Night Toggle on qodesign is free: the sun sets into a cratered moon and the stars fall in. It takes a controlled night prop and an onChange(night) callback, so you can feed it the theme from the hook above and call setTheme when it changes. There's a Next.js guide for it. More free pieces with full source are at /free.
The server renders the page without knowing the saved theme, the browser paints that light HTML, and your code only switches to dark after React runs. An inline script in <head> that adds the dark class to <html> before the first paint removes the flash.
Yes, on the <html> element. The inline script changes its class before React hydrates, and React reports the attribute mismatch in development. suppressHydrationWarning only covers that one element's attributes, so it won't hide other hydration problems.
Add @custom-variant dark (&:where(.dark, .dark *)); to your main CSS file after @import "tailwindcss";. That line replaces the darkMode: "class" option from a v3 config file. Without it, dark: follows the operating system setting.
Only if you don't offer a toggle and just follow the system with prefers-color-scheme. To remember a user's choice without a script, the server has to read it from a cookie, which makes your routes dynamic in Next.js.