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+.
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) @ ThemeProviderCe 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 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 :
| 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 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 :
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 :
"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
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
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 :
- 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-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 |
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
next-themesest effectivement abandonné — dernière version mars 2025, avertissement React 19 non corrigéuseServerInsertedHTMLest la bonne primitive Next.js pour injecter des scripts sans avertissements React- Zustand fournit un état de thème réactif avec moins de code qu'un provider Context
- La solution complète fait ~100 lignes, zéro nouvelle dépendance, et vous en êtes propriétaire
Sources
- 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