32blogby StudioMitsu

Cómo solucionar errores de hidratación en Next.js

Guía completa para diagnosticar y solucionar errores de hidratación en Next.js: los 4 patrones comunes con ejemplos de código bueno/malo y consejos de depuración.

15 min read
Contenido

Eran las 11 de la noche, una hora antes de una demo con un cliente, y la consola gritaba en rojo. El mensaje de error se extendía por mi terminal en ese inconfundible muro de texto:

Error: Hydration failed because the initial UI does not match what was rendered on the server.

La página se veía bien visualmente, pero React había remontado completamente todo el árbol de componentes, hundiendo el rendimiento y dejando un parpadeo desagradable al cargar. La demo era a la mañana siguiente. No tenía idea de qué lo estaba causando.

Si has visto ese error, esta guía es para ti. Al final, entenderás exactamente qué es la hidratación y por qué falla, reconocerás cada patrón común que la causa, y tendrás código funcional para cada solución. Se acabaron las adivinanzas.

¿Qué es un error de hidratación en Next.js?

Next.js renderiza tu página en el servidor primero, produciendo HTML crudo que el navegador puede mostrar inmediatamente. Luego React se carga en el navegador e "hidrata" ese HTML — adjunta event listeners, conecta el estado y toma el control de la interactividad. Este proceso de dos fases es lo que hace a Next.js rápido.

La hidratación solo funciona bajo una condición: el HTML que React produce en el navegador debe ser idéntico byte por byte al HTML que produjo el servidor. React no re-renderiza desde cero — recorre el DOM existente y lo compara con lo que espera. Si algo difiere, lanza un error de hidratación y recurre a un renderizado completo del lado del cliente.

El mensaje de error completo en Next.js típicamente se ve así:

Error: Hydration failed because the initial UI does not match what was rendered on the server.

Warning: Expected server HTML to contain a matching <div> in <div>.

O en versiones más recientes de Next.js:

Unhandled Runtime Error
Error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.

Ambos significan lo mismo: el HTML del servidor y del cliente no coinciden. Veamos cada razón común por la que esto ocurre.

¿Por qué los valores dinámicos como Date.now() rompen la hidratación?

El error de hidratación más común. Cualquier valor que difiera entre servidor y cliente — generado en tiempo de renderizado — romperá la hidratación.

Los culpables clásicos: Date.now(), Math.random(), new Date() y typeof window.

El servidor se ejecuta en Node.js. El cliente se ejecuta en el navegador. Se ejecutan en momentos diferentes. Una marca de tiempo o número aleatorio generado durante el renderizado del servidor nunca coincidirá con el generado milisegundos después durante la hidratación del cliente.

La versión rota:

tsx
// ❌ Date.now() produce valores diferentes en servidor y cliente
export default function LastUpdated() {
  return (
    <p>Page loaded at: {new Date(Date.now()).toLocaleTimeString()}</p>
  );
}
tsx
// ❌ Math.random() es diferente en cada llamada
export default function RandomId() {
  const id = Math.random().toString(36).slice(2);
  return <div id={id}>Some content</div>;
}
tsx
// ❌ typeof window es 'undefined' en servidor, 'object' en cliente
export default function PlatformBadge() {
  return (
    <span>{typeof window !== "undefined" ? "Browser" : "Server"}</span>
  );
}

La solución: defer a useEffect

Todo lo que deba diferir entre servidor y cliente debe calcularse después de la hidratación — dentro de useEffect, que solo se ejecuta en el navegador.

tsx
// ✅ Calcular el valor solo-cliente después de la hidratación
"use client";

import { useState, useEffect } from "react";

export default function LastUpdated() {
  const [time, setTime] = useState<string | null>(null);

  useEffect(() => {
    setTime(new Date().toLocaleTimeString());
  }, []);

  if (time === null) return <p>Loading...</p>;
  return <p>Page loaded at: {time}</p>;
}
tsx
// ✅ Generar IDs estables con useId (React 18+)
"use client";

import { useId } from "react";

export default function StableId() {
  const id = useId();
  return <div id={id}>Some content</div>;
}
tsx
// ✅ Detección solo-cliente que no rompe la hidratación
"use client";

import { useState, useEffect } from "react";

export default function PlatformBadge() {
  const [isClient, setIsClient] = useState(false);

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

  // Renderizar lo mismo en el servidor y durante la hidratación
  if (!isClient) return <span>Loading...</span>;
  return <span>Browser</span>;
}

