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 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 con useEffect).
  • 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

  1. Busca en consola el stack trace del error (ej. Hydration failed because the server rendered HTML didn't match the client).
  2. Identifica el componente y la línea exacta donde ocurre.
  3. 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>;
}
Enter fullscreen mode Exit fullscreen mode

💡 suppressHydrationWarning es 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 />;
}
Enter fullscreen mode Exit fullscreen mode
// ✅ 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>;
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

⚠️ Si usas styled-components, asegúrate de usar createEmotionServer y createEmotionInstance en app/layout.tsx (ver guía oficial).


🔧 Pro-tips para evitar regresiones

  1. Herramientas de detección temprana:

    • Usa next dev --turbo para 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);
       };
     }
    
  2. Valida tu HTML prerrendereado:

    • Abre view-source:https://tu-sitio.com/ruta y compáralo con el DOM del navegador (F12 > Elements).
    • Busca diferencias en:
      • Espacios en blanco (&nbsp; vs )
      • Atributos data-reactroot, data-hydrate
      • Nodos <script> inyectados por extensiones (desactiva todas en modo incógnito).
  3. Evita suppressHydrationWarning como 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)