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 por el servidor (SSR/SSG) no coincide con el árbol de React generado durante la primera renderización en el navegador (hydration). Es un problema crítico de consistencia de estado que rompe la experiencia de usuario y puede causar comportamientos impredecibles.

Causa raíz

En tu caso, el problema es casi seguro causado por el uso de APIs del lado del cliente (window, localStorage, Date.now(), etc.) durante la renderización inicial, o por modificaciones no deterministas del DOM antes de la hidratación (como detecta iOS números telefónicos y los convierte en <a>).

Solución definitiva (pasos verificados)

✅ Paso 1: Identifica la fuente del mismatch

Busca en tus componentes:

  • new Date() o Date.now() en el render
  • typeof window !== 'undefined' fuera de useEffect
  • localStorage.getItem() o sessionStorage en render
  • <time>, <date>, o contenido dinámico sin suppressHydrationWarning
  • CSS-in-JS mal configurado (ver Pro-tip abajo)

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

Caso A: Contenido dinámico (fechas, IDs, tokens)

Usa suppressHydrationWarning solo en el elemento específico:

// ✅ CORRECTO: Solo en el elemento problemático
<time 
  dateTime={new Date().toISOString()} 
  suppressHydrationWarning
>
  {new Date().toLocaleDateString()}
</time>
Enter fullscreen mode Exit fullscreen mode

⚠️ Importante: suppressHydrationWarning no debe usarse en contenedores padres. Solo en elementos de texto que sabes que variarán.

Caso B: Lógica condicional basada en cliente/servidor

Usa useEffect para postergar la renderización:

// ✅ CORRECTO: Renderiza placeholder en SSR
import { useState, useEffect } from 'react';

export default function ClientOnlyComponent() {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) return null; // O un skeleton loading

  return (
    <div>
      {/* Aquí puedes usar window, localStorage, etc. */}
      <span>Token: {localStorage.getItem('token')}</span>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Caso C: Componentes que dependen de APIs del navegador

Desactiva SSR explícitamente:

// pages/client-only.tsx
import dynamic from 'next/dynamic';

const ClientOnly = dynamic(
  () => import('../components/ClientOnly'),
  { ssr: false }
);

export default function Page() {
  return (
    <div>
      <ClientOnly />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

✅ Paso 3: Previene el problema en iOS (frecuente en App Router)

Agrega este <meta> en tu <head> global (en app/layout.tsx):

// app/layout.tsx
export const metadata = {
  title: 'My App',
  description: '...',
  metadataBase: new URL('https://example.com'),
};

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

🔍 Verifica: Abre tu app en Safari iOS y busca si números como 555-1234 se convierten en enlaces azules → si sí, el problema es este.


Pro-tip: Diagnóstico rápido con DevTools

  1. Abre Chrome DevToolsF12
  2. Ve a la pestaña Elements
  3. Haz clic derecho en el <body>Break onsubtree modifications
  4. Recarga la página
  5. Cuando se detenga el debugger, revisa qué código modificó el DOM antes de que React hidrate

📌 Error común en Next.js 13+: Usar use client en componentes que renderizan contenido dinámico sin manejar el estado de mount. Siempre usa el patrón useState + useEffect para evitar mismatches.


Código corregido (ejemplo real)

// components/DateTime.tsx
'use client';

import { useState, useEffect } from 'react';

export default function DateTime() {
  const [mounted, setMounted] = useState(false);
  const [now, setNow] = useState<Date | null>(null);

  useEffect(() => {
    setMounted(true);
    setNow(new Date());
  }, []);

  if (!mounted) return <span aria-hidden="true">Loading...</span>;

  return (
    <time 
      dateTime={now?.toISOString()} 
      suppressHydrationWarning
    >
      {now?.toLocaleString()}
    </time>
  );
}
Enter fullscreen mode Exit fullscreen mode

Resultado: Sin mismatches, compatible con SSR, iOS y extensiones de navegador.

Top comments (0)