El punto clave: en el renderizado inicial, devuelve algo que coincida con lo que el servidor produciría. Solo después de que useEffect se dispare — ya pasada la hidratación de forma segura — actualiza al contenido específico del cliente.

React 18 introdujo useId() para generar IDs que son estables entre servidor y cliente. Si necesitas un identificador único para elementos del DOM (etiquetas de formulario, atributos ARIA), usa useId() en lugar de Math.random().

¿Pueden las extensiones del navegador causar errores de hidratación?

Este es engañoso porque no es tu código en absoluto — es una extensión del navegador que inyecta elementos en el DOM antes de que React hidrate.

Extensiones como ColorZilla, Grammarly, gestores de contraseñas y herramientas de traducción frecuentemente inyectan <div>, <style> o nodos de atributos en la página. React encuentra estos nodos inesperados durante la hidratación y entra en pánico.

El error a menudo se ve así:

Warning: Expected server HTML to contain a matching <div> in <body>.

O el elemento completo <html> o <body> muestra una discrepancia.

Puedes reproducirlo abriendo tu app en una ventana de incógnito. Si el error desaparece, una extensión del navegador es la culpable.

La solución: suppressHydrationWarning

Para elementos que las extensiones comúnmente atacan — <html>, <body> o <div>s contenedores — puedes decirle a React que ignore las discrepancias en ese elemento específico:

tsx
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body suppressHydrationWarning>
        {children}
      </body>
    </html>
  );
}

Ten en cuenta que suppressHydrationWarning solo suprime advertencias para el elemento al que se aplica — no para sus hijos. No silencia errores en cascada más profundos en el árbol. Úsalo con moderación, solo en elementos donde se espera la inyección de extensiones.

Para contenido dentro de tus componentes que legítimamente difiere entre servidor y cliente (fechas formateadas por locale, datos específicos del usuario), el patrón de useEffect del Patrón 1 es la solución correcta — no suppressHydrationWarning.

¿Cómo causa errores de hidratación el anidamiento HTML inválido?

React replica las reglas de parseo HTML del navegador. Cuando escribes anidamiento HTML inválido — estructura que el parser del navegador auto-corregiría — el servidor y el cliente terminan con árboles DOM diferentes aunque hayas escrito el mismo JSX.

El navegador es permisivo de maneras que crean discrepancias silenciosas.

Patrones comunes de anidamiento inválido:

tsx
// ❌ <p> no puede contener elementos de bloque
export default function Article() {
  return (
    <p>
      Some text
      <div>A block element inside a paragraph</div>
    </p>
  );
}
tsx
// ❌ <a> no puede anidarse dentro de otro <a>
export default function Navigation() {
  return (
    <a href="/home">
      Home
      <a href="/home/sub">Sub-page</a>  {/* Anidamiento inválido */}
    </a>
  );
}
tsx
// ❌ <ul>/<ol> solo puede contener <li> como hijos directos
export default function List() {
  return (
    <ul>
      <div>  {/* Inválido — div directamente en ul */}
        <li>Item one</li>
        <li>Item two</li>
      </div>
    </ul>
  );
}
tsx
// ❌ <table> tiene requisitos estrictos de modelo de contenido
export default function DataTable() {
  return (
    <table>
      <tr>  {/* tr debe estar dentro de thead/tbody/tfoot */}
        <td>Cell</td>
      </tr>
    </table>
  );
}

Las soluciones:

tsx
// ✅ Usa <div> o <section> en lugar de <p> para contenido de bloque
export default function Article() {
  return (
    <div>
      Some text
      <div>A block element, now valid</div>
    </div>
  );
}
tsx
// ✅ Sin anclas anidadas — usa un botón o una estructura diferente
export default function Navigation() {
  return (
    <div>
      <a href="/home">Home</a>
      <a href="/home/sub">Sub-page</a>
    </div>
  );
}
tsx
// ✅ Estructura correcta de tabla
export default function DataTable() {
  return (
    <table>
      <tbody>
        <tr>
          <td>Cell</td>
        </tr>
      </tbody>
    </table>
  );
}

El validador HTML del W3C es una buena verificación cuando sospechas problemas de anidamiento. Pega tu HTML renderizado y señalará problemas estructurales que causan discrepancias de hidratación.

El navegador auto-corrige el anidamiento inválido durante el parseo, pero las correcciones difieren entre el parseo del lado del servidor (que usa Next.js) y el parseo del lado del cliente. Estas diferencias se manifiestan como errores de hidratación aunque ambos lados ejecuten exactamente el mismo JSX.

¿Qué pasa cuando usas APIs del navegador durante el renderizado?

window, document, localStorage, navigator y todas las demás APIs exclusivas del navegador lanzan un ReferenceError cuando se accede en el servidor — porque no existen en Node.js. Cuando tu componente las accede en tiempo de renderizado, el servidor se cae y la página ni siquiera puede hidratarse.

El problema de hidratación relacionado ocurre cuando proteges estas llamadas con typeof window !== 'undefined' — que se evalúa de forma diferente en servidor (false) y cliente (true), produciendo salidas de renderizado diferentes.

La versión rota:

tsx
// ❌ window no existe en el servidor
"use client";

export default function ThemeToggle() {
  // Esto se cae durante el renderizado del servidor
  const saved = localStorage.getItem("theme");
  const [theme, setTheme] = useState(saved ?? "dark");

  return (
    <button onClick={() => setTheme(t => t === "dark" ? "light" : "dark")}>
      Current: {theme}
    </button>
  );
}
tsx
// ❌ Renderizado condicional basado en verificación de window — discrepancia servidor/cliente
"use client";

export default function WindowSize() {
  const width = typeof window !== "undefined" ? window.innerWidth : 0;

  return <p>Window width: {width}px</p>;
  // Servidor renderiza: "Window width: 0px"
  // Cliente renderiza: "Window width: 1440px"
  // ¡Discrepancia de hidratación!
}

La solución: siempre usa useEffect para acceso a APIs del navegador

tsx
// ✅ Leer localStorage después del montaje
"use client";

import { useState, useEffect } from "react";

export default function ThemeToggle() {
  const [theme, setTheme] = useState<"dark" | "light">("dark");

  useEffect(() => {
    // Seguro: solo se ejecuta en el navegador, después de la hidratación
    const saved = localStorage.getItem("theme") as "dark" | "light" | null;
    if (saved) setTheme(saved);
  }, []);

  const toggle = () => {
    setTheme((t) => {
      const next = t === "dark" ? "light" : "dark";
      localStorage.setItem("theme", next);
      return next;
    });
  };

  return (
    <button onClick={toggle}>
      Current: {theme}
    </button>
  );
}
tsx
// ✅ Dimensiones de ventana con inicialización adecuada
"use client";

import { useState, useEffect } from "react";

