FediScanner

#react

← Back

Cómo solucionar "Text content does not match server-rendered HTML" en Next.js App Router


Este error ocurre cuando el HTML generado en el servidor (server-side rendering) no coincide con el árbol de React generado durante la primera renderización en el navegador (client-side hydration). React detecta esta inconsistencia y lanza una advertencia crítica porque puede romper la interactividad y causar comportamientos impredecibles.

Causa raíz


La mayoría de los casos se deben a:

  • Uso de APIs del navegador (window, localStorage, Date.now(), etc.) directamente en el renderizado.
  • Lógica condicional basada en typeof window !== 'undefined' dentro del cuerpo del componente (no dentro de useEffect).
  • Elementos HTML anidados inválidos según el estándar (ej. <p> dentro de otro <p>, <button> dentro de <button>).
  • Extensiones del navegador que modifican el DOM antes de la hidratación.
  • Metaetiqueta de detección automática de iOS (format-detection) que inyecta <a> dinámicos en números de teléfono o fechas.


Solución definitiva (pasos verificados)

✅ Paso 1: Aisla la fuente de la inconsistencia


Busca en tu código:

  • Uso directo de window, localStorage, navigator, Date(), Math.random(), etc. en el cuerpo del componente.
  • Lógica condicional como if (typeof window !== 'undefined') { ... } fuera de hooks.

Ejemplo problemático:

// ❌ INCORRECTO: Se ejecuta en SSR y en CSR con resultados distintos
function Timestamp() {
const now = new Date(); // ❌ Diferente en SSR vs CSR
return <time>{now.toLocaleString()}</time>;
}

✅ Paso 2: Aplica la solución según el caso

🔹 Caso A: Contenido dinámico (fechas, IDs aleatorios, etc.)


Usa suppressHydrationWarning en el elemento problemático:

// ✅ CORRECTO: Advertencia suprimida solo en el elemento afectado
<time suppressHydrationWarning>{new Date().toLocaleString()}</time>


⚠️ Importante: Solo aplica a elementos textuales y de un solo nivel de profundidad.


🔹 Caso B: Lógica que requiere el navegador (ej. localStorage, window.innerWidth)


Usa useEffect para postergar la lógica al cliente:

// ✅ CORRECTO: Renderiza lo mismo en SSR y CSR inicialmente
import { useState, useEffect } from 'react';

function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('light'); // Valor por defecto seguro

useEffect(() => {
const stored = localStorage.getItem('theme');
if (stored === 'dark' || stored === 'light') {
setTheme(stored);
}
}, []);

return <button className={theme}>Toggle</button>;
}

🔹 Caso C: Componente totalmente client-side (ej. canvas, mapas)


Desactiva SSR con dynamic y ssr: false:

// pages/page.tsx
import dynamic from 'next/dynamic';

const InteractiveMap = dynamic(() => import('../components/Map'), {
ssr: false,
loading: () => <p>Cargando mapa...</p>
});

export default function Page() {
return <InteractiveMap />;
}

✅ Paso 3: Verifica estructura HTML válida


Revisa que no haya:

  • <p> anidados (ej. <p><div>...</div></p> → inválido).
  • <button> dentro de <a>, o viceversa.
  • <ul>/<ol> dentro de <p>.
  • <a> dentro de <a>.

Ejemplo corregido:

// ❌ INCORRECTO
<p>
<a href="/home">Home</a> | <a href="/about">About</a>
</p>

// ✅ CORRECTO (usar <span> o fragmento)
<span>
<a href="/home">Home</a> | <a href="/about">About</a>
</span>

✅ Paso 4: Deshabilita detección automática en iOS (si aplica)


Agrega esta metaetiqueta en <head> de tu layout:

// 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>
);
}

Pro-tip: Diagnóstico rápido


  1. Activa React DevTools → Selecciona la pestaña "Components" y busca el ícono de advertencia (⚠️) en el árbol.
  2. Busca en consola: El error muestra la ruta exacta del componente afectado (ej. at div (app/page.tsx:15:10)).
  3. Prueba en modo incógnito: Si desaparece el error, es una extensión del navegador (común: Grammarly, Dark Reader, ad blockers).


Código corregido (ejemplo completo)


// app/page.tsx
import { useState, useEffect } from 'react';

function ClientOnlyContent() {
const [data, setData] = useState<string>('Cargando...');

useEffect(() => {
// ✅ Solo se ejecuta en cliente
const timer = setTimeout(() => {
setData('Contenido generado en el cliente');
}, 100);
return () => clearTimeout(timer);
}, []);

return <p>{data}</p>;
}

export default function Page() {
return (
<main>
<h1>App Router</h1>
<ClientOnlyContent />
{/* ✅ Supresión segura para fechas dinámicas */}
<p>Última actualización: <time suppressHydrationWarning>{new Date().toLocaleDateString()}</time></p>
</main>
);
}


✅ Validación final: Ejecuta next build y revisa los logs. Si no hay errores de hidratación, ¡solucionado.


#javascript #nextjs #react #webdev #software #coding #development #engineering #inclusive #community
Cómo solucionar \"Text content does not match server-rendered HTML\" en Next.js App Router

Show Original PostReport