Cambiar tema

Modo oscuro en Next.js: guía completa con next-themes

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

La primera vez que implementé modo oscuro en un proyecto Next.js, me llevé un buen susto. En el instante de carga, un flash blanco y luego el cambio a oscuro — ese parpadeo resultaba insoportable. Los usuarios comentaban que «casi se les quemaban los ojos», y ahí entendí lo grave que era.

Probé varias soluciones: código a mano, la librería use-dark-mode, montones de tutoriales. Al final, next-themes fue el salvavidas. Ahora lo uso en todos mis proyectos: cero parpadeo, configuración sencilla y seguimiento perfecto del tema del sistema. En este artículo comparto las trampas que pisé y cómo las resolví.

Por qué acabé eligiendo next-themes

Al principio dudé si merecía la pena escribir la lógica yo mismo. Leer localStorage y cambiar una class parece trivial. Pero con el SSR de Next.js, la cosa se complica mucho.

Probé varias opciones:

Solución manual: el mayor problema es el parpadeo. En SSR el servidor no conoce la preferencia del usuario y renderiza el tema claro por defecto. Solo en hydration el cliente lee localStorage y cambia a oscuro — ahí aparece el flash.

use-dark-mode: no está mal, pero no está pensada para Next.js y en SSR sigue dando problemas de compatibilidad.

theme-ui: muy potente, pero excesiva si solo quieres alternar modo oscuro; además el bundle es grande.

Al final encontré next-themes: más de 6000 stars en GitHub, diseñada para Next.js, cero dependencias y menos de 1 kb gzipped. Lo clave: cero parpadeo, soporte del tema del sistema listo para usar y persistencia automática. El soporte TypeScript también es excelente.

Pasos de implementación completos

Instalar dependencias

Primero, instala el paquete:

npm install next-themes

O con pnpm o yarn:

pnpm add next-themes
# o
yarn add next-themes

Crear el componente ThemeProvider

Crea un componente Provider. Suele ir en una carpeta providers o components.

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

Debe llevar 'use client', porque next-themes usa APIs del navegador. Fue mi primer error: sin esa directiva, un montón de errores de hydration.

Integrar en el Layout

Añade ThemeProvider al layout raíz. Con App Router (Next.js 13+), en app/layout.tsx:

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

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

Algunas opciones clave:

attribute="class": next-themes cambia el tema modificando la class de <html>. Encaja muy bien con el prefijo dark: de Tailwind CSS.

defaultTheme="system": por defecto sigue el tema del sistema. En la primera visita detecta la preferencia del SO.

enableSystem: activa la detección del tema del sistema. Sin esto, defaultTheme="system" no funciona.

disableTransitionOnChange: desactiva transiciones al cambiar. Puedes ajustarlo; yo lo dejo activo porque con animaciones todos los elementos se mueven a la vez y el efecto no me convence.

suppressHydrationWarning: va en <html> y es muy importante. next-themes modifica la class de html antes de hydration; sin este atributo, React mostrará avisos.

Crear el botón de cambio de tema

Con el Provider listo, crea el botón. Archivo 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="Cambiar tema"
    >
      {theme === 'dark' ? '🌞' : '🌙'}
    </button>
  )
}

Truco: devolver null hasta que el componente esté montado. En SSR no hay información del tema; renderizar antes provoca hydration mismatch. Tras mounted, useTheme devuelve el tema correcto.

Para un ciclo de tres estados (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>
  )
}

Análisis en profundidad del parpadeo

Lo que me empujó a investigar a fondo fue ese parpadeo molesto. Tardé un buen rato en entender bien la causa.

Cómo se produce el FOUC

El FOUC (Flash of Unstyled Content) es muy habitual al implementar modo oscuro en Next.js. La raíz está en la discrepancia entre SSR y el estado del cliente.

En SSR, Node.js no tiene window, ni localStorage, ni conoce el tema del sistema. El servidor solo puede renderizar un tema por defecto (normalmente claro).

El HTML llega al navegador y empieza la hydration: React convierte el HTML estático en componentes interactivos. Ahí JavaScript lee localStorage, ve que el usuario eligió oscuro y añade la class dark al DOM.

Ese cambio fuerza un re-render y los estilos pasan de claro a oscuro — de ahí el parpadeo.

La solución de next-themes

next-themes inyecta un script bloqueante en <head>. Se ejecuta antes del render, lee el tema en localStorage y aplica la class correspondiente a <html>.

