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 cliente. React detecta la inconsistencia durante el proceso de hydration y lanza una advertencia crítica que puede romper la funcionalidad de tu app.

Causa raíz

La mayoría de los casos provienen de código no idempotente entre SSR y CSR, es decir, código que produce resultados diferentes dependiendo de si se ejecuta en el servidor o en el navegador. Las causas más frecuentes:

  • Uso de Date.now(), new Date(), Math.random() o APIs dependientes del tiempo en el renderizado.
  • Acceso directo a window, localStorage, navigator, etc. sin protección.
  • Lógica condicional basada en typeof window !== 'undefined' dentro del JSX.
  • Extensiones del navegador que inyectan elementos (ej. ad-blockers, traductores).
  • Metaetiqueta format-detection de iOS que modifica el DOM tras la carga inicial.

Solución definitiva (pasos verificados)

✅ Paso 1: Identifica el elemento problemático

Busca en el stack trace del error el componente y la línea exacta. Si no está claro:

  1. Abre DevTools → pestaña Console.
  2. Haz clic en el error → verás el mensaje detallado con la ruta del componente.
  3. Busca en ese componente:
    • Date, Math.random(), localStorage, window.innerWidth, etc.
    • Uso de useEffect que modifique el estado antes de la primera renderización.
    • Uso de typeof window !== 'undefined' en el cuerpo del componente (no dentro de useEffect).

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

🔹 Caso A: Contenido dinámico (ej. fecha/hora, ID aleatorio)

❌ Incorrecto:

export default function Timestamp() {
  return <time>{new Date().toISOString()}</time>; // ❌ Diferente en SSR vs CSR
}
Enter fullscreen mode Exit fullscreen mode

✅ Correcto (opción 1): Usa suppressHydrationWarning para contenido inmutable

export default function Timestamp() {
  return (
    <time datetime={new Date().toISOString()} suppressHydrationWarning>
      {new Date().toISOString()}
    </time>
  );
}
Enter fullscreen mode Exit fullscreen mode

✅ Correcto (opción 2): Usa useEffect para retrasar la renderización

import { useState, useEffect } from 'react';

export default function Timestamp() {
  const [timestamp, setTimestamp] = useState('');

  useEffect(() => {
    setTimestamp(new Date().toISOString());
  }, []);

  return <time>{timestamp || '...'}</time>;
}
Enter fullscreen mode Exit fullscreen mode

🔹 Caso B: Acceso a APIs del navegador (localStorage, window, etc.)

❌ Incorrecto:

export default function ThemeToggle() {
  const isDark = localStorage.theme === 'dark'; // ❌ Error en SSR
  return <button>{isDark ? '🌙' : '☀️'}</button>;
}
Enter fullscreen mode Exit fullscreen mode

✅ Correcto: Usa useEffect + estado local

import { useState, useEffect } from 'react';

export default function ThemeToggle() {
  const [isDark, setIsDark] = useState(false);

  useEffect(() => {
    const stored = localStorage.getItem('theme');
    setIsDark(stored === 'dark');
  }, []);

  return <button>{isDark ? '🌙' : '☀️'}</button>;
}
Enter fullscreen mode Exit fullscreen mode

🔹 Caso C: Lógica condicional basada en entorno

❌ Incorrecto:

export default function ResponsiveLayout() {
  const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
  return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>;
}
Enter fullscreen mode Exit fullscreen mode

✅ Correcto: Usa useEffect para detectar tamaño de pantalla

import { useState, useEffect } from 'react';

export default function ResponsiveLayout() {
  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 ? 'Mobile' : 'Desktop'}</div>;
}
Enter fullscreen mode Exit fullscreen mode

🔹 Caso D: iOS inyecta enlaces automáticamente

✅ Solución definitiva: Agrega la metaetiqueta 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

🛠️ Bloque de código corregido (ejemplo completo)

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

export default function Page() {
  const [isClient, setIsClient] = useState(false);
  const [currentTime, setCurrentTime] = useState('');

  useEffect(() => {
    setIsClient(true);
    setCurrentTime(new Date().toLocaleTimeString());
  }, []);

  return (
    <div>
      <h1>App Router Features</h1>
      <p>Latest Version: 16.3.0</p>

      {/* Contenido seguro para SSR/CSR */}
      <time 
        datetime={new Date().toISOString()} 
        suppressHydrationWarning
      >
        {currentTime || 'Cargando...'}
      </time>

      {/* Componente con lógica client-only */}
      {isClient && <ClientOnlyComponent />}
    </div>
  );
}

// Componente client-only explícito
const ClientOnlyComponent = dynamic(() => import('./ClientOnly'), {
  ssr: false,
});
Enter fullscreen mode Exit fullscreen mode

💡 Pro-tip: Prevención a largo plazo

  1. Herramientas de detección temprana:
   # Instala ESLint plugin para Next.js
   npm install --save-dev eslint-plugin-next
Enter fullscreen mode Exit fullscreen mode

Configura en .eslintrc.json:

   {
     "extends": ["next/core-web-vitals", "plugin:next/recommended"]
   }
Enter fullscreen mode Exit fullscreen mode

Esto detecta usos inseguros de window, localStorage, etc.

  1. Regla de oro para SSR:

    Todo lo que se renderice en el JSX debe producir el mismo resultado en el servidor y en el cliente.

  2. Para componentes 100% client-side:

   import dynamic from 'next/dynamic';
   const ClientComponent = dynamic(() => import('./ClientComponent'), { ssr: false });
Enter fullscreen mode Exit fullscreen mode
  1. Verifica en producción: Usa next build && next start para reproducir el entorno de producción (donde ocurren más errores de hydration).

Resultado: El error desaparece, la app se hydrata correctamente y el usuario ve contenido consistente desde la primera interacción.

Top comments (0)