DEV Community

Erick Eduardo Ramos
Erick Eduardo Ramos

Posted on

Cómo solucionar el error “Text content does not match server-rendered HTML” en Next.js App Router

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 que se genera durante la primera renderización en el cliente (hydration). React detecta una inconsistencia crítica y detiene la hidratación para evitar comportamientos impredecibles.

Causa raíz

En tu caso, el problema no es un error de lógica de renderizado obvio (como <p> anidado en otro <p>), sino una diferencia temporal o contextual entre el servidor y el cliente. Las causas más probables:

  • Uso de Date.now(), new Date(), o Math.random() en el renderizado.
  • Acceso directo a window, localStorage, navigator, etc. durante la renderización.
  • Uso de typeof window !== 'undefined' dentro del cuerpo del componente (no dentro de useEffect o useMemo).
  • Metaetiqueta format-detection de iOS activa → convierte números de teléfono en <a> → rompe el DOM.
  • Extensiones del navegador (ej. traductores, dark mode) modificando el DOM antes de la hidratación.
  • Configuración de CDN (Cloudflare Auto Minify, etc.) que altera el HTML prerenderizado.

Solución definitiva (pasos verificados)

✅ Paso 1: Busca y elimina dependencias de tiempo o contexto del renderizado

❌ Mal:

// ❌ ¡Esto rompe la hidratación!
export default function Page() {
  const now = new Date(); // ❌ El servidor y el cliente tienen hora distinta
  return <p>Última actualización: {now.toLocaleString()}</p>;
}
Enter fullscreen mode Exit fullscreen mode

✅ Bien (opción 1: renderiza placeholder en SSR, luego actualiza):

import { useState, useEffect } from 'react';

export default function Page() {
  const [now, setNow] = useState<string>('Cargando...');

  useEffect(() => {
    setNow(new Date().toLocaleString());
  }, []);

  return <p>Última actualización: {now}</p>;
}
Enter fullscreen mode Exit fullscreen mode

✅ Bien (opción 2: usa suppressHydrationWarning solo si es inevitable):

export default function Page() {
  return (
    <p>
      Última actualización: 
      <time 
        suppressHydrationWarning 
        dateTime={new Date().toISOString()}
      >
        {new Date().toLocaleString()}
      </time>
    </p>
  );
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Pro-tip: suppressHydrationWarning solo debe usarse en elementos específicos, nunca en el <body> o <html>. React ignora solo ese nodo durante la comparación.


✅ Paso 2: Deshabilita detección automática de iOS (¡frecuente causa silenciosa!)

Agrega esta metaetiqueta en <head> de app/layout.tsx (o app/layout.tsx si usas App Router):

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="es">
      <head>
        {/* 👇 SOLUCIÓN CLAVE PARA iOS */}
        <meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
      </head>
      <body>{children}</body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

🔍 ¿Por qué? iOS convierte automáticamente números como 1-800-555-0199 en <a href="tel:...">. Esto modifica el DOM prerenderizado → mismatch.


✅ Paso 3: Evita APIs del navegador en render

❌ Mal:

export default function Page() {
  const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; // ❌
  return <div>{isMobile ? 'Móvil' : 'Escritorio'}</div>;
}
Enter fullscreen mode Exit fullscreen mode

✅ Bien (con useEffect + estado):

import { useState, useEffect } from 'react';

export default function Page() {
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    const checkMobile = () => setIsMobile(window.innerWidth < 768);
    checkMobile();
    window.addEventListener('resize', checkMobile);
    return () => window.removeEventListener('resize', checkMobile);
  }, []);

  return <div>{isMobile ? 'Móvil' : 'Escritorio'}</div>;
}
Enter fullscreen mode Exit fullscreen mode

✅ Alternativa (recomendada en App Router): usa headers().get('User-Agent') en el servidor:

// app/page.tsx (Server Component)
import { headers } from 'next/headers';

export default function Page() {
  const headersList = headers();
  const ua = headersList.get('user-agent') || '';
  const isMobile = /Mobi|Android/i.test(ua);

  return <div>{isMobile ? 'Móvil' : 'Escritorio'}</div>;
}
Enter fullscreen mode Exit fullscreen mode

✅ Paso 4: Verifica configuración de CDN

Si usas Cloudflare, Vercel Edge Config, o cualquier proxy intermedio:

  • Cloudflare: Desactiva Auto Minify (HTML) y Rocket Loader.
  • Vercel: Asegúrate de no tener vercel dev con --turbo (puede alterar SSR).
  • Otros: Busca reglas que modifiquen Content-Type: text/html (ej. reglas de reescritura, optimización de imágenes en HTML, etc.).

Bloque de código corregido (ejemplo completo)

// app/page.tsx
import { headers } from 'next/headers';

export default function Page() {
  // ✅ Seguro: se evalúa en el servidor
  const ua = headers().get('user-agent') || '';
  const isMobile = /Mobi|Android/i.test(ua);

  return (
    <div>
      <h1>Estado: {isMobile ? 'Móvil' : 'Escritorio'}</h1>
      <p>
        Última actualización: 
        <time 
          suppressHydrationWarning 
          dateTime={new Date().toISOString()}
        >
          {new Date().toLocaleString()}
        </time>
      </p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="es">
      <head>
        {/* ✅ Previene iOS from modifying DOM */}
        <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. Reproduce en modo incógnito (sin extensiones).
  2. Desactiva Cloudflare/Vercel Edge temporalmente.
  3. Busca en el código: new Date(), window., localStorage, Math.random().
  4. Usa console.log('CLIENT') dentro de useEffect para confirmar qué se renderiza en cliente vs servidor.

🚨 Nunca uses suppressHydrationWarning como solución principal. Es un escape hatch, no un parche. Si el 90% de tu app usa suppressHydrationWarning, hay un problema arquitectónico.

¡Listo! Con esto el error desaparecerá de forma definitiva.

Top comments (0)