La lógica es algo así:

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

Al ser síncrono y bloquear el render, la class correcta está lista antes de mostrar contenido. El CSS aplica desde el inicio y no hay parpadeo.

Errores de configuración habituales

Muchos fallan en estos puntos:

Olvidar suppressHydrationWarning:

Sin este atributo en <html>, la consola muestra:

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

No rompe la funcionalidad, pero resulta molesto.

ThemeProvider mal ubicado:

Ponerlo en un Server Component o fuera de body causa problemas. ThemeProvider debe envolver el contenido de la página y ser Client Component.

Configuración incorrecta de Tailwind:

Si tailwind.config.js tiene:

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

hay un problema. El modo media es solo CSS y sigue el sistema; no permite cambio manual. Debe ser:

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

Persistencia y seguimiento del sistema

Mecanismo de persistencia

next-themes guarda la elección en localStorage con la clave 'theme'. Es automático; no hace falta código extra.

Para personalizar la clave:

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

En algunos casos conviene cookies en lugar de localStorage — por ejemplo, si quieres conocer el tema en el servidor y evitar cualquier flash. Entonces:

  1. Lee la cookie en middleware y pásala en cabeceras
  2. Renderiza en servidor según esa cabecera
  3. Sincroniza cookie y localStorage en cliente

Para la mayoría de proyectos, el esquema por defecto de next-themes basta.

Seguimiento del tema del sistema

Con enableSystem, next-themes escucha cambios del tema del SO. Si el usuario cambia claro/oscuro en el sistema y tu app está en system, se actualiza sola.

Por debajo escucha la media query prefers-color-scheme:

window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    // lógica de cambio de tema
  })

El usuario también puede sobrescribir el sistema. Si el SO está en claro pero en tu sitio elige oscuro, next-themes lo recuerda en la próxima visita.

Soporte de múltiples temas

Aunque hablamos sobre todo de modo oscuro, next-themes admite tantos temas como quieras — púrpura, verde, etc.:

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

Y en CSS defines los estilos:

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

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

Muy flexible con variables CSS.

Trucos prácticos y problemas frecuentes

Con Tailwind CSS

Con Tailwind es aún más sencillo. En tailwind.config.js:

module.exports = {
  darkMode: 'class',
  // resto de config...
}

Luego usa el prefijo dark::

<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
  <h1 className="text-2xl font-bold">Título</h1>
  <p className="text-gray-600 dark:text-gray-400">Texto del párrafo</p>
</div>

La variante dark: de Tailwind actúa cuando <html> tiene la class dark, alineada con next-themes.

Animaciones y transiciones

Sobre disableTransitionOnChange, yo lo dejo activo. Si hay muchas propiedades transition en el CSS, al cambiar tema todo se anima a la vez y se ve desordenado.

Si quieres transición suave:

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

Y en CSS global:

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

Hay un efecto de fundido. Lo probé varias veces y prefiero el cambio instantáneo.

Soporte de tipos TypeScript

next-themes tiene buen soporte TypeScript. Para extender los tipos de tema:

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),
  }
}

Así tienes autocompletado y no asignas un tema inexistente.

Solución de problemas

Problema 1: el tema cambia pero los estilos no

Revisa:

  • Que darkMode en Tailwind sea 'class'
  • Que uses bien el prefijo dark: o el selector .dark
  • En la consola, que <html> tenga la class correcta

Problema 2: sigue parpadeando al recargar

Posibles causas:

  • Falta suppressHydrationWarning en <html>
  • ThemeProvider mal colocado
  • Otro script interfiere (p. ej. Google Analytics)

Problema 3: no sigue el tema del sistema

Comprueba:

  • enableSystem en true
  • Que el navegador soporte prefers-color-scheme (los modernos sí)
  • Que el tema actual sea system (si cambiaste a mano, puede ser light o dark)

Resumen

De estar atascado con el parpadeo a implementar modo oscuro sin fricción, next-themes marcó la diferencia. No solo resuelve lo técnico: mejora la experiencia del usuario.

Puntos clave:

  1. next-themes resuelve el parpadeo en modo oscuro de Next.js con poca configuración
  2. Añade suppressHydrationWarning en <html> y marca ThemeProvider como componente de cliente
  3. En Tailwind, darkMode: 'class'
  4. El botón de tema debe renderizarse tras mounted para evitar hydration mismatch
  5. Seguimiento del sistema y cambio manual pueden convivir

