READMEby ansango
← Volver al libro

React del lado del servidor : renderToString, renderToPipeableStream, hidratación

Limitaciones del client-only rendering (SEO, performance, network waterfalls, CSRF), beneficios del SSR, renderToString vs renderToPipeableStream vs renderToReadableStream, hidratación vs resumability, Suspense para SSR

~6 min de lectura
Resumen

React empezó siendo una librería client-side, pero las limitaciones de CSR — SEO endeble en crawlers que no ejecutan JS, time to interactive alargado por waterfalls de red, poca accesibilidad en dispositivos lentos, riesgos de CSRF — empujaron el modelo hacia el servidor. Hoy React ofrece tres APIs para SSR (renderToString, renderToPipeableStream, renderToReadableStream), todas se complementan con hidratación y, sobre todo en combinación con Suspense, abren la puerta a streaming de HTML y partial hydration. El capítulo cierra con una advertencia muy repetida por el libro: no hagas tu propio SSR en producción. Confía en Next.js, Remix o el framework que tu equipo haya elegido.

Por qué CSR no basta

React como librería pura de cliente pinta el árbol en el DOM del navegador. Eso funciona perfectamente bien si el usuario llega con buen 4G y un iPhone 15. Si no, aparece la lista de problemas:

ProblemaPor qué duele
SEOCrawlers propietarios que no ejecutan JS ven una shell vacía.
PerformanceEl time to interactive depende de la descarga, parseo y ejecución del bundle. En redes lentas o dispositivos de gama baja es lento.
Network waterfallsEl HTML inicial no trae nada útil. La primera petición sirve el shell; la segunda, el JS; la tercera, el JSON de la API.
AccesibilidadLectores de pantalla y crawlers sociales (Facebook Open Graph, Twitter cards) ven páginas vacías.

SEO: ¿Google ya lo arregla?

Google y Bing indexan contenido que se renderiza con JS, pero los crawlers desconocidos, los archivadores web y los lectores sociales siguen esperando HTML. La forma segura sigue siendo enviar el HTML ya renderizado desde el servidor.

Performance: el argumento central

Con CSR el primer byte es un <div id="root"></div> vacío. El usuario espera a que baje el bundle, lo parsee, lo ejecute y entonces ve algo. Con SSR el primer byte ya contiene la UI, y la hidratación se ejecuta en paralelo a la percepción del usuario.

   Client only               Server rendered
   ───────────               ──────────────
   GET /                →    GET /
   ↓ shell vacío              ↓ HTML con UI
   GET bundle.js        →    GET bundle.js (en paralelo, mientras se ve UI)
   GET /api/items       →    GET /api/items (durante hidratación)
   ↓ render                  ↓ interactivo cuando termina hydrate()
   = waterfalls

Seguridad: CSRF

Si todo tu código vive en el cliente, no tienes un shared secret entre front y back. Eso facilita ataques CSRF donde una página externa dispara acciones autenticadas con tus cookies. Tener un servidor controlado habilita tokens anti-CSRF que mitigan este vector.

const Account = () => {
  const [balance, setBalance] = useState(100);
  const handleWithdrawal = async (amount) => {
    const response = await fetch("/withdraw", {
      method: "POST",
      credentials: "include",                  // envía cookies automáticamente
      body: JSON.stringify({ amount }),
    });
    if (response.ok) setBalance(await response.json());
  };
  // ...
};
// server.ts
const csrfToken = randomBytes(32).toString("hex");
return ReactDOMServer.renderToString(
  <Account csrfToken={csrfToken} />              // token generado por el server
);

// Account.tsx
fetch("/withdraw", {
  method: "POST",
  headers: { "X-CSRF-Token": csrfToken },       // el back compara con la sesión
  body: JSON.stringify({ amount }),
});

Beneficios del SSR

Resumidos en una tabla que el libro desglosa:

