# Fix the Dark Mode Flash in Next.js (Tailwind v4, No Library)

> 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.

- URL: https://qodesign.dev/blog/nextjs-dark-mode-flash-tailwind
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Next.js fixes
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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.

## Why the flash happens

Say a visitor picked dark mode last week and you saved `"dark"` in `localStorage`. On the next visit:

1. The server sends HTML. It can't read `localStorage`, so there's no `dark` class on `<html>`.
2. The browser paints that HTML with your light colors.
3. React loads, hydrates, and a `useEffect` reads `localStorage` and adds the class.
4. 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.

## Step 1: a class-based dark variant in Tailwind v4

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.

## Step 2: set the class before the first paint

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.

## Step 3: suppressHydrationWarning on html

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](/blog/fix-hydration-error-dates-random-nextjs).

## Step 4: a toggle that reads the real theme

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](/blog/usesyncexternalstore-getserversnapshot-nextjs).

## Step 5 (optional): follow system changes

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 themselves
export 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.

## What I tested

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.
- The production console had no errors or warnings.

`tsc --strict` passes on all four files.

## Should you use next-themes instead?

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](https://github.com/pacocoursey/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.

## A toggle worth clicking

The toggle above is a plain button on purpose. If you want one with some life in it, the [Day / Night Toggle](/components/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](/react/day-night-theme-toggle) for it. More free pieces with full source are at [/free](/free).

## FAQ

### Why does my Next.js site flash white before dark mode loads?

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.

### Do I need suppressHydrationWarning for dark mode in Next.js?

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.

### How do I set up class-based dark mode in Tailwind CSS v4?

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.

### Can I avoid the dark mode flash without JavaScript?

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.