Si aún no has probado next-themes, merece la pena. La documentación oficial es clara: github.com/pacocoursey/next-themes

Implementa un modo oscuro fluido en tu proyecto Next.js. Tus usuarios te lo agradecerán.

Flujo completo para implementar modo oscuro en Next.js

Implementa modo oscuro sin parpadeo con next-themes, con seguimiento del tema del sistema y cambio manual

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Instalar next-themes

    Instalar la dependencia:
    • npm install next-themes

    O con otro gestor de paquetes:
    • pnpm add next-themes
    • yarn add next-themes

    Nota: next-themes no tiene dependencias y ocupa muy poco
  2. 2

    Step 2: Configurar ThemeProvider

    En el layout raíz:
    • Crear providers.tsx (marcar 'use client')
    • Envolver children con ThemeProvider
    • Importarlo en app/layout.tsx

    Configuración clave:
    • attribute="class": cambiar tema con class
    • enableSystem: seguir el tema del sistema
    • storageKey: clave en localStorage

    Nota: ThemeProvider debe ser un componente de cliente
  3. 3

    Step 3: Configurar Tailwind CSS

    En tailwind.config.js:
    • darkMode: 'class'
    • Tailwind cambia el tema según la class del html

    Ejemplo:
    module.exports = {
    darkMode: 'class',
    // ... resto de config
    }

    Estilos oscuros con el prefijo dark:
    className="bg-white dark:bg-gray-900"
  4. 4

    Step 4: Corregir avisos de hydration

    En la etiqueta html:
    • atributo suppressHydrationWarning
    • evita avisos por discrepancia servidor/cliente

    En layout.tsx:
    <html lang="zh" suppressHydrationWarning>
    <body>{children}</body>
    </html>

    Así evitas los avisos de hydration de Next.js
  5. 5

    Step 5: Crear botón de cambio de tema

    Con el hook useTheme:
    • Crear ThemeToggle ('use client')
    • useTheme() para theme y setTheme
    • Renderizar tras mounted para evitar desajustes

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

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

    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
    Cambiar tema
    </button>
  6. 6

    Step 6: Probar y validar

    Qué probar:
    • Cambio manual sin parpadeo
    • Seguimiento del tema del sistema
    • Tema persistente tras recargar
    • Mismo tema en todas las páginas

    Checklist:
    • Carga sin flash
    • Cambio fluido
    • localStorage correcto
    • Sigue el tema del sistema al cambiar

FAQ

¿Por qué parpadea al cargar la página?
En SSR el servidor no conoce la preferencia del usuario y renderiza el tema por defecto. Solo en hydration el cliente lee localStorage y cambia el tema, lo que provoca el flash. next-themes inyecta un script antes del render para leer el tema y elimina el problema.
¿En qué se diferencia next-themes de otras librerías de temas?
next-themes está pensado para Next.js, resuelve el parpadeo en SSR, no tiene dependencias y pesa menos de 1 kb. use-dark-mode no está orientado a Next.js y da problemas en SSR. theme-ui es potente pero pesado si solo necesitas alternar modo oscuro.
¿Cómo seguir el tema del sistema?
En ThemeProvider pon enableSystem={true} y next-themes detecta y aplica la preferencia del sistema. El usuario también puede cambiar manualmente; eso sobrescribe el sistema. Tres modos: light, dark y system.
¿Por qué hace falta suppressHydrationWarning?
En SSR no se conoce la preferencia y se renderiza el tema por defecto; en hydration el cliente aplica el de localStorage y el HTML difiere. suppressHydrationWarning indica a React que es esperado y evita el aviso.
¿Por qué el botón debe renderizarse tras mounted?
Para evitar desajustes de hydration. En SSR no se puede leer localStorage; si renderizas el botón directo, el HTML servidor/cliente no coincide y React falla en hydration. Esperar a mounted garantiza render solo en cliente.
¿Cómo personalizar la lógica de cambio de tema?
Con setTheme de useTheme puedes definir la lógica. Por ejemplo: setTheme(theme === 'dark' ? 'light' : 'dark'). También puedes fijar un tema: setTheme('dark'), setTheme('light') o setTheme('system').
¿Qué temas soporta next-themes?
Por defecto light y dark. Con la prop themes de ThemeProvider puedes añadir más, por ejemplo: themes={['light', 'dark', 'blue', 'green']}. Cada tema corresponde a una class CSS distinta.

9 min de lectura · Publicado el: 20 dic 2025 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog