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) no coincide con el árbol de React generado durante la primera renderización del cliente. React detecta esta inconsistencia durante el proceso de hydration y lanza una advertencia crítica que puede romper la interactividad o causar comportamientos impredecibles.

Causa raíz

La inconsistencia proviene de código que se ejecuta de forma diferente en el servidor y en el cliente, como:

  • Uso de APIs del navegador (window, localStorage, Date.now(), etc.) directamente en el renderizado.
  • Lógica condicional basada en typeof window !== 'undefined'.
  • Elementos que cambian dinámicamente (fechas, IDs aleatorios, estado inicial no determinista).
  • Extensiones del navegador que modifican el DOM (como traductores o bloqueadores de anuncios).
  • Metaetiquetas de detección automática en iOS (format-detection) que inyectan <a> tags en tiempo real.

⚠️ Importante: El error no siempre es visible en desarrollo, pero sí en producción (donde SSR está activo).


Solución definitiva (pasos verificados)

✅ Paso 1: Identifica el elemento problemático

Busca en tu código:

  • Date(), new Date(), Math.random(), localStorage.getItem(...), window.innerWidth, etc.
  • Uso de useEffect dentro de componentes que también se renderizan en el servidor sin protección.
  • Contenido dinámico en <p>, <time>, <span>, etc., que dependa de estado no inicializado.

🔍 Usa el React DevTools o busca en el HTML fuente (View Source) para comparar con el DOM renderizado en el navegador.


✅ Paso 2: Aplica la solución más adecuada (elige una)

🔹 Opción A: Usa suppressHydrationWarning (para contenido intencionalmente variable)

// Ejemplo: fecha/hora actual
<time datetime={new Date().toISOString()} suppressHydrationWarning>
  {new Date().toLocaleString()}
</time>

// O: contenido generado dinámicamente (ej. UUID)
<span suppressHydrationWarning>{crypto.randomUUID()}</span>
Enter fullscreen mode Exit fullscreen mode

⚠️ Solo usa esto si el contenido debe cambiar entre SSR y CSR. No es una solución general.


🔹 Opción B: Usa useEffect para diferir la renderización client-side

'use client'

import { useState, useEffect } from 'react'

export default function DynamicDate() {
  const [date, setDate] = useState<string>('Cargando...')

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

  return <time>{date}</time>
}
Enter fullscreen mode Exit fullscreen mode

Ventaja: Mantiene SSR para el contenido estático, y solo reemplaza lo dinámico después de hydration.


🔹 Opción C: Deshabilita SSR para componentes problemáticos

// components/DynamicComponent.tsx
'use client'

export default function DynamicComponent() {
  const [width, setWidth] = useState(0)

  useEffect(() => {
    setWidth(window.innerWidth)
  }, [])

  return <div>Ancho: {width}px</div>
}

// page.tsx
import dynamic from 'next/dynamic'

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

export default function Page() {
  return (
    <main>
      <h1>Contenido SSR</h1>
      <DynamicComponent />
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

Ideal para componentes totalmente dependientes del cliente (ej. gráficos con Canvas, reproductores de video).


✅ Paso 3: Solución específica para iOS (frecuente causa oculta)

Agrega esta metaetiqueta en tu <head> (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

📱 iOS inyecta automáticamente <a href="tel:..."> en números de teléfono, causando mismatches silenciosos.


Pro-tip: Prevención activa

  1. Nunca uses APIs del navegador en el renderizado directo:
   // ❌ MAL
   const Component = () => <span>{window.innerWidth > 768 ? 'Desktop' : 'Mobile'}</span>

   // ✅ BIEN
   const Component = () => {
     const [isDesktop, setIsDesktop] = useState(false)
     useEffect(() => {
       setIsDesktop(window.innerWidth > 768)
     }, [])
     return <span>{isDesktop ? 'Desktop' : 'Mobile'}</span>
   }
Enter fullscreen mode Exit fullscreen mode
  1. Valida tu HTML con next dev en modo producción:
   npm run build && npm run start
Enter fullscreen mode Exit fullscreen mode
  1. Revisa configuraciones de CDN/Edge:

    • Cloudflare Auto Minify → desactívalo temporalmente.
    • Vercel Edge Config → evita inyección de scripts en HTML.
  2. Usa console.error en lugar de throw en useEffect para evitar interrupciones de hydration.


Resultado esperado: El error desaparece, el hydration se completa sin advertencias, y tu app es 100% funcional en SSR/CSR.

Top comments (0)