Changer le thème

Mode sombre Next.js : guide complet next-themes

Easton editorial illustration: one split light-and-dark browser card with a central theme toggle

La première fois que j’ai ajouté un mode sombre dans un projet Next.js, ça s’est mal passé. Au chargement, un flash blanc avant le passage au sombre — des utilisateurs disaient que ça leur « brûlait les yeux ». Là j’ai compris à quel point c’était grave.

J’ai testé plusieurs approches : code maison, use-dark-mode, plein de tutos. Au final next-themes a tout changé : zéro scintillement, config simple, suivi du thème système nickel. Cet article reprend les pièges que j’ai pris et la solution qui tient la route.

Pourquoi j’ai choisi next-themes

Au début je me demandais si un petit script localStorage + class suffisait. En pratique, avec le SSR Next.js, c’est vite le bazar.

Solution maison : le flash. Le serveur ne connaît pas la préférence, rend souvent le clair, puis le client lit localStorage à l’hydratation — bascule visible.

use-dark-mode : correct, mais pas pensé pour Next.js ; des frictions en SSR.

theme-ui : très complet, trop lourd si vous voulez juste clair/sombre, bundle plus gros.

next-themes cumule 6000+ stars sur GitHub, zéro dépendance, moins de 1 ko gzippé, zéro flash, thème système et persistance auto. Le typage TypeScript est propre.

Étapes d’implémentation

Installer les dépendances

npm install next-themes

Ou avec pnpm / yarn :

pnpm add next-themes
# ou
yarn add next-themes

Créer le composant ThemeProvider

Je place souvent ce genre de provider dans providers ou components.

Fichier providers/theme-provider.tsx :

'use client'

import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

Obligatoire : 'use client', car next-themes utilise les API navigateur. Sans ça, j’avais des erreurs d’hydratation partout.

Intégrer dans le layout

Avec l’App Router (Next.js 13+), dans app/layout.tsx :

import { ThemeProvider } from '@/providers/theme-provider'
import './globals.css'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="fr" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

Détail des options :

attribute="class" : next-themes modifie la class de <html>, idéal avec les préfixes dark: de Tailwind.

defaultTheme="system" : première visite = préférence OS.

enableSystem : indispensable pour que defaultTheme="system" fonctionne.

disableTransitionOnChange : coupe les transitions au changement de thème ; sinon tout le DOM s’anime en même temps, peu lisible. À ajuster selon le design.

suppressHydrationWarning sur <html> : next-themes peut changer la class avant l’hydratation ; sans cet attribut, React warn.

Bouton de bascule de thème

components/theme-toggle.tsx :

'use client'

import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'

export function ThemeToggle() {
  const [mounted, setMounted] = useState(false)
  const { theme, setTheme } = useTheme()

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) {
    return null
  }

  return (
    <button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
      aria-label="Changer de thème"
    >
      {theme === 'dark' ? '🌞' : '🌙'}
    </button>
  )
}

Astuce : retourner null avant mounted. En SSR on n’a pas le thème ; un rendu immédiat provoque un mismatch. Après montage, useTheme est fiable.

Bascule à trois états (light / dark / system) :

export function ThemeToggle() {
  const [mounted, setMounted] = useState(false)
  const { theme, setTheme } = useTheme()

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) return null

  const cycleTheme = () => {
    if (theme === 'light') setTheme('dark')
    else if (theme === 'dark') setTheme('system')
    else setTheme('light')
  }

  const getIcon = () => {
    if (theme === 'light') return '🌞'
    if (theme === 'dark') return '🌙'
    return '💻'
  }

  return (
    <button
      onClick={cycleTheme}
      className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
    >
      {getIcon()}
    </button>
  )
}

Comprendre le scintillement

C’est ce flash qui m’a poussé à creuser le sujet.

D’où vient le FOUC

Le FOUC (Flash of Unstyled Content) est fréquent avec le mode sombre en Next.js : décalage SSR / client.

En SSR, pas de window, pas de localStorage, pas de prefers-color-scheme côté Node — le serveur rend souvent le thème clair par défaut.

Le HTML arrive, React hydrate, le JS lit localStorage, applique dark sur <html>, les styles basculent — le flash.

Comment next-themes le corrige

