Cómo solucionar “Text content does not match server-rendered HTML” en Next.js App Router
Este error ocurre cuando el HTML generado por el servidor (SSR/SSG) no coincide con el árbol de React generado durante la primera renderización del cliente. Durante la hidratación, React espera que el DOM inicial coincida exactamente con el que generó en el servidor. Cualquier diferencia —incluso un espacio extra, una etiqueta anidada incorrectamente o una llamada a Date.now()— rompe la sincronización y lanza este error crítico.
🔍 Causa raíz (diagnóstico rápido)
En entornos App Router, cualquier lógica no determinista durante la renderización inicial (fuera de useEffect, useLayoutEffect, o fuera de componentes dynamic({ ssr: false })) genera una divergencia entre el HTML prerenderizado y el virtual DOM del cliente.
Las causas más comunes en producción:
- Uso de
Date.now(),Math.random(),localStorage,window, etc. en el cuerpo del componente. - Metaetiquetas de detección automática de iOS (
format-detection) que inyectan nodos<a>en tiempo de ejecución. - Extensiones del navegador (ej. traductores, ad-blockers) que modifican el DOM antes de la hidratación.
- Minificación agresiva por CDN (Cloudflare Auto Minify, Vercel Edge Config mal configurado).
- Uso de
typeof window !== 'undefined'dentro de la lógica de renderizado (no solo en efectos).
✅ 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 donde ocurre la divergencia. Si no está claro:
- Abre DevTools > Network > Desactiva Cache.
- Recarga con
?__next_dev__=1(ej:http://localhost:3000?__next_dev__=1) para ver errores detallados. - Usa
console.logsolo en useEffect para verificar qué valor cambia entre SSR y CSR.
Paso 2: Aplica la solución según el caso
🚫 Caso A: Contenido dinámico (fechas, IDs aleatorios, etc.)
No lo renderices directamente. Usa suppressHydrationWarning en el elemento específico:
// ❌ Mal: genera HTML distinto en SSR vs CSR
function Timestamp() {
return <time>{new Date().toISOString()}</time>;
}
// ✅ Bien: silencia la advertencia en el elemento problemático
function Timestamp() {
return <time suppressHydrationWarning>{new Date().toISOString()}</time>;
}
⚠️ Pro-tip:
suppressHydrationWarningsolo funciona en el elemento raíz del contenido divergente, no en contenedores padre. React ignora solo la diferencia en ese nodo y sus hijos de texto directos.
🚫 Caso B: Lógica condicional basada en window/localStorage
Mueve la lógica a useEffect o usa dynamic({ ssr: false }):
// ❌ Mal: typeof window se evalúa en SSR y CSR → divergencia
function ThemeToggle() {
const [theme, setTheme] = useState(
typeof window !== 'undefined' && localStorage.getItem('theme') === 'dark'
? 'dark'
: 'light'
);
// ...
}
// ✅ Bien: usa estado inicial seguro + efecto para sincronizar
import { useState, useEffect } from 'react';
function ThemeToggle() {
const [theme, setTheme] = useState('light'); // valor seguro para SSR
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
const stored = localStorage.getItem('theme');
if (stored === 'dark' || stored === 'light') {
setTheme(stored);
}
}, []);
// Renderiza igual en SSR y CSR inicial
return (
<div className={isClient ? theme : 'light'}>
{isClient ? 'Cargado en cliente' : 'Cargado en servidor'}
</div>
);
}
Alternativa (recomendada para componentes 100% client):
// components/ThemeToggle.client.tsx
'use client';
export default function ThemeToggle() {
// Aquí puedes usar window/localStorage sin miedo
}
// page.tsx
import dynamic from 'next/dynamic';
const ThemeToggle = dynamic(() => import('../components/ThemeToggle.client'), {
ssr: false,
loading: () => <span>Cargando tema...</span>
});
🚫 Caso C: iOS inyecta <a> en números/teléfonos
Agrega la metaetiqueta en <head> (en 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>
);
}
🚫 Caso D: CDN/Minificación modificando HTML
- Cloudflare: Desactiva Auto Minify (HTML) en Speed > Optimization.
-
Vercel: Verifica si usas
vercel.jsoncon reglas de reescritura que alteren el HTML. -
Prueba localmente: Ejecuta
next build && next starty verifica si el error persiste. Si no, el problema está en la infraestructura.
🛠️ Diagnóstico avanzado (para errores persistentes)
- Desactiva extensiones del navegador (especialmente traductores, ad-blockers, Dark Mode).
-
Verifica el HTML prerenderizado:
- Abre la consola y ejecuta:
document.documentElement.innerHTML
- Compáralo con el HTML fuente (
view-source:...).-
Usa
next dev --turbo(Next.js 14.2+) para detectar errores de hidratación más rápido.
-
Usa
💡 Pro-tip: Prevención definitiva
-
Nunca uses APIs del navegador fuera de
useEffect. -
Usa
'use client'explícitamente en componentes que requieran interactividad. - Valida anidación HTML con herramientas como HTML Validator (Chrome).
-
En tests unitarios, simula
windowylocalStoragepara evitar falsos positivos.
✅ Regla de oro: Si el contenido no es 100% determinista antes de la hidratación, no lo renderices en el cuerpo del componente. Usa
useEffect,suppressHydrationWarning, odynamic({ ssr: false }).
Aplica estos pasos en orden y el error desaparecerá. Si persiste, revisa logs de tu CDN y desactiva todas las extensiones antes de depurar.
Top comments (0)