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

Mode sombre Next.js sans flash ni avertissements React 19

Comment remplacer next-themes par Zustand + useServerInsertedHTML pour un mode sombre sans clignotement dans Next.js 15+.

Publié 11 avril 20268 min de lecture

TL;DR

next-themes insère une balise <script> dans un React Client Component pour éviter le flash de thème (FOUC). React 19 émet désormais un avertissement — impossible à supprimer. La bibliothèque n'a pas été mise à jour depuis mars 2025. La solution : remplacer next-themes par un store Zustand + useServerInsertedHTML pour injecter le script hors de l'arbre React. Zéro dépendance ajoutée. Zéro FOUC. Zéro avertissement.

Le Problème

Si vous utilisez next-themes avec Next.js 15+ et React 19, vous obtenez cette erreur dans la console à chaque chargement de page :

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

Ce n'est pas une incohérence d'hydratation. React 19 avertit explicitement que les balises <script> rendues par des composants React côté client ne s'exécuteront jamais. Le script fonctionne pendant le SSR (il est dans le HTML), mais React le signale comme incorrect.

Pourquoi Ça Se Produit

next-themes doit définir la bonne classe de thème sur <html> avant l'hydratation de React — sinon il y a un flash du mauvais thème. Pour cela, il injecte un <script> inline 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 a changé son comportement : les balises script dans les composants sont désormais explicitement signalées. Avant React 19, c'était ignoré silencieusement. La prop suppressHydrationWarning sur le script n'aide pas — elle supprime les avertissements d'hydratation, pas l'avertissement "script dans un composant".

Ce Que Nous Avons Essayé (Et Pourquoi Ça N'a Pas Marché)

Nous avons systématiquement essayé chaque approche avant de trouver la 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

La Solution : Zustand + useServerInsertedHTML

L'insight clé : useServerInsertedHTML est un hook Next.js qui injecte du HTML dans le flux SSR en dehors de l'arbre de composants React. Le script se retrouve dans le HTML mais React ne le "voit" jamais lors du rendu côté client — donc pas d'avertissement. Combiné avec un store Zustand pour l'état réactif du thème, on obtient un remplacement complet sans dépendances.

Comment Ça Fonctionne

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

Étape 1 : Store Zustand

Le store gère l'état du thème, applique les classes au DOM, détecte le thème système et synchronise entre les onglets. La méthode _init() renvoie une fonction de nettoyage pour 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,
  }));
}

Étape 2 : ThemeProvider

Le provider fait deux choses : injecte le script anti-FOUC via useServerInsertedHTML, et initialise le store Zustand au montage :

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

Étape 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>
  )
}

Étape 4 : Utilisation

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 depuis next-themes

L'API est intentionnellement identique. La migration se résume à un seul changement d'import par fichier :

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

Comparaison

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

Pourquoi Pas Les Autres Alternatives ?

@wrksz/themes

Un remplacement direct qui utilise aussi useServerInsertedHTML. Ça fonctionne, mais c'est une dépendance de plus maintenue par un seul développeur. Si next-themes nous a appris quelque chose — les dépendances finissent par être abandonnées. Avec ~100 lignes de code, vous possédez entièrement la solution.

next-themes@1.0.0-beta.0

Existe sur npm, mais sans date de sortie, sans changelog et sans indication claire que l'avertissement React 19 est corrigé. Miser du code de production sur une bêta indéfinie n'est pas un risque qui en vaut la peine.

CSS uniquement (prefers-color-scheme)

Fonctionne pour la détection du thème système, mais ne peut pas gérer la persistance des préférences utilisateur (localStorage), le changement manuel de thème ni l'option "system". JavaScript est nécessaire pour cela.

Conclusions

  1. next-themes est effectivement abandonné — dernière version mars 2025, avertissement React 19 non corrigé
  2. useServerInsertedHTML est la bonne primitive Next.js pour injecter des scripts sans avertissements React
  3. Zustand fournit un état de thème réactif avec moins de code qu'un provider Context
  4. La solution complète fait ~100 lignes, zéro nouvelle dépendance, et vous en êtes propriétaire

Sources