Capítulo 8 de 24

Chapter 8: SSR with Streaming

Core Idea

Identical app to Chapter 7 (same router config, same posts/$postId route with a deferred commentsPromise) but the server renders using renderRouterToStream instead of renderRouterToString. This lets the initial HTML (post title/body) flush to the browser immediately while slow loader data (comments, delayed 2s) streams in afterward.

Setup

  • Routing style: file-based
  • Key dependencies: @tanstack/react-router, @tanstack/router-plugin, express, compression, get-port (same dependency set as the non-streaming example)
  • Structure: same layout as ch007: server.js, src/router.tsx, src/entry-server.tsx, src/routes/posts/$postId.tsx. The only functional diff is inside entry-server.tsx.

Code Example

// src/entry-server.tsx (streaming variant)
import { pipeline } from 'node:stream/promises'
import {
  RouterServer,
  createRequestHandler,
  renderRouterToStream,
} from '@tanstack/react-router/ssr/server'
import { createRouter } from './router'

export async function render({ req, res, head }) {
  const url = new URL(req.originalUrl || req.url, 'http://localhost:3000').href
  const request = new Request(url, { method: req.method, headers: new Headers(/* ... */) })

  const handler = createRequestHandler({
    request,
    createRouter: () => {
      const router = createRouter()
      router.update({ context: { ...router.options.context, head } })
      return router
    },
  })

  // Note: renderRouterToStream also receives `request`, not just responseHeaders/router
  const response = await handler(({ request, responseHeaders, router }) =>
    renderRouterToStream({
      request,
      responseHeaders,
      router,
      children: <RouterServer router={router} />,
    }),
  )

  res.statusMessage = response.statusText
  res.status(response.status)
  response.headers.forEach((value, name) => res.setHeader(name, value))
  return pipeline(response.body, res)
}
  • What it demonstrates: swapping renderRouterToString for renderRouterToStream (which additionally needs the request object) turns the SSR response into a stream, so wrapInSuspense: true route options and <Await> boundaries in the route component resolve progressively on the wire instead of blocking the first byte.

Key Takeaways

  1. The only code change needed to go from blocking to streaming SSR is the render function import and call (renderRouterToString -> renderRouterToStream), everything else (router factory, Express glue, route definitions) is unchanged.
  2. renderRouterToStream requires the Fetch request object as an argument; renderRouterToString does not, because streaming needs to track the request lifecycle to know when to close the stream.
  3. Streaming pairs naturally with routes that mix an awaited loader value (post) and a deferred one (commentsPromise): the shell renders fast, the deferred piece arrives later without a second round-trip.
  4. Use streaming when a route loader has a clear "fast" and "slow" part (fetch a summary, then defer comments/related data); use plain string rendering when everything resolves quickly or predictably.

Connects To

  • ch007-basic-ssr-file-based: Same app, non-streaming baseline. Compare entry-server.tsx side by side to see the minimal diff.
  • ch009-deferred-data: Shows the client-only equivalent of the deferred-promise pattern used here (defer() + <Await>), useful for understanding what wrapInSuspense/Await do independent of SSR.