BeneficioDetalle
Faster first meaningful paintHTML con UI ya visible al primer byte.
Mejor accesibilidadUsuarios con conexiones lentas ven contenido sin esperar JS.
Mejor SEOHTML completo e indexable.
Mejor seguridadContrato bidireccional cliente ↔ servidor, con tokens y validación.

Hidratación

Una vez enviado el HTML estático, la página todavía no es interactiva. La hidratación es el proceso por el que React toma ese HTML y le engancha event listeners, restaurando el comportamiento de SPA.

import React from "react";
import { hydrateRoot } from "react-dom/client";
import App from "./App";

hydrateRoot(document, <App />);   // reusa el DOM, no lo reemplaza
Hidratación estricta

Si el árbol JSX produce una estructura distinta al HTML del servidor, React avisa con “hydration mismatch” y vuelve a renderizar desde cero. La causa típica: usar Date.now(), Math.random() o condiciones basadas en typeof window en render que no se replica en el servidor. Una de las trampas más sutiles de SSR.

Hidratación vs resumability

La crítica a la hidratación es que reconstruye todo el árbol en cliente aunque el servidor ya lo tenía listo. Resumability (Qwik, Marko) envía en el HTML serializado qué listeners van dónde, para que el cliente solo reanude sin reejecutar todo el render. Más rápido en TTI pero más complejo de implementar; debate abierto en el ecosistema.

Cómo funciona: server manual con Express

El libro muestra un SSR naive en Express para que se entienda el flujo. No es para producción.

const express = require("express");
const path = require("path");
const React = require("react");
const ReactDOMServer = require("react-dom/server");
const App = require("./src/App");

const app = express();
app.use(express.static(path.join(__dirname, "build")));

