Cómo solucionar "Text content does not match server-rendered HTML" en Next.js App Router
Este error ocurre cuando el HTML generado en el servidor (server-side rendering) no coincide con el árbol de React generado durante la primera renderización en el navegador (client-side hydration). React detecta esta inconsistencia y lanza una advertencia crítica porque puede romper la interactividad y causar comportamientos impredecibles.
Causa raíz
La mayoría de los casos se deben a:
- Uso de APIs del navegador (
window,localStorage,Date.now(), etc.) directamente en el renderizado. - Lógica condicional basada en
typeof window !== 'undefined'dentro del cuerpo del componente (no dentro deuseEffect). - Elementos HTML anidados inválidos según el estándar (ej.
<p>dentro de otro<p>,<button>dentro de<button>). - Extensiones del navegador que modifican el DOM antes de la hidratación.
- Metaetiqueta de detección automática de iOS (
format-detection) que inyecta<a>dinámicos en números de teléfono o fechas.
Solución definitiva (pasos verificados)
✅ Paso 1: Aisla la fuente de la inconsistencia
Busca en tu código:
- Uso directo de
window,localStorage,navigator,Date(),Math.random(), etc. en el cuerpo del componente. - Lógica condicional como
if (typeof window !== 'undefined') { ... }fuera de hooks.
Ejemplo problemático:
// ❌ INCORRECTO: Se ejecuta en SSR y en CSR con resultados distintos
function Timestamp() {
const now = new Date(); // ❌ Diferente en SSR vs CSR
return <time>{now.toLocaleString()}</time>;
}
✅ Paso 2: Aplica la solución según el caso
🔹 Caso A: Contenido dinámico (fechas, IDs aleatorios, etc.)
Usa suppressHydrationWarning en el elemento problemático:
// ✅ CORRECTO: Advertencia suprimida solo en el elemento afectado
<time suppressHydrationWarning>{new Date().toLocaleString()}</time>
⚠️ Importante: Solo aplica a elementos textuales y de un solo nivel de profundidad.
🔹 Caso B: Lógica que requiere el navegador (ej. localStorage, window.innerWidth)
Usa useEffect para postergar la lógica al cliente:
// ✅ CORRECTO: Renderiza lo mismo en SSR y CSR inicialmente
import { useState, useEffect } from 'react';
function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('light'); // Valor por defecto seguro
useEffect(() => {
const stored = localStorage.getItem('theme');
if (stored === 'dark' || stored === 'light') {
setTheme(stored);
}
}, []);
return <button className={theme}>Toggle</button>;
}
🔹 Caso C: Componente totalmente client-side (ej. canvas, mapas)
Desactiva SSR con dynamic y ssr: false:
// pages/page.tsx
import dynamic from 'next/dynamic';
const InteractiveMap = dynamic(() => import('../components/Map'), {
ssr: false,
loading: () => <p>Cargando mapa...</p>
});
export default function Page() {
return <InteractiveMap />;
}
✅ Paso 3: Verifica estructura HTML válida
Revisa que no haya:
-
<p>anidados (ej.<p><div>...</div></p>→ inválido). -
<button>dentro de<a>, o viceversa. -
<ul>/<ol>dentro de<p>. -
<a>dentro de<a>.
Ejemplo corregido:
// ❌ INCORRECTO
<p>
<a href="/home">Home</a> | <a href="/about">About</a>
</p>
// ✅ CORRECTO (usar <span> o fragmento)
<span>
<a href="/home">Home</a> | <a href="/about">About</a>
</span>
✅ Paso 4: Deshabilita detección automática en iOS (si aplica)
Agrega esta metaetiqueta en <head> de tu layout:
// 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>
);
}
Pro-tip: Diagnóstico rápido
- Activa React DevTools → Selecciona la pestaña "Components" y busca el ícono de advertencia (⚠️) en el árbol.
-
Busca en consola: El error muestra la ruta exacta del componente afectado (ej.
at div (app/page.tsx:15:10)). - Prueba en modo incógnito: Si desaparece el error, es una extensión del navegador (común: Grammarly, Dark Reader, ad blockers).
Código corregido (ejemplo completo)
// app/page.tsx
import { useState, useEffect } from 'react';
function ClientOnlyContent() {
const [data, setData] = useState<string>('Cargando...');
useEffect(() => {
// ✅ Solo se ejecuta en cliente
const timer = setTimeout(() => {
setData('Contenido generado en el cliente');
}, 100);
return () => clearTimeout(timer);
}, []);
return <p>{data}</p>;
}
export default function Page() {
return (
<main>
<h1>App Router</h1>
<ClientOnlyContent />
{/* ✅ Supresión segura para fechas dinámicas */}
<p>Última actualización: <time suppressHydrationWarning>{new Date().toLocaleDateString()}</time></p>
</main>
);
}
✅ Validación final: Ejecuta
next buildy revisa los logs. Si no hay errores de hidratación, ¡solucionado.
Top comments (0)