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 navegador. Es un problema crítico de hydration mismatch, y si no se soluciona, puede causar comportamientos erráticos, pérdida de accesibilidad y problemas de rendimiento.
🔍 Causa raíz (diagnóstico técnico)
El error no es solo una advertencia. Ocurre cuando:
- Se renderiza contenido diferente en el servidor vs. cliente (ej. usando
Date.now(),localStorage,window.innerWidth, etc.). - Se usa lógica condicional basada en
typeof window !== 'undefined'dentro del render (sin protegerlo conuseEffect). - iOS Safari inyecta automáticamente enlaces en números de teléfono/email (
<a href="tel:...">) sin que React lo espere. - Librerías CSS-in-JS (como
styled-components) inyectan estilos dinámicos en el cliente que alteran el DOM. - Herramientas de Edge/CDN (Cloudflare Auto Minify, Fastly, etc.) modifican el HTML prerrendereado.
⚠️ Importante: React espera que el DOM inicial del cliente coincida exactamente con el HTML prerrendereado. Cualquier diferencia (espacios, nodos, atributos) dispara este error.
✅ Solución definitiva (pasos verificados)
Paso 1: Aisla el componente problemático
- Busca en consola el stack trace del error (ej.
Hydration failed because the server rendered HTML didn't match the client). - Identifica el componente y la línea exacta donde ocurre.
- Usa
console.log('server?', typeof window === 'undefined')en el componente sospechoso para confirmar si hay lógica condicional.
Paso 2: Aplica la solución según el caso
🛠️ Caso A: Contenido dinámico (fechas, localStorage, APIs del navegador)
Usa useEffect para retrasar la renderización del contenido sensible:
// ✅ CORRECTO: Renderiza placeholder en SSR, luego actualiza en cliente
import { useState, useEffect } from 'react';
export default function Timestamp() {
const [timestamp, setTimestamp] = useState<string>('');
useEffect(() => {
setTimestamp(new Date().toLocaleString());
}, []);
return <time suppressHydrationWarning>{timestamp || '...'}</time>;
}
💡
suppressHydrationWarninges seguro aquí porque el contenido es intencionalmente diferente y no afecta la estructura del DOM.
🛠️ Caso B: Uso de window, localStorage, navigator, etc.
Envuelve el acceso en useEffect o usa dynamic({ ssr: false }):
// ✅ Opción 1: Con dynamic (recomendado para componentes completos)
import dynamic from 'next/dynamic';
const ClientOnlyComponent = dynamic(
() => import('../components/ClientOnlyComponent'),
{ ssr: false }
);
export default function Page() {
return <ClientOnlyComponent />;
}
// ✅ Opción 2: Con `useEffect` + estado (para lógica parcial)
import { useState, useEffect } from 'react';
export default function ThemeSwitcher() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const stored = localStorage.getItem('theme');
if (stored) setTheme(stored);
}, []);
return <div className={theme}>...</div>;
}
🛠️ Caso C: iOS inyecta enlaces en números/tiempos
Agrega la meta etiqueta 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>
);
}
🛠️ Caso D: Librerías CSS-in-JS (styled-components, emotion)
Asegura que el hash de estilos sea idéntico en SSR y CSR:
# Verifica que uses la versión compatible con Next.js App Router
npm install @emotion/react@latest @emotion/styled@latest
Y en app/layout.tsx:
// app/layout.tsx
import { EmotionJsxStyle } from 'next/document';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<EmotionJsxStyle />
<body>{children}</body>
</html>
);
}
⚠️ Si usas
styled-components, asegúrate de usarcreateEmotionServerycreateEmotionInstanceenapp/layout.tsx(ver guía oficial).
🔧 Pro-tips para evitar regresiones
-
Herramientas de detección temprana:
- Usa
next dev --turbopara activar el modo estricto de hydration. - Agrega este script en desarrollo para detectar mismatches silenciosos:
// utils/hydration-debug.ts if (typeof window !== 'undefined') { const originalWarn = console.warn; console.warn = (...args) => { if (args[0]?.includes?.('Hydration')) { console.trace('⚠️ HYDRATION MISMATCH DETECTED'); } originalWarn(...args); }; } - Usa
-
Valida tu HTML prerrendereado:
- Abre
view-source:https://tu-sitio.com/rutay compáralo con el DOM del navegador (F12 > Elements). - Busca diferencias en:
- Espacios en blanco (
vs) - Atributos
data-reactroot,data-hydrate - Nodos
<script>inyectados por extensiones (desactiva todas en modo incógnito).
- Espacios en blanco (
- Abre
-
Evita
suppressHydrationWarningcomo primera solución:- Solo úsalo en elementos de solo lectura (fechas, IDs temporales).
- Nunca en elementos interactivos (
<button>,<a>,<input>).
📌 Checklist final
| ✅ | Acción |
|---|---|
| ✔ | Eliminar typeof window !== 'undefined' del render directo |
| ✔ | Migrar lógica de window/localStorage a useEffect
|
| ✔ | Agregar <meta name="format-detection" ...> si usas números/tiempos |
| ✔ | Verificar que tu CDN no modifique el HTML (Cloudflare: desactiva Auto Minify y Rocket Loader) |
| ✔ | Validar que no haya elementos anidados inválidos (<p> dentro de <p>, <button> dentro de <a>) |
🔥 Último recurso: Si el error persiste, ejecuta
rm -rf .next && npm run dev. A veces el cache de Next.js oculta mismatches persistentes.
Con esto, el error desaparecerá de forma definitiva. No hay atajos: la clave es garantizar identidad total entre SSR y CSR.
Top comments (0)