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.
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) @ ThemeProviderAceasta 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 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:
| Attempt | Result |
|---|---|
suppressHydrationWarning on <html> | Suppresses hydration mismatch, but NOT the script tag warning |
Delay mount with useState + useEffect | Warning disappears, but causes FOUC (theme flash) |
Raw <script> in layout <head> | React 19 catches it — same warning even in Server Components |
next/script with beforeInteractive | Still rendered inside React tree — same warning |
Remove <head>, put Script in body | Same warning — it's inside <html> which is React-managed |
next-themes@1.0.0-beta.0 | Beta, 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 componentsPasul 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:
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:
"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
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
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:
- 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-themes | Zustand + useServerInsertedHTML | |
|---|---|---|
| React 19 warning | Yes — <script> in client component | No |
| FOUC prevention | Yes | Yes |
| Cross-tab sync | Yes | Yes |
| System theme detection | Yes | Yes |
disableTransitionOnChange | Yes | Yes |
| Bundle size | ~3.5 KB | ~1.5 KB (uses existing Zustand) |
| Dependencies | +1 (next-themes) | 0 (Zustand already in project) |
| Maintenance risk | Abandoned since March 2025 | Your 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
next-themeseste practic abandonat — ultima lansare martie 2025, avertismentul React 19 nerezolvatuseServerInsertedHTMLeste primitiva Next.js corectă pentru injectarea scripturilor fără avertismente React- Zustand oferă stare reactivă a temei cu mai puțin cod decât un provider de Context
- Întreaga soluție are ~100 de linii, zero dependențe noi, și deții fiecare linie
Surse
- shadcn/ui #10104 — Dark mode guide should address React 19 script warning
- next-themes #296 — React 19 support request (open since 2024)
- React #34008 — Script tags not executing in components (by design)
- @wrksz/themes — Drop-in replacement using useServerInsertedHTML
- Next.js Docs — useServerInsertedHTML API