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

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

Publicado 11 de abril de 20268 min de lectura

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) @ ThemeProvider

Esto 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 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 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:

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

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 components

Paso 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:

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

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:

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

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

Paso 4: Úsalo

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

Migración desde next-themes

La API es intencionalmente idéntica. La migración es un único cambio de import por archivo:

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

Comparación

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

¿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

  1. next-themes está efectivamente abandonado — última versión marzo de 2025, advertencia de React 19 sin corregir
  2. useServerInsertedHTML es el primitivo correcto de Next.js para inyectar scripts sin advertencias de React
  3. Zustand proporciona estado reactivo del tema con menos código que un proveedor de Context
  4. La solución completa tiene ~100 líneas, cero nuevas dependencias, y eres dueño de cada línea

Fuentes