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 en el navegador (hydration). Es un problema crítico de consistencia de estado que rompe la experiencia de usuario y puede causar comportamientos impredecibles.
Causa raíz
En tu caso, el problema es casi seguro causado por el uso de APIs del lado del cliente (window, localStorage, Date.now(), etc.) durante la renderización inicial, o por modificaciones no deterministas del DOM antes de la hidratación (como detecta iOS números telefónicos y los convierte en <a>).
Solución definitiva (pasos verificados)
✅ Paso 1: Identifica la fuente del mismatch
Busca en tus componentes:
-
new Date()oDate.now()en el render -
typeof window !== 'undefined'fuera deuseEffect -
localStorage.getItem()osessionStorageen render -
<time>,<date>, o contenido dinámico sinsuppressHydrationWarning - CSS-in-JS mal configurado (ver Pro-tip abajo)
✅ Paso 2: Aplica la solución correcta según el caso
Caso A: Contenido dinámico (fechas, IDs, tokens)
Usa suppressHydrationWarning solo en el elemento específico:
// ✅ CORRECTO: Solo en el elemento problemático
<time
dateTime={new Date().toISOString()}
suppressHydrationWarning
>
{new Date().toLocaleDateString()}
</time>
⚠️ Importante:
suppressHydrationWarningno debe usarse en contenedores padres. Solo en elementos de texto que sabes que variarán.
Caso B: Lógica condicional basada en cliente/servidor
Usa useEffect para postergar la renderización:
// ✅ CORRECTO: Renderiza placeholder en SSR
import { useState, useEffect } from 'react';
export default function ClientOnlyComponent() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null; // O un skeleton loading
return (
<div>
{/* Aquí puedes usar window, localStorage, etc. */}
<span>Token: {localStorage.getItem('token')}</span>
</div>
);
}
Caso C: Componentes que dependen de APIs del navegador
Desactiva SSR explícitamente:
// pages/client-only.tsx
import dynamic from 'next/dynamic';
const ClientOnly = dynamic(
() => import('../components/ClientOnly'),
{ ssr: false }
);
export default function Page() {
return (
<div>
<ClientOnly />
</div>
);
}
✅ Paso 3: Previene el problema en iOS (frecuente en App Router)
Agrega este <meta> en tu <head> global (en app/layout.tsx):
// app/layout.tsx
export const metadata = {
title: 'My App',
description: '...',
metadataBase: new URL('https://example.com'),
};
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>
);
}
🔍 Verifica: Abre tu app en Safari iOS y busca si números como
555-1234se convierten en enlaces azules → si sí, el problema es este.
Pro-tip: Diagnóstico rápido con DevTools
- Abre Chrome DevTools →
F12 - Ve a la pestaña Elements
- Haz clic derecho en el
<body>→ Break on → subtree modifications - Recarga la página
- Cuando se detenga el debugger, revisa qué código modificó el DOM antes de que React hidrate
📌 Error común en Next.js 13+: Usar
use clienten componentes que renderizan contenido dinámico sin manejar el estado de mount. Siempre usa el patrónuseState + useEffectpara evitar mismatches.
Código corregido (ejemplo real)
// components/DateTime.tsx
'use client';
import { useState, useEffect } from 'react';
export default function DateTime() {
const [mounted, setMounted] = useState(false);
const [now, setNow] = useState<Date | null>(null);
useEffect(() => {
setMounted(true);
setNow(new Date());
}, []);
if (!mounted) return <span aria-hidden="true">Loading...</span>;
return (
<time
dateTime={now?.toISOString()}
suppressHydrationWarning
>
{now?.toLocaleString()}
</time>
);
}
✅ Resultado: Sin mismatches, compatible con SSR, iOS y extensiones de navegador.
Top comments (0)