Apogée Consult
Retour au blog
Mathieu Ponton
Mathieu PontonCo-Founder & ingénieur logiciel

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.

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert
Dark mode Next.js sans flash FOUC, solution App Router | Apogée Consult