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

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:
- Lee la cookie en middleware y pásala en cabeceras
- Renderiza en servidor según esa cabecera
- 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
darkModeen 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
suppressHydrationWarningen<html> - ThemeProvider mal colocado
- Otro script interfiere (p. ej. Google Analytics)
Problema 3: no sigue el tema del sistema
Comprueba:
enableSystementrue- Que el navegador soporte
prefers-color-scheme(los modernos sí) - Que el tema actual sea
system(si cambiaste a mano, puede serlightodark)
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:
next-themesresuelve el parpadeo en modo oscuro de Next.js con poca configuración- Añade
suppressHydrationWarningen<html>y marca ThemeProvider como componente de cliente - En Tailwind,
darkMode: 'class' - El botón de tema debe renderizarse tras
mountedpara evitar hydration mismatch - 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
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
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
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
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
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
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 qué se diferencia next-themes de otras librerías de temas?
¿Cómo seguir el tema del sistema?
¿Por qué hace falta suppressHydrationWarning?
¿Por qué el botón debe renderizarse tras mounted?
¿Cómo personalizar la lógica de cambio de tema?
¿Qué temas soporta next-themes?
9 min de lectura · Publicado el: 20 dic 2025 · Actualizado el: 21 ago 2026
Guía completa de Next.js
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Guía completa de monitorización en producción con Next.js: integración de Sentry, gestión de logs y alertas
Te guiamos paso a paso para montar un sistema de monitorización en producción con Next.js: integración de Sentry, gestión de logs, monitorización de rendimiento y alertas. Configuración práctica con App Router y plantillas de código listas para usar
Parte 43 de 51
Siguiente
Next.js + Tailwind CSS: mejores prácticas — guía completa de configuración a modo oscuro (edición 2025)
Guía práctica 2025 de Next.js + Tailwind CSS v4: soluciona clases demasiado largas, configuración de temas personalizados, modo oscuro y optimización de rendimiento. Caso real de reducción de 500 KB a 50 KB con ejemplos de código completos.
Parte 45 de 51



Comentarios
Inicia sesión con GitHub para dejar un comentario