
Je suis Mathieu Ponton, Co-Founder & ingénieur logiciel chez Apogée Consult à Lyon. Ingénieur diplômé de Polytech Lyon (Informatique), j'ai fait trois ans en apprentissage, partagés entre la Métropole de Lyon (inclusion numérique avec Res'in et sobriété énergétique avec Écolyo) et Superwyze, une startup medtech (POCs, dont certains aujourd'hui industrialisés, et travail sur des codebases existantes). J'ai livré plus de 10 projets en production (web, mobile et IA / RAG) pour des PME, startups et organisations publiques.
Dark mode sans clignotement sur Next.js App Router: la solution propre
- ui-ux
Le flash blanc au chargement du dark mode est un problème structurel de l'App Router. On explique pourquoi next-themes ne suffit pas et comment on l'a résolu proprement.
Dark mode sans clignotement sur Next.js App Router: la solution propre
On ajoute next-themes, on configure defaultTheme="system", on lance le projet. En mode dark, on voit pendant une fraction de seconde le fond blanc avant que le thème s'applique. Ce clignotement, le FOUC (Flash of Unstyled Content), est reproductible à chaque rechargement dur, et il est particulièrement visible sur les écrans à forte latence de rendu.
La cause est structurelle. La solution demande de comprendre pourquoi, pas juste de copier un snippet.
Pourquoi le FOUC se produit
Le serveur ne connaît pas la préférence de thème de l'utilisateur au moment du rendu HTML. Next.js génère le HTML côté serveur (ou le prébuild au build time) sans accès au localStorage ni au media query prefers-color-scheme du navigateur de l'utilisateur.
Le HTML arrive donc sans classe de thème. Le navigateur l'affiche avec les styles par défaut (fond clair). Puis JavaScript s'exécute, lit la préférence stockée, applique la classe dark sur le <html>. L'écran flashe.
next-themes résout ce problème dans les projets Pages Router en injectant un script inline dans _document.tsx qui s'exécute avant le premier paint. Dans l'App Router, ce mécanisme ne fonctionne pas de la même façon : les composants serveur ne permettent pas d'injecter du JavaScript arbitraire dans le <head> de manière synchrone.
Ce que next-themes fait (et ne fait pas) dans l'App Router
next-themes v0.3+ a ajouté un support de l'App Router via le composant ThemeProvider. Mais son script de détection anticipée est injecté via dangerouslySetInnerHTML dans un composant Client, qui s'exécute après l'hydratation, trop tard pour éviter le flash.
Le résultat : next-themes fonctionne pour la gestion du thème une fois la page chargée, mais ne supprime pas le FOUC initial sur les rechargements durs ou la première visite.
La solution : script bloquant dans le layout
La seule façon d'éviter le FOUC est d'exécuter du JavaScript avant le premier paint du navigateur. Cela signifie un script inline dans le <head>, sans defer ni async, qui lit la préférence et applique la classe immédiatement.
Dans l'App Router, on injecte ce script dans app/layout.tsx via le composant Script de Next.js avec strategy="beforeInteractive", ou directement en JSX avec dangerouslySetInnerHTML dans le <head> :
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored === 'dark' || stored === 'light' ? stored : (prefersDark ? 'dark' : 'light');
document.documentElement.classList.add(theme);
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
})();
`,
}}
/>
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}L'attribut suppressHydrationWarning sur <html> est nécessaire parce que la classe appliquée par le script sera différente de ce que le serveur a rendu. Sans lui, React affiche un warning d'hydratation.
Le script est enveloppé dans un try/catch pour ne pas bloquer le rendu si localStorage est inaccessible (mode privé sur certains navigateurs, CSP strict).
Gestion du thème avec CSS variables
La classe dark sur <html> permet de cibler les styles avec Tailwind (dark:bg-zinc-900) ou avec des sélecteurs CSS classiques. Mais pour un design system cohérent, on préfère passer par des CSS variables :
/* globals.css */
:root {
--background: #ffffff;
--foreground: #0a0a0a;
--primary: #044477;
--surface: #f5f5f5;
}
.dark {
--background: #0a0a0a;
--foreground: #ededed;
--primary: #3b82c4;
--surface: #1a1a1a;
}Cette approche découple le thème de Tailwind. Les composants utilisent bg-[var(--background)] ou des classes utilitaires définies sur les variables. Quand on change de thème, une seule classe change sur <html>, et toutes les variables se mettent à jour instantanément.
Coordination avec next-themes
On continue d'utiliser next-themes pour la gestion de l'état (toggle, persistance, écoute des changements système), mais on lui retire la responsabilité de l'initialisation :
// components/ThemeProvider.tsx
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
)
}disableTransitionOnChange évite l'animation de transition lors du changement de thème, ce qui peut provoquer un second flash si les transitions CSS sur background-color sont longues. On réactive les transitions uniquement sur les éléments qui en ont besoin, pas globalement.
Ce que cette approche ne couvre pas
Le script inline lit localStorage, pas prefers-color-scheme en priorité. Si un utilisateur n'a jamais visité le site et que son OS est en dark mode, le script doit vérifier le media query :
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored ?? (prefersDark ? 'dark' : 'light');Ce cas est géré dans le snippet ci-dessus, mais certaines implémentations l'oublient, ce qui donne un flash sur la première visite en dark mode système.
La vraie limite : sur des environnements avec une CSP (Content Security Policy) qui interdit les scripts inline sans nonce, cette approche nécessite un nonce dynamique généré côté serveur, ce qui complexifie la configuration. Est-ce qu'il existe une approche purement CSS (cookies, headers Sec-CH-Prefers-Color-Scheme) qui éviterait JavaScript entièrement ? Techniquement oui, mais le support navigateur est encore insuffisant en 2025.