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/SSG) no coincide con el árbol de React que se construye durante la primera renderización en el navegador (hydration). Es un problema crítico de consistencia de estado que rompe la experiencia de usuario y puede causar comportamientos impredecibles.
🔍 Causa raíz (diagnóstico técnico)
En tu caso, el error está relacionado con contenido dinámico que varía entre renderizado del servidor y renderizado del cliente, probablemente causado por:
- Uso de
Date(),Math.random(),localStorage,window, o APIs del navegador directamente en el render. - Uso de
typeof window !== 'undefined'como condición de renderizado (no es idempotente entre SSR y CSR). - Metaetiquetas de detección automática de iOS (
format-detection) que inyectan nodos<a>en tiempo de ejecución. - Extensiones del navegador (especialmente en desarrollo) que modifican el DOM.
- Librerías CSS-in-JS mal configuradas que inyectan clases o estilos dinámicos en CSR.
⚠️ Nota crítica: Next.js App Router no permite el uso de useEffect para evitar el mismatch en el primer render — el mismatch debe prevenirse, no suprimirse.
✅ Solución definitiva (pasos verificados)
Paso 1: Elimina toda lógica no determinista del render
NUNCA uses lo siguiente directamente en el cuerpo del componente:
// ❌ Evitar
const now = new Date(); // ❌
const isClient = typeof window !== 'undefined'; // ❌
const randomId = Math.random(); // ❌
const theme = localStorage.getItem('theme'); // ❌
✅ Reemplaza con:
// ✅ Usar `useEffect` para *actualizar* el estado, no para *determinar* el render inicial
import { useState, useEffect } from 'react';
export default function Component() {
const [time, setTime] = useState<string>(''); // Inicializa con valor seguro (ej. string vacío o placeholder)
useEffect(() => {
setTime(new Date().toISOString());
}, []);
return <time dateTime={time || ''} suppressHydrationWarning>{time || 'Cargando...'}</time>;
}
✅ Clave: El servidor y el cliente renderizan exactamente lo mismo en el primer render (ej.
''o'Cargando...'). El cambio ocurre después de hydration, cuando React ya ha montado el componente.
Paso 2: Deshabilita la detección automática de iOS (si aplica)
Agrega esta metaetiqueta en <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>
);
}
✅ Esto evita que iOS inyecte
<a>tags alrededor de fechas/números, causando mismatches silenciosos.
Paso 3: Usa suppressHydrationWarning solo como escape hatch
Solo si el contenido debe cambiar (ej. reloj en tiempo real, UUIDs):
// ✅ Solo para contenido *intencionalmente* no determinista
<div suppressHydrationWarning>
{typeof window !== 'undefined' ? window.innerWidth : 0}
</div>
⚠️ Restricciones:
- Solo funciona en el nodo inmediato (no en hijos anidados).
- React ignora por completo mismatches en ese subárbol.
- Nunca lo uses en elementos interactivos (
<button>,<a>, etc.).
Paso 4: Verifica configuraciones de Edge/CDN
Si usas Cloudflare, Vercel Edge Functions, o Akamai:
- Desactiva Auto Minify (Cloudflare) o HTML Minification (Vercel).
- Asegúrate de que el servidor no modifique el HTML (ej. middlewares que inyecten scripts o cookies).
🧪 Diagnóstico rápido (checklist)
| Síntoma | Causa probable | Solución |
|---|---|---|
Expected server HTML ... does not match ... |
Uso de Date() en render |
Inicializa con '' + useEffect
|
Mismatch en <p> o <a> anidados |
iOS inyecta <a> en fechas/teléfonos |
Metaetiqueta format-detection
|
| Error solo en desarrollo | Extensiones (ej. React DevTools, Dark Mode) | Prueba en incógnito |
| Error en producción | CDN/Edge modificando HTML | Desactivar minificación HTML |
💡 Pro-tip: Prueba de "hydration safety"
Antes de desplegar, ejecuta este script en consola en producción (sin React DevTools):
document.body.innerHTML = document.body.innerHTML;
Si no hay errores en consola, tu render es hydration-safe.
Si sí hay errores, busca el primer console.error con Text content does not match... — ahí está tu fuente.
📦 Código corregido (ejemplo real)
// app/page.tsx
import { useState, useEffect } from 'react';
export default function Page() {
const [version, setVersion] = useState<string>('Cargando...');
useEffect(() => {
// Solo se ejecuta en cliente → no causa mismatch
setVersion('16.3.2'); // o fetch('.../version')
}, []);
return (
<div>
<h1>Latest Version</h1>
<span suppressHydrationWarning>{version}</span>
</div>
);
}
✅ Resultado:
- Servidor renderiza
'Cargando...' - Cliente renderiza
'Cargando...'→ luego actualiza a'16.3.2' - Zero mismatch.
🔥 Consejo final: Si el error persiste, usa
console.log('SERVER:', process.env.NEXT_PUBLIC_VERCEL_ENV)para verificar si el código se ejecuta en SSR. Si lo hace, nada de lógica del navegador debe estar ahí.
Top comments (0)