Skip to main content
← Back to blog
next.jsreact-19dark-modezustandperformance

Next.js Dark Mode Without Flash or React 19 Warnings

How to replace next-themes with Zustand + useServerInsertedHTML for flicker-free dark mode in Next.js 15+ without triggering React 19 script warnings.

Published April 11, 20268 min read

TL;DR

next-themes renders a <script> tag inside a React Client Component to prevent theme flash (FOUC). React 19 now warns about this — and there's no way to suppress it. The library hasn't been updated since March 2025. The fix: replace next-themes with a Zustand store + useServerInsertedHTML to inject the script outside the React tree. Zero dependencies added. Zero FOUC. Zero warnings.

The Problem

If you use next-themes with Next.js 15+ and React 19, you get this error in the console on every page load:

Encountered a script tag while rendering React component.
Scripts inside React components are never executed when rendering on the client.
Consider using template tag instead.

src/providers/theme-provider.tsx (7:10) @ ThemeProvider

This isn't a hydration mismatch. It's React 19 explicitly warning that <script> tags rendered by React components on the client will never execute. The script works during SSR (it's in the HTML), but React flags it as incorrect.

Why It Happens

next-themes needs to set the correct theme class on <html> before React hydrates — otherwise you get a flash of the wrong theme. To do this, it injects an inline <script> via React.createElement:

next-themes internals (minified)
// next-themes renders a <script> inside a Client Component
return React.createElement(Provider, { value },
  React.createElement("script", {
    suppressHydrationWarning: true,
    dangerouslySetInnerHTML: { __html: `(...theme init code...)` }
  }),
  children
)

React 19 changed its behavior: script tags inside components are now explicitly flagged. Before React 19, this was silently ignored. The suppressHydrationWarning prop on the script doesn't help — it suppresses hydration warnings, not the "script in component" warning.

What We Tried (And Why It Failed)

We systematically tried every approach before finding the solution:

AttemptResult
suppressHydrationWarning on <html>Suppresses hydration mismatch, but NOT the script tag warning
Delay mount with useState + useEffectWarning disappears, but causes FOUC (theme flash)
Raw <script> in layout <head>React 19 catches it — same warning even in Server Components
next/script with beforeInteractiveStill rendered inside React tree — same warning
Remove <head>, put Script in bodySame warning — it's inside <html> which is React-managed
next-themes@1.0.0-beta.0Beta, no release date, unclear if fixed

The Solution: Zustand + useServerInsertedHTML

The key insight: useServerInsertedHTML is a Next.js hook that injects HTML into the SSR stream outside the React component tree. The script ends up in the HTML but React never "sees" it during client rendering — so no warning. Combined with a Zustand store for reactive theme state, we get a complete replacement with zero dependencies.

How It Works

SSR (server):
  useServerInsertedHTML → injects <script> into HTML stream
  ↓
  Browser receives HTML with theme script already in it
  ↓
  Script runs BEFORE React hydrates → correct class on <html>
  ↓
  No flash. No mismatch. No warning.

Client (after hydration):
  useEffect → _init() → Zustand store syncs with localStorage
  ↓
  useTheme() → reactive theme state for components

Step 1: Zustand Store

The store manages theme state, applies classes to the DOM, handles system theme detection, and syncs across tabs. The _init() method returns a cleanup function for use in useEffect:

store/use-theme-store.ts
import { create } from "zustand";

const STORAGE_KEY = "theme";
const MEDIA_QUERY = "(prefers-color-scheme: dark)";

function getSystemTheme(): "light" | "dark" {
  return window.matchMedia(MEDIA_QUERY).matches ? "dark" : "light";
}

function applyTheme(resolved: string, disableTransition: boolean) {
  const d = document.documentElement;

  if (disableTransition) {
    const style = document.createElement("style");
    style.appendChild(
      document.createTextNode(
        "*,*::before,*::after{transition:none!important}"
      )
    );
    document.head.appendChild(style);
    window.getComputedStyle(document.body);
    setTimeout(() => document.head.removeChild(style), 1);
  }

  d.classList.remove("light", "dark");
  d.classList.add(resolved);
  d.style.colorScheme = resolved;
}

interface ThemeState {
  theme: string;
  systemTheme: "light" | "dark";
  resolvedTheme: string;
  setTheme: (theme: string) => void;
  _init: (disableTransition: boolean) => () => void;
}

export const useThemeStore = create<ThemeState>((set, get) => ({
  theme: "system",
  systemTheme: "light",
  resolvedTheme: "light",

  setTheme: (newTheme) => {
    const { systemTheme, disableTransitionOnChange } = get();
    const resolved = newTheme === "system" ? systemTheme : newTheme;
    localStorage.setItem(STORAGE_KEY, newTheme);
    applyTheme(resolved, disableTransitionOnChange);
    set({ theme: newTheme, resolvedTheme: resolved });
  },

  _init: (disableTransition) => {
    const stored = localStorage.getItem(STORAGE_KEY) || "system";
    const system = getSystemTheme();
    const resolved = stored === "system" ? system : stored;

    set({ theme: stored, systemTheme: system, resolvedTheme: resolved });
    applyTheme(resolved, disableTransition);

    // System theme changes
    const mq = window.matchMedia(MEDIA_QUERY);
    const onChange = (e: MediaQueryListEvent) => {
      const newSystem = e.matches ? "dark" : "light";
      const { theme } = get();
      set({ systemTheme: newSystem,
            resolvedTheme: theme === "system" ? newSystem : theme });
      if (theme === "system") applyTheme(newSystem, disableTransition);
    };
    mq.addEventListener("change", onChange);

    // Cross-tab sync
    const onStorage = (e: StorageEvent) => {
      if (e.key !== STORAGE_KEY || !e.newValue) return;
      const system = getSystemTheme();
      const resolved = e.newValue === "system" ? system : e.newValue;
      set({ theme: e.newValue, resolvedTheme: resolved });
      applyTheme(resolved, disableTransition);
    };
    window.addEventListener("storage", onStorage);

    return () => {
      mq.removeEventListener("change", onChange);
      window.removeEventListener("storage", onStorage);
    };
  },
}));

export function useTheme() {
  return useThemeStore((s) => ({
    theme: s.theme,
    setTheme: s.setTheme,
    resolvedTheme: s.resolvedTheme,
    systemTheme: s.systemTheme,
  }));
}

Step 2: ThemeProvider

The provider does two things: injects the FOUC-prevention script via useServerInsertedHTML, and initializes the Zustand store on mount:

providers/theme-provider.tsx
"use client"

import { useEffect } from "react"
import { useServerInsertedHTML } from "next/navigation"
import { useThemeStore } from "@/store/use-theme-store"

const THEME_INIT_SCRIPT = `(function(){
  try {
    var t = localStorage.getItem("theme") || "system";
    var r = t;
    if (t === "system") {
      r = window.matchMedia("(prefers-color-scheme: dark)").matches
        ? "dark" : "light";
    }
    document.documentElement.classList.remove("light", "dark");
    document.documentElement.classList.add(r);
    document.documentElement.style.colorScheme = r;
  } catch(e) {}
})()`

export function ThemeProvider({
  children,
  disableTransitionOnChange = false,
}) {
  // Injects script into SSR HTML outside the React tree
  // — prevents FOUC without triggering React 19 warning
  useServerInsertedHTML(() => (
    <script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
  ))

  useEffect(() => {
    return useThemeStore.getState()._init(disableTransitionOnChange)
  }, [disableTransitionOnChange])

  return <>{children}</>
}

Step 3: Layout

app/layout.tsx
import { ThemeProvider } from "@/providers/theme-provider"

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider disableTransitionOnChange>
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

Step 4: Use It

components/theme-switch.tsx
import { useTheme } from "@/store/use-theme-store"

export function ThemeSwitch() {
  const { theme, setTheme } = useTheme();

  return (
    <button onClick={() => setTheme(
      theme === "dark" ? "light" : "dark"
    )}>
      {theme === "dark" ? "☀️" : "🌙"}
    </button>
  );
}

Migration from next-themes

The API is intentionally identical. Migration is a single import change per file:

migration
- import { useTheme } from "next-themes"
+ import { useTheme } from "@/store/use-theme-store"

  // API is identical — no other changes needed
  const { theme, setTheme, resolvedTheme, systemTheme } = useTheme()

Comparison

next-themesZustand + useServerInsertedHTML
React 19 warningYes — <script> in client componentNo
FOUC preventionYesYes
Cross-tab syncYesYes
System theme detectionYesYes
disableTransitionOnChangeYesYes
Bundle size~3.5 KB~1.5 KB (uses existing Zustand)
Dependencies+1 (next-themes)0 (Zustand already in project)
Maintenance riskAbandoned since March 2025Your own code

Why Not Other Alternatives?

@wrksz/themes

A drop-in replacement that also uses useServerInsertedHTML. It works, but it's another dependency from a single maintainer. If next-themes taught us anything — dependencies get abandoned. With ~100 lines of code, you can own the solution entirely.

next-themes@1.0.0-beta.0

Exists on npm, but with no release date, no changelog, and no clear indication that the React 19 warning is fixed. Betting production code on an indefinite beta is not a risk worth taking.

CSS-only (prefers-color-scheme)

Works for system theme detection, but can't handle user preference persistence (localStorage), manual theme switching, or the "system" option. You need JavaScript for that.

Conclusions

  1. next-themes is effectively abandoned — last release March 2025, React 19 warning unfixed
  2. useServerInsertedHTML is the correct Next.js primitive for injecting scripts without React warnings
  3. Zustand provides reactive theme state with less code than a Context provider
  4. The entire solution is ~100 lines, zero new dependencies, and you own every line

Sources