Skip to main content
Înapoi la blog
next.jsreact-19dark-modezustandperformance

Next.js Dark Mode fără flash sau avertismente React 19

Cum să înlocuiești next-themes cu Zustand + useServerInsertedHTML pentru dark mode fără pâlpâire în Next.js 15+, fără avertismente de script React 19.

Publicat 11 aprilie 20268 min de citit

TL;DR

next-themes randează un tag <script> în interiorul unui React Client Component pentru a preveni flash-ul de temă (FOUC). React 19 avertizează acum despre asta — și nu există nicio modalitate de a-l suprima. Biblioteca nu a mai fost actualizată din martie 2025. Soluția: înlocuiește next-themes cu un store Zustand + useServerInsertedHTML pentru a injecta scriptul în afara arborelui React. Zero dependențe adăugate. Zero FOUC. Zero avertismente.

Problema

Dacă folosești next-themes cu Next.js 15+ și React 19, primești această eroare în consolă la fiecare încărcare a paginii:

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

Aceasta nu este o nepotrivire de hidratare. Este React 19 care avertizează explicit că tagurile <script> randate de componente React pe client nu vor rula niciodată. Scriptul funcționează în timpul SSR (se află în HTML), dar React îl marchează ca fiind incorect.

De ce se întâmplă

next-themes trebuie să seteze clasa de temă corectă pe <html> înainte ca React să hidrateze — altfel obții un flash cu tema greșită. Pentru asta, injectează un <script> inline prin 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 și-a schimbat comportamentul: tagurile script din interiorul componentelor sunt acum marcate explicit. Înainte de React 19, acest lucru era ignorat în tăcere. Prop-ul suppressHydrationWarning de pe script nu ajută — el suprimă avertismentele de hidratare, nu avertismentul „script în componentă".

Ce am încercat (și de ce a eșuat)

Am încercat sistematic fiecare abordare înainte de a găsi soluția:

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

Soluția: Zustand + useServerInsertedHTML

Ideea cheie: useServerInsertedHTML este un hook Next.js care injectează HTML în stream-ul SSR în afara arborelui de componente React. Scriptul ajunge în HTML, dar React nu îl „vede" niciodată în timpul randării pe client — deci niciun avertisment. Combinat cu un store Zustand pentru stare reactivă a temei, obținem un înlocuitor complet, fără dependențe.

Cum funcționează

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

Pasul 1: Store Zustand

Store-ul gestionează starea temei, aplică clase pe DOM, se ocupă de detectarea temei sistemului și sincronizează între taburi. Metoda _init() returnează o funcție de curățare pentru a fi folosită în 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,
  }));
}

Pasul 2: ThemeProvider

Provider-ul face două lucruri: injectează scriptul de prevenire a FOUC prin useServerInsertedHTML și inițializează store-ul Zustand la montare:

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}</>
}

Pasul 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>
  )
}

Pasul 4: Folosește-l

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>
  );
}

Migrare de la next-themes

API-ul este identic în mod intenționat. Migrarea înseamnă o singură modificare de import per fișier:

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()

Comparație

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

De ce nu alte alternative?

@wrksz/themes

Un înlocuitor direct care folosește și el useServerInsertedHTML. Funcționează, dar este încă o dependență de la un singur maintainer. Dacă next-themes ne-a învățat ceva — dependențele sunt abandonate. Cu ~100 de linii de cod, poți deține soluția în întregime.

next-themes@1.0.0-beta.0

Există pe npm, dar fără dată de lansare, fără changelog și fără vreo indicație clară că avertismentul React 19 este rezolvat. A paria cod de producție pe o versiune beta pe termen nedefinit nu este un risc care merită asumat.

Doar CSS (prefers-color-scheme)

Funcționează pentru detectarea temei sistemului, dar nu poate gestiona persistența preferinței utilizatorului (localStorage), schimbarea manuală a temei sau opțiunea „system". Pentru asta ai nevoie de JavaScript.

Concluzii

  1. next-themes este practic abandonat — ultima lansare martie 2025, avertismentul React 19 nerezolvat
  2. useServerInsertedHTML este primitiva Next.js corectă pentru injectarea scripturilor fără avertismente React
  3. Zustand oferă stare reactivă a temei cu mai puțin cod decât un provider de Context
  4. Întreaga soluție are ~100 de linii, zero dependențe noi, și deții fiecare linie

Surse