SSR, hidratación y streaming en React | Nicolás Garzón
Server-side rendering genera HTML en el servidor; hidratación conecta ese HTML con el árbol interactivo del cliente; streaming permite entregar el documento por partes. Son procesos relacionados, pero resuelven problemas diferentes y pueden ocurrir en momentos distintos.
En una aplicación renderizada únicamente en el cliente, el navegador suele recibir primero un documento HTML mínimo:
HTML
Copiar < div id = " root" > </ div>
< script type = " module" src = " /src/main.tsx" > </ script> Después debe descargar JavaScript, ejecutarlo, obtener datos y construir la interfaz:
Texto
Copiar HTML mínimo
↓
descargar JavaScript
↓
ejecutar React
↓
cargar datos
↓
renderizar contenidoMientras ocurre ese trabajo, el usuario puede ver una pantalla vacía o un loading inicial.
Con server-side rendering, React ejecuta el árbol en el servidor y produce HTML que ya contiene una representación inicial de la pantalla:
Texto
Copiar request
↓
servidor ejecuta React
↓
HTML inicial
↓
navegador muestra contenido
↓
JavaScript se descarga e hidrata
La aparición inicial de contenido.
La capacidad de buscadores y consumidores que leen HTML.
La experiencia en redes o dispositivos lentos.
La posibilidad de enviar metadatos y estructura desde el servidor.
SSR no garantiza por sí solo una aplicación rápida. Un servidor lento, JavaScript excesivo, datos en cascada o una hidratación costosa pueden eliminar parte del beneficio.
Texto
Copiar servidor
├─ ejecuta componentes
├─ obtiene un árbol React
└─ genera HTML
↓
red
↓
navegador
├─ analiza y muestra HTML
├─ descarga JavaScript
├─ ejecuta el primer render cliente
└─ hidrata el DOM existente
↓
aplicación interactivaEl HTML inicial es una fotografía. No contiene automáticamente el estado vivo de React, sus handlers ni todos los módulos necesarios para futuras interacciones.
Estos conceptos no son equivalentes.
El navegador genera la interfaz inicial mediante React:
TypeScript
Copiar import { createRoot } from "react-dom/client" ;
createRoot ( document. getElementById ( "root" ) ! ) . render ( < App / > ) ; Usa createRoot cuando el contenedor no posee HTML generado previamente por React.
El servidor genera HTML para una petición y el cliente lo hidrata:
Texto
Copiar request → render servidor → HTML → hydrateRootNormalmente se calcula por solicitud, aunque una plataforma puede aplicar caché.
El HTML se genera antes de recibir la petición, por ejemplo durante un build o proceso de publicación:
Texto
Copiar build → HTML estático → CDN → navegadorPuede hidratarse igual que una página SSR si necesita interactividad.
Los Server Components producen una representación del árbol desde el servidor y permiten excluir su implementación del bundle cliente. No son simplemente “SSR mejorado”.
Texto
Copiar SSR → genera HTML inicial
RSC → divide componentes servidor/cliente y transporta una representaciónUn Client Component puede participar en SSR. Un Server Component no se hidrata como una instancia interactiva porque su implementación no viaja al navegador.
Las APIs de react-dom/server se ejecutan en el servidor, normalmente en el punto de entrada. Un framework suele invocarlas por ti.
Para entornos basados en Node.js, la API principal de streaming es:
TypeScript
Copiar import { renderToPipeableStream } from "react-dom/server" ; TypeScript
Copiar import express from "express" ;
import { renderToPipeableStream } from "react-dom/server" ;
import App from "./App" ;
const app = express ( ) ;
app. get ( "*" , ( request, response) => {
let didError = false ;
const { pipe, abort } = renderToPipeableStream (
< App url= { request. url} / > ,
{
bootstrapScripts: [ "/client.js" ] ,
onShellReady ( ) {
response. statusCode = didError ? 500 : 200 ;
response. setHeader ( "content-type" , "text/html" ) ;
pipe ( response) ;
} ,
onShellError ( error) {
response. statusCode = 500 ;
response. send ( "<!doctype html><p>No fue posible cargar la página.</p>" ) ;
} ,
onError ( error) {
didError = true ;
console . error ( error) ;
} ,
} ,
) ;
request. on ( "close" , ( ) => abort ( ) ) ;
} ) ; Este ejemplo muestra el contrato general, no una infraestructura completa. En producción también debes gestionar headers, compresión, caché, bots, timeouts, seguridad, estáticos y logging.
En runtimes con Web Streams puedes utilizar:
TypeScript
Copiar import { renderToReadableStream } from "react-dom/server" ;
async function handleRequest ( ) {
const stream = await renderToReadableStream ( < App / > , {
bootstrapScripts: [ "/client.js" ] ,
} ) ;
return new Response ( stream, {
headers: {
"content-type" : "text/html; charset=utf-8" ,
} ,
} ) ;
} React 19.2 también ofrece estas APIs por compatibilidad en Node.js, pero la documentación oficial recomienda las APIs específicas de Node Streams allí por rendimiento y compresión.
TypeScript
Copiar import { renderToString } from "react-dom/server" ;
const html = renderToString ( < App / > ) ; renderToString genera un string completo, pero tiene soporte limitado de Suspense. Si un descendiente suspende, normalmente genera el fallback y no espera a que el contenido pendiente termine.
Para aplicaciones modernas con contenido progresivo, las APIs streaming suelen ser más apropiadas.
renderToStaticMarkup produce HTML no diseñado para hidratarse. Es útil para correos o documentos estáticos, no para una interfaz React interactiva.
En streaming, el shell es la parte del árbol que existe fuera de las boundaries de Suspense:
TypeScript
Copiar function ProductPage ( ) {
return (
< html lang= "es" >
< body>
< Header / >
< main>
< ProductSummary / >
< Suspense fallback= { < ReviewsSkeleton / > } >
< ProductReviews / >
< / Suspense>
< / main>
< / body>
< / html>
) ;
} En este caso, el shell incluye el documento, el encabezado, el resumen y el fallback de reseñas.
Texto
Copiar shell
├─ html
├─ Header
├─ ProductSummary
└─ ReviewsSkeletonCuando el shell está listo, el servidor puede empezar a responder sin esperar todo el árbol.
Conserva la estructura principal.
Permite comprender qué pantalla se está abriendo.
Evita un spinner global como único contenido.
Mantiene dimensiones estables.
Incluye navegación y contexto esencial.
Streaming divide la respuesta en fragmentos:
Texto
Copiar 1. shell y fallbacks
↓
2. contenido de una boundary
↓
3. contenido de otra boundary
↓
4. documento completadoTypeScript
Copiar function Dashboard ( ) {
return (
< main>
< h1> Panel principal< / h1>
< Suspense fallback= { < SummarySkeleton / > } >
< Summary / >
< / Suspense>
< Suspense fallback= { < OrdersSkeleton / > } >
< RecentOrders / >
< / Suspense>
< / main>
) ;
} Si Summary termina primero, el servidor puede enviar esa sección antes que RecentOrders.
El usuario obtiene progresivamente una pantalla más completa sin esperar que todo el servidor termine.
Suspense solo coordina fuentes compatibles. Entre ellas:
Componentes cargados mediante lazy.
Promises leídas mediante use.
Datos coordinados por frameworks compatibles con Suspense.
Contenido de Server Components transmitido mediante una integración compatible.
Este fetch no activa una boundary:
TypeScript
Copiar useEffect ( ( ) => {
fetch ( "/api/orders" ) . then ( ) ;
} , [ ] ) ; Los Effects no se ejecutan en el servidor y comienzan después de la hidratación en el cliente.
El navegador puede mostrar HTML recibido por streaming antes de descargar React:
Texto
Copiar HTML progresivo visible
≠
JavaScript listo
≠
hidratación completaUna sección puede estar visible pero todavía no responder a eventos. Esto se conoce como una brecha entre contenido visible e interactividad.
Por eso no debes asumir que “SSR significa interacción inmediata”.
La hidratación conecta un árbol React con DOM que ya existe:
TypeScript
Copiar import { hydrateRoot } from "react-dom/client" ;
import App from "./App" ;
hydrateRoot ( document, < App / > ) ; Para un contenedor parcial:
TypeScript
Copiar hydrateRoot (
document. getElementById ( "root" ) ! ,
< App / > ,
) ; React no vuelve a crear todo el DOM desde cero. Intenta relacionar los elementos del primer render cliente con el HTML generado por el servidor y añade la capacidad interactiva necesaria.
Texto
Copiar HTML existente
+
primer árbol cliente compatible
↓
React conecta handlers, state y refshydrateRoot espera que el cliente produzca el mismo contenido inicial que el servidor. Los mismatches deben tratarse como bugs.
TypeScript
Copiar function Clock ( ) {
return < p> { new Date ( ) . toLocaleTimeString ( ) } < / p> ;
} El servidor y el navegador pueden ejecutar en instantes, zonas horarias o locales distintos. El texto puede no coincidir.
Enviar el valor ya calculado como dato inicial.
Renderizar un formato determinista.
Mostrar una representación estable y actualizar después.
Resolver la fecha en el servidor y reutilizarla en el cliente.
TypeScript
Copiar type ClockProps = {
initialIsoDate: string ;
} ;
function Clock ( { initialIsoDate } : ClockProps) {
const [ date, setDate] = useState ( ( ) => new Date ( initialIsoDate) ) ;
useEffect ( ( ) => {
const id = window. setInterval ( ( ) => setDate ( new Date ( ) ) , 1000 ) ;
return ( ) => window. clearInterval ( id) ;
} , [ ] ) ;
return < time dateTime= { date. toISOString ( ) } > { formatTime ( date) } < / time> ;
} El valor inicial debe ser idéntico en ambos entornos.
El servidor y el cliente cargan snapshots diferentes:
Texto
Copiar server: user = Ana
client: user = nullTransfiere o rehidrata el mismo estado inicial cuando corresponda.
TypeScript
Copiar const id = Math. random ( ) ;
const createdAt = Date. now ( ) ; No los produzcas durante ambos renders esperando igualdad.
TypeScript
Copiar function ThemeLabel ( ) {
const isDark = window. matchMedia ( "(prefers-color-scheme: dark)" ) . matches;
return < p> { isDark ? "Oscuro" : "Claro" } < / p> ;
} window no existe en el servidor. Además, un check como este dentro del render tampoco garantiza HTML coincidente:
TypeScript
Copiar const isClient = typeof window !== "undefined" ; El servidor produciría una rama y el cliente otra.
TypeScript
Copiar const theme = localStorage. getItem ( "theme" ) ; Lee storage después del montaje o integra la preferencia mediante cookie/dato servidor cuando el HTML inicial debe respetarla.
El navegador puede corregir automáticamente una estructura inválida antes de que React hidrate:
HTML
Copiar < p> < div> Contenido</ div> </ p> El DOM resultante puede diferir del árbol esperado.
Whitespace añadido alrededor del root, extensiones del navegador o scripts que modifican el DOM pueden provocar diferencias.
Fechas, números y ordenaciones dependen del entorno. Usa una configuración explícita cuando necesites un resultado determinista.
useId genera identificadores compatibles con SSR e hidratación:
TypeScript
Copiar function EmailField ( ) {
const id = useId ( ) ;
return (
< div>
< label htmlFor= { id} > Correo< / label>
< input id= { id} name= "email" type= "email" / >
< / div>
) ;
} Si hidratas múltiples raíces en la misma página, puedes utilizar identifierPrefix y debes usar el mismo prefijo en servidor y cliente.
No uses useId como key. Las keys provienen de la identidad de los datos.
Para una diferencia inevitable y localizada:
TypeScript
Copiar < time suppressHydrationWarning>
{ new Date ( ) . toLocaleTimeString ( ) }
< / time> Es una salida de emergencia:
Solo silencia una diferencia limitada.
Funciona un nivel de profundidad.
No corrige inconsistencias estructurales.
React no garantiza reparar todo el contenido diferente.
Puede ocultar bugs reales si se usa ampliamente.
No lo coloques en un layout completo para apagar warnings.
Cuando realmente necesitas contenido distinto después de entrar al cliente:
TypeScript
Copiar function ClientOnlyInformation ( ) {
const [ isClient, setIsClient] = useState ( false ) ;
useEffect ( ( ) => {
setIsClient ( true ) ;
} , [ ] ) ;
return < p> { isClient ? "Contenido cliente" : "Contenido inicial estable" } < / p> ;
} El servidor y el primer render cliente muestran la misma rama. Después del Effect ocurre otro render.
Añade trabajo y otro render.
Puede producir un cambio visual.
En conexiones lentas, la UI inicial puede permanecer más tiempo.
No debe usarse como solución general a todo mismatch.
Suspense permite que React coordine la hidratación por regiones en lugar de exigir que todo el documento termine como una unidad indivisible.
Texto
Copiar shell visible
├─ Header hidratado
├─ Search boundary pendiente
└─ Feed boundary hidratándoseReact puede priorizar trabajo relevante para la interacción y continuar con otras boundaries después. Esta capacidad no significa que debas diseñar cientos de boundaries pequeñas. La granularidad debe corresponder a unidades visuales e interactivas coherentes.
Los Effects no se ejecutan al generar HTML en el servidor:
TypeScript
Copiar useEffect ( ( ) => {
console . log ( "Solo después de montar en cliente" ) ;
} , [ ] ) ;
No dependas de un Effect para producir contenido SEO inicial.
No cargues datos críticos únicamente allí si esperas HTML servidor completo.
Las suscripciones y listeners comienzan en cliente.
El cleanup solo existe después de que el setup fue ejecutado en cliente.
useLayoutEffect tampoco mide layout en el servidor.
Una biblioteca que usa layout puede necesitar una región cliente o un fallback estable.
El HTML puede presentar un botón antes de que su JavaScript esté listo:
TypeScript
Copiar < button onClick= { handlePurchase} > Comprar< / button> Diseña la experiencia considerando:
Tiempo hasta interacción.
Feedback al usuario.
Progressive enhancement mediante formularios o enlaces cuando sea posible.
Evitar controles visualmente listos cuyo código tarda demasiado.
Reducir el bundle de la ruta.
SSR mejora la aparición del contenido; reducir JavaScript mejora la llegada de la interacción.
Un error puede ocurrir antes o después de crear el shell.
Si falla contenido esencial fuera de Suspense, no existe una página mínima válida para transmitir. En Node, onShellError permite responder con un documento alternativo.
React puede transmitir el fallback de Suspense e intentar renderizar esa región nuevamente en cliente. Si también falla en cliente, una Error Boundary puede mostrar la recuperación correspondiente.
No muestres stack traces ni mensajes internos al usuario. Registra el error en servidor con un identificador de correlación.
Después de empezar a enviar bytes, normalmente ya no puedes cambiar el status code o headers.
Por eso debes decidir qué información pertenece al shell y qué errores pueden representarse dentro de la UI.
Texto
Copiar headers enviados
↓
stream iniciado
↓
status ya comprometidoUn framework suele coordinar redirects, not found, errores de ruta y boundaries antes de iniciar el stream.
Una petición puede cerrarse o superar un timeout. Debes abortar trabajo que ya no será entregado:
TypeScript
Copiar const { pipe, abort } = renderToPipeableStream ( < App / > , options) ;
setTimeout ( ( ) => {
abort ( ) ;
} , 10_000 ) ; El framework puede implementar esta política. Abortar evita mantener trabajo inútil, pero debes decidir qué fallback o respuesta recibe el usuario.
Las APIs de servidor permiten declarar scripts de arranque:
TypeScript
Copiar renderToPipeableStream ( < App / > , {
bootstrapModules: [ "/client.js" ] ,
nonce: cspNonce,
} ) ; El nonce debe coincidir con la Content Security Policy de la respuesta. No construyas HTML concatenando valores no confiables.
Una store externa necesita un snapshot de servidor consistente:
TypeScript
Copiar const value = useSyncExternalStore (
subscribe,
getSnapshot,
getServerSnapshot,
) ; getServerSnapshot debe producir en el cliente inicial el mismo valor utilizado durante SSR. De lo contrario puede existir mismatch o un cambio inmediato.
Un fallback forma parte de la experiencia real:
TypeScript
Copiar < section aria- busy= "true" aria- labelledby= "orders-heading" >
< h2 id= "orders-heading" > Pedidos recientes< / h2>
< OrdersSkeleton / >
< / section>
Mantener headings y landmarks.
Evitar que el foco desaparezca al reemplazar contenido.
No anunciar cada fragmento con role="alert".
Mantener tamaños estables para reducir saltos.
Comunicar pending sin bloquear navegación.
Preservar contenido previo mediante transitions cuando corresponda.
Los crawlers modernos pueden procesar contenido transmitido, pero el contenido esencial y los metadatos deben formar parte de una estrategia clara.
No coloques todo el contenido relevante detrás de una espera cliente iniciada en un Effect si necesitas que exista en el HTML inicial.
El framework puede manejar metadata, bots y streaming de forma específica; consulta su documentación.
SSR y streaming cambian dónde y cuándo ocurre el trabajo. No eliminan el coste.
Time to First Byte.
Tiempo de shell.
First Contentful Paint.
Largest Contentful Paint.
JavaScript descargado.
Tiempo hasta hidratación e interacción.
Long tasks durante hydration.
Errores y recoverable errors.
Uso de CPU y memoria del servidor.
Un TTFB peor puede compensarse con HTML más útil, pero también puede retrasar todo. Evalúa la experiencia completa.
hydrateRoot acepta callbacks de errores:
TypeScript
Copiar hydrateRoot ( document, < App / > , {
onRecoverableError ( error, errorInfo) {
reportHydrationError ( { error, componentStack: errorInfo. componentStack } ) ;
} ,
onUncaughtError ( error, errorInfo) {
reportClientError ( { error, componentStack: errorInfo. componentStack } ) ;
} ,
} ) ; No reemplaces el logging por un callback que silencie errores. Registra release, ruta y contexto sin enviar información sensible.
React 19.2 incorpora APIs para pre-renderizar una parte estática y reanudar el trabajo dinámico después:
Texto
Copiar prerender
↓
prelude estático + estado pospuesto
↓
CDN o almacenamiento
↓
resume en una petición posterior
↓
stream SSR dinámicoEstas APIs son avanzadas y normalmente las integra un framework. No las presentes como requisito de una aplicación React común.
En Node.js, React recomienda las variantes específicas de Node Streams para este flujo.
En aplicaciones reales, un framework suele encargarse de:
Elegir la API de servidor.
Routing.
Carga y caché de datos.
Streaming y Suspense.
Scripts y assets.
Hydration.
Server Components.
Headers, redirects y status.
Runtime y deployment.
Comprender React base permite diagnosticar problemas, pero la configuración concreta pertenece al framework. No conviertas una regla de Next.js, Remix u otro router en una regla universal de React.
Contenido que debe aparecer pronto.
Rutas públicas y contenido indexable.
Dispositivos o redes donde una pantalla vacía inicial sería costosa.
Aplicaciones con framework que integra datos y streaming.
Experiencias que pueden entregar una shell útil antes de completar todo.
Dashboards internos pequeños detrás de autenticación.
Widgets embebidos.
Aplicaciones totalmente locales.
Productos estáticos que pueden usar generación estática.
Equipos sin necesidad ni infraestructura para operar servidor React.
Una SPA no es automáticamente una mala arquitectura. Elige según producto, datos, despliegue y experiencia.
Lee el warning completo y localiza la rama.
Compara HTML del servidor con el primer render cliente.
Busca fechas, random, locale y browser APIs.
Revisa datos iniciales y stores.
Valida la estructura HTML.
Desactiva temporalmente extensiones que modifican DOM.
Reduce el componente hasta aislar la diferencia.
Corrige la causa; no empieces con suppressHydrationWarning.
Mide el bundle de la ruta.
Identifica long tasks en Performance.
Revisa Client Components demasiado amplios.
Reduce dependencias cliente innecesarias.
Divide código en fronteras útiles.
Revisa effects costosos al montar.
Comprueba scripts de terceros.
Mide en un dispositivo representativo.
Confundir HTML visible con aplicación interactiva.
Usar createRoot sobre HTML que debía hidratarse.
Usar hydrateRoot en una SPA sin HTML servidor.
Renderizar fechas, random o storage directamente en el primer render.
Usar typeof window para producir dos árboles distintos.
Ocultar warnings con suppressHydrationWarning.
Creer que un Effect se ejecuta en servidor.
Presentar renderToString como estrategia moderna completa de Suspense.
Colocar una Suspense boundary global cuyo shell solo es un spinner.
No abortar renders de peticiones cerradas.
Ignorar la brecha entre contenido y eventos.
Confundir SSR con Server Components.
Implementar manualmente infraestructura que el framework ya coordina.
SSR genera HTML; hidratación conecta React; streaming entrega HTML progresivamente.
El primer render cliente debe coincidir con el resultado del servidor.
createRoot inicia una raíz cliente; hydrateRoot reutiliza HTML React existente.
Streaming puede mostrar contenido antes de que React sea interactivo.
Suspense define unidades de revelado y puede coordinar hidratación selectiva.
Effects no se ejecutan durante SSR.
Los mismatches son bugs, no warnings decorativos.
suppressHydrationWarning es una salida limitada.
Node Streams son la opción recomendada para SSR en Node.js.
SSR, SSG y Server Components son modelos distintos que pueden combinarse.
Un framework suele administrar la infraestructura concreta.
¿Qué diferencia existe entre renderizar HTML y hacer que sea interactivo?
¿Por qué Math.random() durante render puede romper hidratación?
¿Qué representa el shell durante streaming?
¿Por qué un fetch dentro de useEffect no activa Suspense en SSR?
¿Cuándo debes usar hydrateRoot en lugar de createRoot?
¿Por qué una UI transmitida puede verse antes de responder a clicks?
¿Qué diferencia existe entre SSR y Server Components?
¿Por qué no conviene silenciar un árbol completo con suppressHydrationWarning?
Ver respuestas
El HTML puede mostrarse sin tener handlers, state ni módulos cliente conectados; la hidratación añade esas capacidades.
Porque servidor y cliente generan valores distintos y el primer árbol no coincide con el DOM existente.
La estructura mínima fuera de Suspense, incluyendo los fallbacks, que debe estar lista antes de iniciar el stream.
Porque los Effects solo comienzan después del commit cliente; Suspense necesita una fuente leída durante render bajo un contrato compatible.
Cuando el contenedor ya contiene HTML generado por React en servidor o generación estática y debe reutilizarse.
Porque el navegador puede analizar y pintar fragmentos HTML antes de descargar y ejecutar el JavaScript de hidratación.
SSR genera HTML inicial; Server Components dividen ejecución servidor/cliente y no envían al cliente la implementación de los componentes servidor.
Porque solo oculta síntomas, no garantiza reparación y puede dejar DOM, props o handlers inconsistentes.
Server Components y límites cliente-servidor profundiza en qué código permanece en el servidor, qué cruza al bundle cliente y cómo se combinan RSC, SSR, Suspense y Actions.