Cómo solucionar el error "Text content does not match server-rendered HTML" en Next.js App Router
El mensaje *“Text content does not match server-rendered HTML”* indica una inconsistencia entre el HTML generado en el servidor y el que React intenta “hidratar...
Listen to Article
PlayingClick play to listen to audio narration
Table of Contents
- •Cómo solucionar el error “Text content does not match server-rendered HTML” en Next.js App Router
- •Introduction
- •Why This Matters
- •How It Works
- •Core Concepts
- •Examples & Code Walkthrough
- •1. Uso de Date.now() directamente en el render
- •2. Lectura de window.innerWidth para diseños responsivos
- •3. IDs generados aleatoriamente para listas de elementos
- •Best Practices
- •Common Mistakes & Anti-Patterns
- •Performance Considerations
- •Real-World Usage
- •Frequently Asked Questions (FAQ)
Cómo solucionar el error “Text content does not match server-rendered HTML” en Next.js App Router
Introduction
El mensaje “Text content does not match server-rendered HTML” indica una inconsistencia entre el HTML generado en el servidor y el que React intenta “hidratar” en el cliente. En el App Router de Next.js, donde los Server Components son la unidad básica de renderizado, este tipo de fallos aparecen con frecuencia cuando el código asume la existencia de APIs del navegador o valores no determinísticos durante la fase de renderizado del servidor.
Why This Matters
Una discrepancia de hidratación obliga a React a descartar el markup del servidor y volver a renderizar todo el árbol en el cliente. Esto provoca:
- Un salto visual perceptible (flash de contenido no estilizado).
- Trabajo extra en la CPU del navegador, afectando el Interaction to Next Paint (INP).
- Posibles problemas de SEO si los rastreadores ven un contenido diferente al que los usuarios experimentan. Entender y corregir la raíz del mismatch mejora tanto la experiencia de usuario como la fiabilidad de la aplicación en producción.
How It Works
Durante la navegación en el App Router, Next.js sigue este flujo:
flowchart TD
subgraph Server["Renderizado en el Servidor (RSC)"]
A[Inicio de la petición] --> B{Ejecución de Server Component}
B --> C[Acceso a datos (cookies, headers, DB)]
C --> D[Generación de markup HTML estático]
D --> E[Stream de HTML hacia el cliente]
end
subgraph Cliente["Hidratación en el Navegador"]
E --> F[Recepción del stream]
F --> G[Construcción del árbol DOM inicial]
G --> H[Descarga del bundle de JavaScript del cliente]
H --> I[Ejecución de Client Components y hooks]
I --> J[Comparación del Virtual DOM con el HTML recibido]
J --> K{¿Coincide el contenido de texto?}
K -- Sí --> L[Hidratación exitosa]
K -- No --> M[Detección de mismatch]
M --> N[React marca nodo como dirty]
N --> O[Re-render del subtree afectado en cliente]
O --> P[Actualización del DOM con markup cliente]
end
subgraph Mitigación["Patrones de solución"]
Q[useEffect / useState con typeof window] --> I
R[Dynamic import (next/dynamic)] --> H
S[suppressHydrationWarning (uso puntual)] --> J
end
Explicación del diagrama
- Servidor: Los Server Components se ejecutan sin acceso a
windowodocument. Si el componente depende de valores comoDate.now()oMath.random(), el HTML resultante variará en cada request. - Stream: Next.js envía el HTML en chunks; el cliente comienza a montar el DOM tan pronto recibe el primer fragmento.
- Hidratación: React asume que el markup que recibe coincide exactamente con el que produciría al ejecutar el mismo código en el cliente.
- Mismatch: Cuando la comparación falla, React marca el nodo como “dirty” y ejecuta un segundo pase de renderizado solo en el cliente, descartando el markup del servidor.
- Mitigación: Los patrones mostrados evitan que se genere HTML diferente en servidor y cliente, o bien aislan la parte que sí varía para que se ejecute únicamente después de la hidratación.
Core Concepts
- Server Component (RSC): Se renderiza únicamente en el servidor, nunca se envía al bundle del cliente. Su salida debe ser totalmente determinista.
- Client Component: Se ejecuta tanto en el servidor (para la generación inicial de HTML) como en el cliente (para hidratación y interactividad). Cualquier lógica que dependa del entorno del navegador debe protegerse.
- Hidratación: Proceso mediante el cual React “asume” el DOM existente y le asigna manejadores de eventos, estado y refs.
- Mismatch de texto: Ocurre cuando el contenido textual de un nodo del DOM no coincide con el esperado por React durante la fase de comparación.
- Streaming y Suspense: Next.js puede enviar partes del HTML antes de que otras estén listas; los límites de
loading.tsxo<Suspense>permiten que el cliente muestre UI de espera mientras espera datos.
Examples & Code Walkthrough
A continuación se muestran tres situaciones típicas que generan el mismatch y su solución correspondiente.
1. Uso de Date.now() directamente en el render
Código problemático
// app/dashboard/page.tsx <-- Server Component por defecto
export default function Dashboard() {
return <span>Última actualización: {Date.now()}</span>;
}
Problema: En el servidor se genera una marca de tiempo; en el cliente, al hidratar, React ejecuta nuevamente la función y obtiene un valor distinto (unos milisegundos después).
Solución: Mover la lógica de tiempo al cliente mediante useEffect.
'use client';
import { useEffect, useState } from 'react';
export default function Dashboard() {
const [timestamp, setTimestamp] = useState<number | null>(null);
useEffect(() => {
setTimestamp(Date.now());
}, []);
return <span>Última actualización: {timestamp ?? 'cargando…'}</span>;
}
Notas: El componente se marca como 'use client' para que se ejecute en el cliente. Durante la primera renderización en el servidor, timestamp es null, por lo que el HTML del servidor contiene el texto de placeholder. Después de la hidratación, el efecto actualiza el estado y React vuelve a renderizar con el valor real.
2. Lectura de window.innerWidth para diseños responsivos
Código problemático
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
const isMobile = window.innerWidth < 640;
return (
<html lang="es">
<body>
{isMobile ? <MobileNav /> : <DesktopNav />}<br />
{children}
</body>
</html>
);
}
Problema: window no está definido durante el renderizado del servidor, lanzando una excepción y rompiendo el streaming de HTML. Incluso si se protege con typeof window, el valor de isMobile será false en el servidor y posiblemente true en el cliente, creando un mismatch.
Solución: Retrasar la detección hasta después de la hidratación usando una hook personalizada.
'use client';
import { useEffect, useState } from 'react';
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 640);
check();
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
return isMobile;
}
// En el layout
export default function RootLayout({ children }: { children: React.ReactNode }) {
const isMobile = useIsMobile();
return (
<html lang="es">
<body>
{isMobile ? <MobileNav /> : <DesktopNav />}<br />
{children}
</body>
</html>
);
}
Notas: En el servidor, useIsMobile devuelve false (valor inicial) y el HTML contiene el DesktopNav. Tras la hidratación, el efecto se ejecuta, actualiza el estado y React vuelve a renderizar con el nav correcto. El parpadeo es mínimo porque el placeholder es razonable.
3. IDs generados aleatoriamente para listas de elementos
Código problemático
// app/blog/page.tsx
export default function BlogList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map(post => (
<li key={Math.random()}> {/* ¡Nunca usar Math.random() en render! */}
{post.title}
</li>
))}
</ul>
);
}
Problema: Cada renderizado en el servidor genera un conjunto distinto de claves; al hidratar, React ve que las claves del DOM no coinciden con las del nuevo árbol y tira una advertencia de mismatch, además de causar re-renders innecesarios.
Solución: Usar un identificador estable proveniente de los datos (por ejemplo, el id de la base de datos) o generar un ID determinista en tiempo de construcción.
export default function BlogList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>
{post.title}
</li>
))}
</ul>
);
}
Si no existe un ID natural, se puede crear uno basado en un hash del contenido:
function stableHash(str: string) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0; // Convert to 32-bit integer
}
return hash;
}
// Uso
<li key={stableHash(post.title + post.date)}>
Best Practices
- Separar claramente Server y Client Components: Marca con
'use client'solo aquellos componentes que realmente necesiten interactividad o acceso a APIs del navegador. - Hacer que el render del servidor sea determinista: Evita
Date.now(),Math.random(),window,localStorage, y cualquier valor que pueda cambiar entre request. - Usar
useEffectpara efectos del lado del cliente: Estado que dependa del navegador debe inicializarse con un valor seguro y actualizarse dentro de un efecto. - Aprovechar
loading.tsxy<Suspense>: Mientras se obtienen datos asincrónicos, muestra una UI de espera estable en lugar de renderizar contenido que pueda cambiar. - Limitar el uso de
suppressHydrationWarning: Solo úsalo cuando hayas confirmado que el mismatch es intencional y no afecta la semántica (por ejemplo, marcas de tiempo que se actualizan inmediatamente después de la hidratación). Documenta el motivo con un comentario. - Prefiere claves estables en listas: Usa IDs de base de datos o valores derivados de forma determinista del contenido.
- Testea en modo de desarrollo con
next dev: React avisa de mismatches en la consola; corrígelos antes de hacer push a producción.
Common Mistakes & Anti-Patterns
| Anti‑patrón | Por qué falla | Corrección |
|---|---|---|
Acceso directo a window o document en el cuerpo del componente | Undefined en el servidor → excepción o HTML roto. | Envuelve el acceso en useEffect o usa una hook que devuelva false/null hasta que el cliente esté listo. |
Estado inicial basado en navigator.userAgent | El agente puede diferir entre prerender y cliente (por ejemplo, con middleware que reescribe el UA). | Detecta características de pantalla con matchMedia dentro de un efecto, o delega la lógica a CSS (media queries). |
Renderizado condicional basado en cookies leídas con cookies() en un Client Component | cookies() solo funciona en Server Components; llamarlo en un cliente lanza error y el HTML del servidor omite el bloque. | Lee la cookie en un Server Component y pasa el valor como prop al Client Component. |
Uso de Math.random() para generar claves o contenido | Cada render produce un valor distinto → mismatch garantizado. | Sustituye por un ID estable o por un hash determinista del dato. |
Dependencia de Date.now() para mostrar timestamps | El timestamp del servidor se queda atrás respecto al cliente. | Muestra una fecha relativa (time ago) calculada en el cliente, o envía la timestamp exacta desde el servidor y deja que el cliente la formatee. |
Performance Considerations
- Costo de la rehidratación: Cuando ocurre un mismatch, React descarta el markup del servidor y vuelve a renderizar el subárbol en el cliente. Esto implica trabajo extra de CPU y tiempo de bloqueo del hilo principal. En dispositivos de gama baja, el impacto puede ser perceptible como un retraso de 10‑30 ms por nodo afectado.
- Efecto en métricas de velocidad: Un aumento en el tiempo de JavaScript ejecutado antes de que el pintado sea estable puede elevar el Interaction to Next Paint (INP) y el Total Blocking Time (TBT). Mantener los mismatches a cero ayuda a mantener esas métricas dentro de los umbrales recomendados por Core Web Vitals.
- Uso de memoria: Cada renderizado adicional crea nuevas instancias de fibras y nodos del DOM temporalmente. En listas largas, la duplicación de trabajo puede incrementar el consumo de memoria de forma transitoria.
- Optimización mediante Streaming: Si el mismatch ocurre dentro de un límite de
Suspense, solo el fragmento afectado se vuelve a renderizar, lo que reduce el coste. Por tanto, agrupar componentes propenso a mismatches dentro de sus propios límites de suspense es una técnica eficaz.
Real-World Usage
Empresas que manejan tráfico masivo en Next.js han adoptado patrones similares para evitar mismatches:
- Netflix: En sus paneles de analytics, utilizan Server Components para obtener datos de visualización y Client Components con
useEffectpara actualizar contadores en tiempo real, asegurando que el HTML inicial sea estático y cachable. - Uber: En el flujo de reserva de viajes, el mapa se renderiza en un Client Component que se importa dinámicamente (
next/dynamicconssr: false). El servidor entrega un placeholder mientras el bundle del mapa se descarga, evitando cualquier intento de acceder agoogle.mapsen el servidor. - Cloudflare: Sus dashboards de logs usan
useIsMediaMatchhooks (similar al ejemplo dewindow.innerWidth) para cambiar entre vistas de escritorio y móvil, manteniendo el HTML del servidor neutro y luego aplicando la clase correcta tras la hidratación.
Frequently Asked Questions (FAQ)
P: ¿Puedo ignorar el error si solo ocurre en modo de desarrollo?
R: No. Aunque la aplicación pueda seguir funcionando, el mismatch indica que el HTML del servidor y del cliente son diferentes. En producción, eso se traduce en trabajo extra y posibles inconsistencias de SEO. Siempre trata de eliminar la causa raíz.
P: ¿Qué pasa si uso suppressHydrationWarning en un contenedor grande?
R: React dejará de avisar, pero seguirá ejecutando el proceso de recuperación (marking dirty y re‑render). No elimina el coste de rendimiento; simplemente oculta la señal de advertencia. Úsalo solo en nodos muy específicos y después de haber verificado que la diferencia es inofensiva (por ejemplo, un timestamp que se actualiza inmediatamente después de la hidratación).
**P: ¿Mi middleware que modifica las cookies puede causar
Written by Lead Frontend & Web Architect
Editorial staff persona leading coverage on modern web architectures, state management, web performance optimization, and client-side framework engineering.