DEV Community

Erick Eduardo Ramos
Erick Eduardo Ramos

Posted on

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

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>;
}
Enter fullscreen mode Exit fullscreen mode

✅ 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>
Enter fullscreen mode Exit fullscreen mode

⚠️ 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>;
}
Enter fullscreen mode Exit fullscreen mode

🔹 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 />;
}
Enter fullscreen mode Exit fullscreen mode

✅ 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>
Enter fullscreen mode Exit fullscreen mode

✅ 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)