Un script bloquant dans <head> s’exécute avant le paint : lit localStorage, détecte le système, pose la bonne class sur <html>.

Logique simplifiée :

(function() {
  try {
    const theme = localStorage.getItem('theme')
    const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
    const currentTheme = theme || systemTheme
    
    if (currentTheme === 'dark') {
      document.documentElement.classList.add('dark')
    }
  } catch (e) {}
})()

Script synchrone → la class est bonne avant l’affichage → pas de flash.

Erreurs de configuration courantes

Oublier suppressHydrationWarning :

Warning: Prop `className` did not match. Server: "" Client: "dark"

Ça marche souvent quand même, mais le warning pollue la console.

Mauvais emplacement de ThemeProvider :

Dans un Server Component, ou hors du body : problèmes. Il doit envelopper le contenu en Client Component.

Mauvaise config Tailwind :

module.exports = {
  darkMode: 'media',
}

media suit seulement l’OS, pas la bascule manuelle. Préférez :

module.exports = {
  darkMode: 'class',
}

Persistance et thème système

Persistance

Par défaut next-themes écrit dans localStorage sous la clé 'theme', sans code supplémentaire.

Clé personnalisée :

<ThemeProvider
  attribute="class"
  defaultTheme="system"
  enableSystem
  storageKey="my-theme"
>
  {children}
</ThemeProvider>

Parfois on veut des cookies pour connaître le thème côté serveur : middleware + en-têtes + rendu SSR + sync client. Pour la plupart des apps, le défaut suffit.

Suivi du thème système

Avec enableSystem, next-themes écoute les changements OS quand le thème actif est system.

Sous le capot, prefers-color-scheme :

window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    // logique de bascule
  })

L’utilisateur peut forcer dark alors que l’OS est en clair : le choix est mémorisé pour les visites suivantes.

Thèmes multiples

Au-delà de clair/sombre :

<ThemeProvider
  attribute="class"
  defaultTheme="system"
  enableSystem
  themes={['light', 'dark', 'purple', 'green']}
>
  {children}
</ThemeProvider>

CSS associé :

.purple {
  --background: #f3e8ff;
  --foreground: #581c87;
}

.green {
  --background: #dcfce7;
  --foreground: #14532d;
}

Très flexible avec des variables CSS.

Astuces et dépannage

Avec Tailwind CSS

module.exports = {
  darkMode: 'class',
  // autres options...
}

Puis les préfixes dark: :

<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
  <h1 className="text-2xl font-bold">Titre</h1>
  <p className="text-gray-600 dark:text-gray-400">Paragraphe</p>
</div>

dark: s’active quand <html> a la class dark — aligné avec next-themes.

Animations et transitions

Je garde souvent disableTransitionOnChange activé : trop de transition en CSS = tout le layout qui bouge au switch.

Pour un fondu :

<ThemeProvider
  attribute="class"
  defaultTheme="system"
  enableSystem
  disableTransitionOnChange={false}
>
  {children}
</ThemeProvider>

Et en global :

* {
  transition: background-color 0.2s ease, color 0.2s ease;
}

À vous de voir ; sans transition c’est souvent plus net.

TypeScript

Extension des thèmes :

import { useTheme } from 'next-themes'

type Theme = 'light' | 'dark' | 'purple'

export function useCustomTheme() {
  const { theme, setTheme } = useTheme()
  
  return {
    theme: theme as Theme,
    setTheme: (theme: Theme) => setTheme(theme),
  }
}

Autocomplétion et garde-fous sur les valeurs.

Dépannage

Le thème change mais pas les styles

  • darkMode: 'class' dans Tailwind ?
  • dark: ou sélecteur .dark correct ?
  • La class sur <html> dans les DevTools ?

Flash au rechargement

  • suppressHydrationWarning sur <html> ?
  • ThemeProvider bien placé ?
  • Autre script qui touche le DOM trop tôt (analytics, etc.) ?

Le thème système ne suit pas

  • enableSystem à true ?
  • Navigateur avec prefers-color-scheme ?
  • Thème courant = system (après bascule manuelle ce peut être light ou dark)

Synthèse

Du flash agaçant à une bascule fluide : next-themes a simplifié la vie — technique et UX.

