Capítulo 7 de 24
This example shows the minimal server-rendering setup for TanStack Router with file-based routing: an Express server hands each request to a router-aware SSR handler that renders the full HTML string before sending it to the client. It is the baseline SSR pattern that the streaming variant (Chapter 8) builds on.
routeTree.gen generated by @tanstack/router-plugin)@tanstack/react-router, @tanstack/router-plugin, express, compression, get-portserver.js (Express entry, dev uses Vite middleware, prod serves ./dist/client), src/router.tsx (shared router factory), src/entry-server.tsx (per-request render), src/routes/posts/$postId.tsx (a route with both an awaited loader value and a deferred promise).// src/entry-server.tsx
import { pipeline } from 'node:stream/promises'
import {
RouterServer,
createRequestHandler,
renderRouterToString,
} 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: /* copied from req.headers */ new Headers() })
const handler = createRequestHandler({
request,
createRouter: () => {
const router = createRouter()
router.update({ context: { ...router.options.context, head } })
return router
},
})
const response = await handler(({ responseHeaders, router }) =>
renderRouterToString({
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)
}
createRequestHandler bridges a Fetch Request/Response to Express, and renderRouterToString produces the complete HTML in one pass (no streaming) via RouterServer.createRouter() must be a fresh factory function per request (src/router.tsx exports it), so each SSR pass gets an isolated router instance, no state leaks between requests.renderRouterToString waits for the whole tree (including awaited loader data) before responding; a route can still hold a genuinely deferred promise (like commentsPromise in posts/$postId.tsx), which resolves client-side after hydration via <Await>.server.js (vite.ssrLoadModule vs import('./dist/server/entry-server.js')) is the standard pattern for SSR apps built with Vite.context: { head: '' } on the router lets entry-server.tsx inject Vite's extracted <head> content per request.posts/$postId.tsx) and same router setup, but swaps renderRouterToString for renderRouterToStream, streaming the response instead of buffering it fully.