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 generado durante la primera renderización en el cliente. React detecta la inconsistencia durante el proceso de hydration y lanza una advertencia crítica que puede romper la funcionalidad de tu app.
Causa raíz
La mayoría de los casos provienen de código no idempotente entre SSR y CSR, es decir, código que produce resultados diferentes dependiendo de si se ejecuta en el servidor o en el navegador. Las causas más frecuentes:
- Uso de
Date.now(),new Date(),Math.random()o APIs dependientes del tiempo en el renderizado. - Acceso directo a
window,localStorage,navigator, etc. sin protección. - Lógica condicional basada en
typeof window !== 'undefined'dentro del JSX. - Extensiones del navegador que inyectan elementos (ej. ad-blockers, traductores).
- Metaetiqueta
format-detectionde iOS que modifica el DOM tras la carga inicial.
Solución definitiva (pasos verificados)
✅ Paso 1: Identifica el elemento problemático
Busca en el stack trace del error el componente y la línea exacta. Si no está claro:
- Abre DevTools → pestaña Console.
- Haz clic en el error → verás el mensaje detallado con la ruta del componente.
- Busca en ese componente:
-
Date,Math.random(),localStorage,window.innerWidth, etc. - Uso de
useEffectque modifique el estado antes de la primera renderización. - Uso de
typeof window !== 'undefined'en el cuerpo del componente (no dentro deuseEffect).
-
✅ Paso 2: Aplica la solución según el caso
🔹 Caso A: Contenido dinámico (ej. fecha/hora, ID aleatorio)
❌ Incorrecto:
export default function Timestamp() {
return <time>{new Date().toISOString()}</time>; // ❌ Diferente en SSR vs CSR
}
✅ Correcto (opción 1): Usa suppressHydrationWarning para contenido inmutable
export default function Timestamp() {
return (
<time datetime={new Date().toISOString()} suppressHydrationWarning>
{new Date().toISOString()}
</time>
);
}
✅ Correcto (opción 2): Usa useEffect para retrasar la renderización
import { useState, useEffect } from 'react';
export default function Timestamp() {
const [timestamp, setTimestamp] = useState('');
useEffect(() => {
setTimestamp(new Date().toISOString());
}, []);
return <time>{timestamp || '...'}</time>;
}
🔹 Caso B: Acceso a APIs del navegador (localStorage, window, etc.)
❌ Incorrecto:
export default function ThemeToggle() {
const isDark = localStorage.theme === 'dark'; // ❌ Error en SSR
return <button>{isDark ? '🌙' : '☀️'}</button>;
}
✅ Correcto: Usa useEffect + estado local
import { useState, useEffect } from 'react';
export default function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const stored = localStorage.getItem('theme');
setIsDark(stored === 'dark');
}, []);
return <button>{isDark ? '🌙' : '☀️'}</button>;
}
🔹 Caso C: Lógica condicional basada en entorno
❌ Incorrecto:
export default function ResponsiveLayout() {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>;
}
✅ Correcto: Usa useEffect para detectar tamaño de pantalla
import { useState, useEffect } from 'react';
export default function ResponsiveLayout() {
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 ? 'Mobile' : 'Desktop'}</div>;
}
🔹 Caso D: iOS inyecta enlaces automáticamente
✅ Solución definitiva: Agrega la metaetiqueta 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>
);
}
🛠️ Bloque de código corregido (ejemplo completo)
// app/page.tsx
import { useState, useEffect } from 'react';
export default function Page() {
const [isClient, setIsClient] = useState(false);
const [currentTime, setCurrentTime] = useState('');
useEffect(() => {
setIsClient(true);
setCurrentTime(new Date().toLocaleTimeString());
}, []);
return (
<div>
<h1>App Router Features</h1>
<p>Latest Version: 16.3.0</p>
{/* Contenido seguro para SSR/CSR */}
<time
datetime={new Date().toISOString()}
suppressHydrationWarning
>
{currentTime || 'Cargando...'}
</time>
{/* Componente con lógica client-only */}
{isClient && <ClientOnlyComponent />}
</div>
);
}
// Componente client-only explícito
const ClientOnlyComponent = dynamic(() => import('./ClientOnly'), {
ssr: false,
});
💡 Pro-tip: Prevención a largo plazo
- Herramientas de detección temprana:
# Instala ESLint plugin para Next.js
npm install --save-dev eslint-plugin-next
Configura en .eslintrc.json:
{
"extends": ["next/core-web-vitals", "plugin:next/recommended"]
}
Esto detecta usos inseguros de window, localStorage, etc.
-
Regla de oro para SSR:
Todo lo que se renderice en el JSX debe producir el mismo resultado en el servidor y en el cliente.
Para componentes 100% client-side:
import dynamic from 'next/dynamic';
const ClientComponent = dynamic(() => import('./ClientComponent'), { ssr: false });
-
Verifica en producción:
Usa
next build && next startpara reproducir el entorno de producción (donde ocurren más errores de hydration).
✅ Resultado: El error desaparece, la app se hydrata correctamente y el usuario ve contenido consistente desde la primera interacción.
Top comments (0)