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 genera durante la primera renderización en el cliente (hydration). React detecta una inconsistencia crítica y detiene la hidratación para evitar comportamientos impredecibles.
Causa raíz
En tu caso, el problema no es un error de lógica de renderizado obvio (como <p> anidado en otro <p>), sino una diferencia temporal o contextual entre el servidor y el cliente. Las causas más probables:
- Uso de
Date.now(),new Date(), oMath.random()en el renderizado. - Acceso directo a
window,localStorage,navigator, etc. durante la renderización. - Uso de
typeof window !== 'undefined'dentro del cuerpo del componente (no dentro deuseEffectouseMemo). - Metaetiqueta
format-detectionde iOS activa → convierte números de teléfono en<a>→ rompe el DOM. - Extensiones del navegador (ej. traductores, dark mode) modificando el DOM antes de la hidratación.
- Configuración de CDN (Cloudflare Auto Minify, etc.) que altera el HTML prerenderizado.
Solución definitiva (pasos verificados)
✅ Paso 1: Busca y elimina dependencias de tiempo o contexto del renderizado
❌ Mal:
// ❌ ¡Esto rompe la hidratación!
export default function Page() {
const now = new Date(); // ❌ El servidor y el cliente tienen hora distinta
return <p>Última actualización: {now.toLocaleString()}</p>;
}
✅ Bien (opción 1: renderiza placeholder en SSR, luego actualiza):
import { useState, useEffect } from 'react';
export default function Page() {
const [now, setNow] = useState<string>('Cargando...');
useEffect(() => {
setNow(new Date().toLocaleString());
}, []);
return <p>Última actualización: {now}</p>;
}
✅ Bien (opción 2: usa suppressHydrationWarning solo si es inevitable):
export default function Page() {
return (
<p>
Última actualización:
<time
suppressHydrationWarning
dateTime={new Date().toISOString()}
>
{new Date().toLocaleString()}
</time>
</p>
);
}
⚠️ Pro-tip:
suppressHydrationWarningsolo debe usarse en elementos específicos, nunca en el<body>o<html>. React ignora solo ese nodo durante la comparación.
✅ Paso 2: Deshabilita detección automática de iOS (¡frecuente causa silenciosa!)
Agrega esta metaetiqueta en <head> de app/layout.tsx (o app/layout.tsx si usas App Router):
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="es">
<head>
{/* 👇 SOLUCIÓN CLAVE PARA iOS */}
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
</head>
<body>{children}</body>
</html>
);
}
🔍 ¿Por qué? iOS convierte automáticamente números como
1-800-555-0199en<a href="tel:...">. Esto modifica el DOM prerenderizado → mismatch.
✅ Paso 3: Evita APIs del navegador en render
❌ Mal:
export default function Page() {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; // ❌
return <div>{isMobile ? 'Móvil' : 'Escritorio'}</div>;
}
✅ Bien (con useEffect + estado):
import { useState, useEffect } from 'react';
export default function Page() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
return <div>{isMobile ? 'Móvil' : 'Escritorio'}</div>;
}
✅ Alternativa (recomendada en App Router): usa headers().get('User-Agent') en el servidor:
// app/page.tsx (Server Component)
import { headers } from 'next/headers';
export default function Page() {
const headersList = headers();
const ua = headersList.get('user-agent') || '';
const isMobile = /Mobi|Android/i.test(ua);
return <div>{isMobile ? 'Móvil' : 'Escritorio'}</div>;
}
✅ Paso 4: Verifica configuración de CDN
Si usas Cloudflare, Vercel Edge Config, o cualquier proxy intermedio:
- Cloudflare: Desactiva Auto Minify (HTML) y Rocket Loader.
-
Vercel: Asegúrate de no tener
vercel devcon--turbo(puede alterar SSR). -
Otros: Busca reglas que modifiquen
Content-Type: text/html(ej. reglas de reescritura, optimización de imágenes en HTML, etc.).
Bloque de código corregido (ejemplo completo)
// app/page.tsx
import { headers } from 'next/headers';
export default function Page() {
// ✅ Seguro: se evalúa en el servidor
const ua = headers().get('user-agent') || '';
const isMobile = /Mobi|Android/i.test(ua);
return (
<div>
<h1>Estado: {isMobile ? 'Móvil' : 'Escritorio'}</h1>
<p>
Última actualización:
<time
suppressHydrationWarning
dateTime={new Date().toISOString()}
>
{new Date().toLocaleString()}
</time>
</p>
</div>
);
}
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="es">
<head>
{/* ✅ Previene iOS from modifying DOM */}
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
</head>
<body>{children}</body>
</html>
);
}
Pro-tip: Diagnóstico rápido
- Reproduce en modo incógnito (sin extensiones).
- Desactiva Cloudflare/Vercel Edge temporalmente.
-
Busca en el código:
new Date(),window.,localStorage,Math.random(). -
Usa
console.log('CLIENT')dentro deuseEffectpara confirmar qué se renderiza en cliente vs servidor.
🚨 Nunca uses
suppressHydrationWarningcomo solución principal. Es un escape hatch, no un parche. Si el 90% de tu app usasuppressHydrationWarning, hay un problema arquitectónico.
¡Listo! Con esto el error desaparecerá de forma definitiva.
Top comments (0)