app.get("*", (req, res) => {
  const html = ReactDOMServer.renderToString(<App />);
  res.send(`
    <!DOCTYPE html>
    <html>
      <head><title>My React App</title></head>
      <body>
        <div id="root">${html}</div>
        <script src="/static/js/main.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000);

Y en el cliente:

import React from "react";
import { hydrateRoot } from "react-dom/client";
import App from "./App";

hydrateRoot(document, <App />);
Fíjate en la advertencia

El libro etiqueta esta sección como “didáctica”. El nodo cachedUserData que cualquier tutorial-Express mostraría es exactamente el tipo de bug que rompe a producción. En el siguiente apartado se insiste: no lo hagas tú mismo.

Las APIs de SSR en React

APIStreamConcurrenciaTamaño del payloadHidratación
renderToStringNoNoString completoSí
renderToPipeableStreamNodeSí (Suspense)Chunks streamedSí
renderToReadableStreamWeb (WHATWG)Sí (Suspense)Chunks streamedSí

renderToString (legacy en la práctica)

Es el API síncrona original. Convierte el árbol en un string de HTML y lo entrega de una vez.

import { renderToString } from "react-dom/server";

const html = renderToString(<App />);
// '<div><h1>Hello, world!</h1>...</div>'

Problemas:

Cuándo sigue valiendo

Solo para apps pequeñas, toy projects, pre-renderizado estático (Next.js static generation), o generación de HTML para emails donde renderToStaticMarkup (su variante sin atributos React) sigue siendo la opción.

renderToPipeableStream (React 18+, Node)

Devuelve un Node.js stream que se conecta con res de Express. Soporta Suspense y streaming de HTML: el shell se envía inmediatamente; los hijos suspendidos se sustituyen en cuanto se resuelven.

import { renderToPipeableStream } from "react-dom/server";

app.get("*", (req, res) => {
  const { pipe } = renderToPipeableStream(<App />, {
    onShellReady: () => {
      res.setHeader("Content-Type", "text/html");
      pipe(res);   // emite el shell en cuanto está listo y sigue con el resto
    },
  });
});
import { lazy, Suspense } from "react";

const DogBreeds = lazy(() => fetch("/api/breeds").then(r => r.json()));

function App() {
  return (
    <div>
      <h1>Dog Breeds</h1>
      <Suspense fallback={<div>Loading Dog Breeds...</div>}>
        <DogBreeds />
      </Suspense>
    </div>
  );
}

Cuando el servidor emite el HTML, dentro del Suspense aparece un comentario/<template> como placeholder; un script inline ($RC(...), el reactComponentCleanup minificado) reemplaza el fallback por el HTML resuelto en el cliente sin necesidad de hidratar todavía. Es decir, el contenido llega y se intercambia, y solo después hidrata el resto.

Streaming + Suspense ≠ hidratación

El streaming usa scripts inline listos por el servidor para reemplazar fallbacks cuando los datos llegan. Para que la página sea interactiva todavía necesitas hydrateRoot, pero el usuario ve contenido y datos en paralelo, no espera la cascada completa.

renderToReadableStream (Web Streams)

Misma idea que renderToPipeableStream pero con ReadableStream (WHATWG Streams), el estándar de los navegadores. Es lo que usarías en edge runtimes (Cloudflare Workers, Deno, Bun).

import { renderToReadableStream } from "react-dom/server";

const stream = await renderToReadableStream(<App />);
return new Response(stream, { headers: { "Content-Type": "text/html" } });

Cuándo usar cada API

El libro resume con un cuadro claro:

Si necesitas SSR en Node → renderToPipeableStream. Si necesitas SSR en un edge runtime / Web Streams → renderToReadableStream. Solo para tiny apps / static pre-render → renderToString.

Pero el propio autor reconoce que en React 18 casi nadie puede usar renderToPipeableStream en producción, porque la mayoría de librerías de CSS-as-JS y data fetching asumen que el render del servidor está completo. La partial hydration y los APIs para preload (prefetchDNS, preconnect, preload) aterrizan en React 19, donde el panorama cambia.

No hagas tu propio SSR

El autor es tajante:

El capítulo 6 termina con un aprendizaje

Implementar SSR “a mano” parece educativo y, de hecho, lo es. Pero en producción es un agujero de edge cases: caching por usuario, data-fetching cancelable, partial hydration, streaming de CSS, errores, reintentos, modo estricto… los frameworks resuelven esto porque es la clase de problema donde los errores humanos son catastróficos. El ejemplo del cachedUserData global que devuelve datos de cliente A a cliente B es exactamente el tipo de bug que se filtra a producción y se arregla con auditorías de seguridad. Si tienes acceso a un servidor, usa Next.js o Remix. Si no, static site generation.

// routes/posts/$postId.tsx
import { useParams } from "react-router-dom";
import { useLoaderData } from "@remix-run/react";

export function loader({ params }) {
  return fetchPost(params.postId);   // se ejecuta en el servidor
}

function Post() {
  const { postId } = useParams();
  const post = useLoaderData();
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </div>
  );
}

El ejemplo opone cargar data en el cliente con useEffect + fetch vs loader del framework. La ganancia es doble: TTFB más rápido y bug imposible de cross-user cache por construcción.

Resumen del capítulo

DecisiónImplicación
Cliente puroNo SSR. SEO malo, TTFB lento, riesgo CSRF.
renderToStringBloqueante, sin streaming, sin Suspense. Aceptable solo en static pre-render.
renderToPipeableStream (Node) o renderToReadableStream (Web)Soportan Suspense, streaming nativo del HTML, partial hydration si el resto lo permite.
HidrataciónReusa el DOM del servidor. Cuidado con los hydration mismatches.
ResumabilityAlternativa a hidratación (Qwik, Marko). Debate abierto.
En producciónConfía en Next.js, Remix, Gatsby o TanStack Start. No roces tu propio SSR.

Próximos pasos

SSR destapa el problema de “el shell se ve, pero los datos no están”. Resolver ese coordination gap es la motivación de 08-react-concurrente.