export default function WindowSize() {
  const [width, setWidth] = useState<number | null>(null);

  useEffect(() => {
    setWidth(window.innerWidth);

    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  // Renderizado del servidor e inicial del cliente: no muestra nada (o un skeleton)
  if (width === null) return <p>Window width: —</p>;
  return <p>Window width: {width}px</p>;
}

Para componentes que son completamente exclusivos del navegador y nunca deberían renderizarse en el servidor, usa dynamic de Next.js con ssr: false:

tsx
// ✅ Omitir SSR completamente para componentes solo-navegador
import dynamic from "next/dynamic";

const MapComponent = dynamic(() => import("./MapComponent"), {
  ssr: false,
  loading: () => <div>Loading map...</div>,
});

export default function Page() {
  return (
    <main>
      <h1>Location</h1>
      <MapComponent />  {/* Nunca se renderiza en el servidor */}
    </main>
  );
}

Esto es particularmente útil para componentes que dependen de window, WebGL, canvas u otras APIs exclusivas del navegador donde no hay un fallback significativo para renderizar en el servidor.

¿Cómo depurar un error de hidratación en Next.js?

Leer el error, usar suppressHydrationWarning correctamente y localizar la fuente rápidamente.

Leer el mensaje de error

Los errores de hidratación de Next.js tienen una estructura específica una vez que sabes cómo parsearlos:

Warning: Prop `className` did not match.
  Server: "theme-dark"
  Client: "theme-light"

Esto te dice el nombre de la prop (className), lo que el servidor produjo (theme-dark) y lo que el cliente produjo (theme-light). Eso suele ser suficiente para encontrar al culpable — algo está calculando theme de manera diferente según el entorno.

Warning: Expected server HTML to contain a matching <div> in <p>.

Esto te dice sobre anidamiento inválido: un <div> apareció dentro de un <p>, que el navegador auto-corrigió de manera diferente al parser del servidor.

Error: Hydration failed because the initial UI does not match what was rendered on the server.

Este es el error genérico. Agrega --inspect a tu proceso Node.js y revisa el stack trace completo para encontrar qué árbol de componentes es el responsable.

Búsqueda binaria en el árbol de componentes

Cuando no puedes determinar qué componente está causando la discrepancia:

tsx
// Agrega temporalmente suppressHydrationWarning para acotar progresivamente
export default function SuspectLayout({ children }: { children: React.ReactNode }) {
  return (
    <div suppressHydrationWarning>
      {children}
    </div>
  );
}

Si agregarlo a un padre silencia el error, el culpable está en algún lugar de ese subárbol. Quítalo y agrégalo más profundo hasta que aísles el componente exacto.

Usar suppressHydrationWarning correctamente

suppressHydrationWarning es legítimo para casos específicos. Úsalo cuando:

  • El contenido difiere intencionalmente (marcas de tiempo que se actualizan, datos específicos del usuario)
  • El elemento es objetivo de extensiones del navegador que no puedes controlar
  • Tienes una biblioteca de terceros confirmada que causa una discrepancia puntual
tsx
// ✅ Uso legítimo: marca de tiempo que es intencionalmente diferente servidor/cliente
export default function Footer() {
  return (
    <footer>
      <time suppressHydrationWarning dateTime={new Date().toISOString()}>
        {new Date().toLocaleDateString()}
      </time>
    </footer>
  );
}

No lo uses como supresión general para errores que no entiendes. La advertencia existe para revelar problemas reales.

Específico de Next.js: verifica la frontera de "use client"

En App Router, una fuente común de errores de hidratación son Server Components que accidentalmente dependen del estado del cliente, o Client Components que no están marcados correctamente:

tsx
// ❌ Server Component usando un hook solo-cliente
// app/components/UserGreeting.tsx (sin "use client")
import { useState } from "react"; // Esto dará error en tiempo de build

export default function UserGreeting() {
  const [name] = useState("Guest");
  return <h1>Hello, {name}</h1>;
}
tsx
// ✅ Correcto: marcar como Client Component
"use client";

import { useState } from "react";

export default function UserGreeting() {
  const [name] = useState("Guest");
  return <h1>Hello, {name}</h1>;
}

La frontera importa: todo dentro de un subárbol de Client Component se hidrata. Todo en un Server Component se renderiza una vez en el servidor y es HTML estático en el cliente.

Un diagnóstico rápido: si estás viendo un error de hidratación y usas App Router, verifica si el componente tiene "use client" al inicio. Los hooks (useState, useEffect, useRef) requieren "use client". Si están en un archivo sin él, Next.js lanzará un error durante el build o en tiempo de ejecución.

Conclusión

Los errores de hidratación parecen misteriosos al principio, pero todos provienen de la misma causa raíz: el servidor y el cliente producen HTML diferente. Una vez que reconoces eso, cada patrón se vuelve predecible.

Aquí tienes la referencia completa:

PatrónCausaSolución
Date.now() / Math.random() al renderizarValores diferentes en cada ejecuciónMover a useEffect, usar useId() para IDs
Renderizado condicional con typeof windowDevuelve valores diferentes servidor vs. clienteInicializar como null, establecer en useEffect
Inyección de extensión del navegadorLa extensión modifica el DOM antes de la hidrataciónsuppressHydrationWarning en <html>/<body>
Anidamiento HTML inválidoEl navegador auto-corrige diferente al servidorCorregir anidamiento: sin <div> en <p>, sin <a> anidados
localStorage / window al renderizarLas APIs del navegador no existen en Node.jsMover a useEffect o usar dynamic({ ssr: false })
Falta "use client"Hooks usados en Server ComponentAgregar directiva "use client"

El flujo de depuración que ahorra más tiempo:

  1. Lee el mensaje de error — usualmente nombra la prop o elemento que no coincidió
  2. Prueba en modo incógnito para descartar extensiones del navegador
  3. Usa suppressHydrationWarning como herramienta de búsqueda binaria para acotar el subárbol
  4. Busca typeof window, Date, Math.random() y localStorage en el componente sospechoso
  5. Mueve cualquier lógica específica del navegador a useEffect

Una vez que hayas solucionado algunos de estos, desarrollas un instinto para lo que causará o no causará discrepancias. La división servidor/cliente se convierte en un modelo mental que llevas naturalmente — y los errores dejan de ser sorpresas.