Cómo solucionar el error "Text content does not match server-rendered HTML" en Next.js (App Router)
Este error ocurre cuando el HTML generado en el servidor (SSR) no coincide con el árbol de React generado durante la primera renderización del cliente. React detecta esta inconsistencia durante el proceso de hydration y lanza una advertencia crítica que puede romper la interactividad o causar comportamientos impredecibles.
Causa raíz
La inconsistencia proviene de código que se ejecuta de forma diferente en el servidor y en el cliente, como:
- Uso de APIs del navegador (
window,localStorage,Date.now(), etc.) directamente en el renderizado. - Lógica condicional basada en
typeof window !== 'undefined'. - Elementos que cambian dinámicamente (fechas, IDs aleatorios, estado inicial no determinista).
- Extensiones del navegador que modifican el DOM (como traductores o bloqueadores de anuncios).
- Metaetiquetas de detección automática en iOS (
format-detection) que inyectan<a>tags en tiempo real.
⚠️ Importante: El error no siempre es visible en desarrollo, pero sí en producción (donde SSR está activo).
Solución definitiva (pasos verificados)
✅ Paso 1: Identifica el elemento problemático
Busca en tu código:
-
Date(),new Date(),Math.random(),localStorage.getItem(...),window.innerWidth, etc. - Uso de
useEffectdentro de componentes que también se renderizan en el servidor sin protección. - Contenido dinámico en
<p>,<time>,<span>, etc., que dependa de estado no inicializado.
🔍 Usa el React DevTools o busca en el HTML fuente (View Source) para comparar con el DOM renderizado en el navegador.
✅ Paso 2: Aplica la solución más adecuada (elige una)
🔹 Opción A: Usa suppressHydrationWarning (para contenido intencionalmente variable)
// Ejemplo: fecha/hora actual
<time datetime={new Date().toISOString()} suppressHydrationWarning>
{new Date().toLocaleString()}
</time>
// O: contenido generado dinámicamente (ej. UUID)
<span suppressHydrationWarning>{crypto.randomUUID()}</span>
⚠️ Solo usa esto si el contenido debe cambiar entre SSR y CSR. No es una solución general.
🔹 Opción B: Usa useEffect para diferir la renderización client-side
'use client'
import { useState, useEffect } from 'react'
export default function DynamicDate() {
const [date, setDate] = useState<string>('Cargando...')
useEffect(() => {
setDate(new Date().toLocaleString())
}, [])
return <time>{date}</time>
}
✅ Ventaja: Mantiene SSR para el contenido estático, y solo reemplaza lo dinámico después de hydration.
🔹 Opción C: Deshabilita SSR para componentes problemáticos
// components/DynamicComponent.tsx
'use client'
export default function DynamicComponent() {
const [width, setWidth] = useState(0)
useEffect(() => {
setWidth(window.innerWidth)
}, [])
return <div>Ancho: {width}px</div>
}
// page.tsx
import dynamic from 'next/dynamic'
const DynamicComponent = dynamic(() => import('../components/DynamicComponent'), {
ssr: false,
})
export default function Page() {
return (
<main>
<h1>Contenido SSR</h1>
<DynamicComponent />
</main>
)
}
✅ Ideal para componentes totalmente dependientes del cliente (ej. gráficos con Canvas, reproductores de video).
✅ Paso 3: Solución específica para iOS (frecuente causa oculta)
Agrega esta metaetiqueta en tu <head> (en app/layout.tsx):
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="es">
<head>
<meta
name="format-detection"
content="telephone=no, date=no, email=no, address=no"
/>
</head>
<body>{children}</body>
</html>
)
}
📱 iOS inyecta automáticamente
<a href="tel:...">en números de teléfono, causando mismatches silenciosos.
Pro-tip: Prevención activa
- Nunca uses APIs del navegador en el renderizado directo:
// ❌ MAL
const Component = () => <span>{window.innerWidth > 768 ? 'Desktop' : 'Mobile'}</span>
// ✅ BIEN
const Component = () => {
const [isDesktop, setIsDesktop] = useState(false)
useEffect(() => {
setIsDesktop(window.innerWidth > 768)
}, [])
return <span>{isDesktop ? 'Desktop' : 'Mobile'}</span>
}
-
Valida tu HTML con
next deven modo producción:
npm run build && npm run start
-
Revisa configuraciones de CDN/Edge:
- Cloudflare Auto Minify → desactívalo temporalmente.
- Vercel Edge Config → evita inyección de scripts en HTML.
Usa
console.erroren lugar dethrowenuseEffectpara evitar interrupciones de hydration.
✅ Resultado esperado: El error desaparece, el hydration se completa sin advertencias, y tu app es 100% funcional en SSR/CSR.
Top comments (0)