Points clés :

  1. next-themes règle le flash du mode sombre Next.js avec peu de config
  2. suppressHydrationWarning sur <html>, ThemeProvider en client
  3. Tailwind : darkMode: 'class'
  4. Bouton de thème après mounted
  5. Système et bascule manuelle cohabitent

Pas encore testé ? Doc claire : github.com/pacocoursey/next-themes

Ajoutez un mode sombre fluide à votre projet Next.js — vos utilisateurs vous remercieront.

Implémentation complète du mode sombre Next.js

Mode sombre sans scintillement avec next-themes, suivi système et bascule manuelle

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Installer next-themes

    Installation :
    • npm install next-themes

    Autres gestionnaires :
    • pnpm add next-themes
    • yarn add next-themes

    Note : bibliothèque sans dépendance, très légère
  2. 2

    Step 2: Configurer ThemeProvider

    Dans la mise en page racine :
    • Créer providers.tsx (marqué 'use client')
    • Envelopper children avec ThemeProvider
    • Importer dans app/layout.tsx

    Options clés :
    • attribute="class" : bascule par classe
    • enableSystem : suivi du thème système
    • storageKey : clé localStorage

    ThemeProvider doit être un composant client
  3. 3

    Step 3: Configurer Tailwind CSS

    Dans tailwind.config.js :
    • darkMode: 'class'
    • Tailwind suit la classe sur la balise html

    Exemple :
    module.exports = {
    darkMode: 'class',
    // ... autres options
    }

    Styles sombres avec le préfixe dark: :
    className="bg-white dark:bg-gray-900"
  4. 4

    Step 4: Corriger l'avertissement d'hydratation

    Sur la balise html :
    • attribut suppressHydrationWarning
    • évite l'avertissement si serveur et client diffèrent

    Dans layout.tsx :
    <html lang="fr" suppressHydrationWarning>
    <body>{children}</body>
    </html>
  5. 5

    Step 5: Créer le bouton de thème

    Avec le hook useTheme :
    • composant ThemeToggle ('use client')
    • useTheme() pour theme et setTheme
    • attendre mounted avant le rendu

    Exemple :
    const { theme, setTheme } = useTheme()
    const [mounted, setMounted] = useState(false)

    useEffect(() => setMounted(true), [])
    if (!mounted) return null

    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
    Changer de thème
    </button>
  6. 6

    Step 6: Tester et valider

    Points de test :
    • bascule manuelle sans scintillement
    • suivi du thème système
    • persistance après rechargement
    • cohérence entre les pages

    Checklist :
    • chargement sans flash
    • transition fluide
    • localStorage correct
    • suivi automatique du thème OS

FAQ

Pourquoi la page scintille au chargement ?
En SSR le serveur ignore la préférence utilisateur et rend le thème par défaut ; à l'hydratation le client lit localStorage et bascule — d'où le flash. next-themes injecte un script avant le rendu pour appliquer le thème tôt et supprimer ce problème.
next-themes vs autres bibliothèques de thème ?
Conçu pour Next.js, il résout le flash SSR, zéro dépendance, volume < 1 ko. use-dark-mode n'est pas pensé pour Next.js et pose des soucis en SSR. theme-ui est puissant mais lourd pour une simple bascule clair/sombre.
Comment suivre le thème système ?
enableSystem={true} sur ThemeProvider : détection automatique. L'utilisateur peut aussi forcer light/dark ; la bascule manuelle prime. Trois modes : light, dark, system.
Pourquoi suppressHydrationWarning ?
Le serveur rend le thème par défaut, le client applique celui du localStorage : HTML différent. suppressHydrationWarning indique à React que l'écart est attendu.
Pourquoi attendre mounted pour le bouton ?
Évite le mismatch d'hydratation : le serveur ne connaît pas localStorage. Rendre le bouton seulement côté client après mounted garantit la cohérence.
Personnaliser la logique de bascule ?
setTheme du hook useTheme : par ex. setTheme(theme === 'dark' ? 'light' : 'dark'). Ou setTheme('dark'), setTheme('light'), setTheme('system').
Quels thèmes next-themes supporte-t-il ?
Par défaut light et dark. La prop themes du ThemeProvider permet d'en ajouter, ex. themes={['light', 'dark', 'blue', 'green']} — chaque thème correspond à une classe CSS.

6 min de lecture · Publié le: 20 déc. 2025 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog