Next.js Dark Mode sin parpadeo ni advertencias de React 19
Cómo reemplazar next-themes con Zustand + useServerInsertedHTML para modo oscuro sin parpadeo en Next.js 15+.
TL;DR
next-themes renderiza una etiqueta <script> dentro de un componente cliente de React para evitar el parpadeo de tema (FOUC). React 19 ahora advierte sobre esto — y no hay forma de suprimirlo. La librería no ha sido actualizada desde marzo de 2025. La solución: reemplazar next-themes con un store de Zustand + useServerInsertedHTML para inyectar el script fuera del árbol de React. Sin dependencias adicionales. Sin FOUC. Sin advertencias.
El Problema
Si usas next-themes con Next.js 15+ y React 19, obtienes este error en la consola en cada carga de página:
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) @ ThemeProviderEsto no es un desajuste de hidratación. Es React 19 advirtiendo explícitamente que las etiquetas <script> renderizadas por componentes React en el cliente nunca se ejecutarán. El script funciona durante el SSR (está en el HTML), pero React lo marca como incorrecto.
Por Qué Ocurre
next-themes necesita establecer la clase de tema correcta en <html> antes de que React hidrate — de lo contrario se produce un destello del tema incorrecto. Para lograr esto, inyecta un <script> en línea mediante 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 cambió su comportamiento: las etiquetas script dentro de componentes ahora se marcan explícitamente. Antes de React 19, esto se ignoraba silenciosamente. La prop suppressHydrationWarning en el script no ayuda — suprime las advertencias de hidratación, no la advertencia de "script en componente".
Qué Intentamos (Y Por Qué Falló)
Probamos sistemáticamente cada enfoque antes de encontrar la solución:
| 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 |
La Solución: Zustand + useServerInsertedHTML
La clave: useServerInsertedHTML es un hook de Next.js que inyecta HTML en el stream SSR fuera del árbol de componentes de React. El script termina en el HTML pero React nunca lo "ve" durante el renderizado en el cliente — por lo tanto, sin advertencias. Combinado con un store de Zustand para el estado reactivo del tema, obtenemos un reemplazo completo sin dependencias adicionales.
Cómo Funciona
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 componentsPaso 1: Store de Zustand
El store gestiona el estado del tema, aplica clases al DOM, maneja la detección del tema del sistema y sincroniza entre pestañas. El método _init() devuelve una función de limpieza para usar en 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,
}));
}Paso 2: ThemeProvider
El provider hace dos cosas: inyecta el script de prevención de FOUC mediante useServerInsertedHTML, e inicializa el store de Zustand al montarse:
"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}</>
}Paso 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>
)
}Paso 4: Úsalo
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>
);
}Migración desde next-themes
La API es intencionalmente idéntica. La migración es un único cambio de import por archivo:
- 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()Comparación
| 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 |
¿Por Qué No Otras Alternativas?
@wrksz/themes
Un reemplazo directo que también usa useServerInsertedHTML. Funciona, pero es otra dependencia de un único mantenedor. Si next-themes nos enseñó algo — las dependencias se abandonan. Con ~100 líneas de código, puedes ser dueño de la solución por completo.
next-themes@1.0.0-beta.0
Existe en npm, pero sin fecha de lanzamiento, sin changelog y sin indicación clara de que la advertencia de React 19 esté corregida. Apostar el código de producción a una beta indefinida no es un riesgo que valga la pena asumir.
Solo CSS (prefers-color-scheme)
Funciona para la detección del tema del sistema, pero no puede manejar la persistencia de preferencias del usuario (localStorage), el cambio manual de tema ni la opción "system". Para eso se necesita JavaScript.
Conclusiones
next-themesestá efectivamente abandonado — última versión marzo de 2025, advertencia de React 19 sin corregiruseServerInsertedHTMLes el primitivo correcto de Next.js para inyectar scripts sin advertencias de React- Zustand proporciona estado reactivo del tema con menos código que un proveedor de Context
- La solución completa tiene ~100 líneas, cero nuevas dependencias, y eres dueño de cada línea
Fuentes
- 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