router-core/ssr
sub-skill>-
SSR (Server-Side Rendering)
WARNING: SSR APIs are experimental. They share internal implementations with TanStack Start and may change. TanStack Start is the recommended way to do SSR in production — use manual SSR setup only when integrating with an existing server.
CRITICAL: TanStack Router is CLIENT-FIRST. Loaders run on the client by default. With SSR enabled, loaders run on BOTH client AND server. They are NOT server-only like Remix/Next.js loaders. See router-core/data-loading.
CRITICAL: Do not generate Next.js patterns (getServerSideProps, App Router, server components) or Remix patterns (server-only loader exports). TanStack Router has its own SSR API.
Concepts
There are two SSR flavors:
- Non-streaming: Full page rendered on server, sent as one HTML response, then hydrated on client.
- Streaming: Critical first paint sent immediately; remaining content streamed incrementally as it resolves.
Key behaviors:
- Memory history is used automatically on the server (no window).
- Loader data is automatically dehydrated on the server and hydrated on the client.
- Data serialization supports Date, Error, FormData, and undefined out of the box.
Setup: Shared Router Factory
The router must be created identically on server and client. Export a factory function from a shared file:
// src/router.tsx
import { createRouter as createTanstackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function createRouter() {
return createTanstackRouter({ routeTree })
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createRouter>
}
}
Non-Streaming SSR
Server Entry (using defaultRenderHandler)
// src/entry-server.tsx
import {
createRequestHandler,
defaultRenderHandler,
} from '@tanstack/react-router/ssr/server'
import { createRouter } from './router'
export async function render({ request }: { request: Request }) {
const handler = createRequestHandler({ request, createRouter })
return await handler(defaultRenderHandler)
}
Server Entry (using renderRouterToString for custom wrappers)
// src/entry-server.tsx
import {
createRequestHandler,
renderRouterToString,
RouterServer,
} from '@tanstack/react-router/ssr/server'
import { createRouter } from './router'
export function render({ request }: { request: Request }) {
const handler = createRequestHandler({ request, createRouter })
return handler(({ responseHeaders, router }) =>
renderRouterToString({
responseHeaders,
router,
children: <RouterServer router={router} />,
}),
)
}
Client Entry
// src/entry-client.tsx
import { hydrateRoot } from 'react-dom/client'
import { RouterClient } from '@tanstack/react-router/ssr/client'
import { createRouter } from './router'
const router = createRouter()
hydrateRoot(document, <RouterClient router={router} />)
Streaming SSR
Server Entry (using defaultStreamHandler)
// src/entry-server.tsx
import {
createRequestHandler,
defaultStreamHandler,
} from '@tanstack/react-router/ssr/server'
import { createRouter } from './router'
export async function render({ request }: { request: Request }) {
const handler = createRequestHandler({ request, createRouter })
return await handler(defaultStreamHandler)
}
Server Entry (using renderRouterToStream for custom wrappers)
// src/entry-server.tsx
import {
createRequestHandler,
renderRouterToStream,
RouterServer,
} from '@tanstack/react-router/ssr/server'
import { createRouter } from './router'
export function render({ request }: { request: Request }) {
const handler = createRequestHandler({ request, createRouter })
return handler(({ request, responseHeaders, router }) =>
renderRouterToStream({
request,
responseHeaders,
router,
children: <RouterServer router={router} />,
}),
)
}
Streaming is automatic — deferred data (unawaited promises from loaders) and streamed markup just work when using defaultStreamHandler or renderRouterToStream.
Document Head Management
Use the head route option to manage <title>, <meta>, <link>, and <style> tags. Render <HeadContent /> in <head> and <Scripts /> in <body>.
Root Route with Head
// src/routes/__root.tsx
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from '@tanstack/react-router'
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'UTF-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
{ title: 'My App' },
],
links: [{ rel: 'icon', href: '/favicon.ico' }],
}),
component: RootComponent,
})
function RootComponent() {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
)
}
Per-Route Head (Nested Deduplication)
Child route title and meta tags override parent tags with the same name/property:
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
return { post }
},
head: ({ loaderData }) => ({
meta: [
{ title: loaderData.post.title },
{ name: 'description', content: loaderData.post.excerpt },
],
}),
component: PostPage,
})
function PostPage() {
const { post } = Route.useLoaderData()
return <article>{post.content}</article>
}
SPA Head (No Full HTML Control)
For SPAs without server-rendered HTML, render <HeadContent /> at the top of the component tree:
import { createRootRoute, HeadContent, Outlet } from '@tanstack/react-router'
const rootRoute = createRootRoute({
head: () => ({
meta: [{ title: 'My SPA' }],
}),
component: () => (
<>
<HeadContent />
<Outlet />
</>
),
})
Body Scripts
Use scripts (separate from head.scripts) to inject scripts into <body> before the app entry point:
export const Route = createRootRoute({
scripts: () => [{ children: 'console.log("runs before hydration")' }],
})
The <Scripts /> component renders these. Place it at the end of <body>.
ScriptOnce for Pre-Hydration Scripts
ScriptOnce renders a <script> during SSR that executes immediately and self-removes. On client navigation, it does nothing (no duplicate execution).
import { ScriptOnce } from '@tanstack/react-router'
const themeScript = `(function() {
try {
const theme = localStorage.getItem('theme') || 'auto';
const resolved = theme === 'auto'
? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.classList.add(resolved);
} catch (e) {}
})();`
function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<>
<ScriptOnce children={themeScript} />
{children}
</>
)
}
If the script modifies the DOM (e.g., adds a class to <html>), use suppressHydrationWarning on the element:
<html lang="en" suppressHydrationWarning>
Express Integration Example
createRequestHandler expects a Web API Request and returns a Web API Response. For Express, convert between formats:
// src/entry-server.tsx
import { pipeline } from 'node:stream/promises'
import {
RouterServer,
createRequestHandler,
renderRouterToString,
} from '@tanstack/react-router/ssr/server'
import { createRouter } from './router'
import type express from 'express'
export async function render({
req,
res,
}: {
req: express.Request
res: express.Response
}) {
const protocol = req.get('x-forwarded-proto') ?? req.protocol
const host = req.get('x-forwarded-host') ?? req.get('host')
const url = new URL(req.originalUrl || req.url, `${protocol}://${host}`).href
const request = new Request(url, {
method: req.method,
headers: (() => {
const headers = new Headers()
for (const [key, value] of Object.entries(req.headers)) {
headers.set(key, value as any)
}
return headers
})(),
})
const handler = createRequestHandler({ request, createRouter })
const response = await handler(({ responseHeaders, router }) =>
renderRouterToString({
responseHeaders,
router,
children: <RouterServer router={router} />,
}),
)
res.status(response.status)
response.headers.forEach((value, name) => {
res.setHeader(name, value)
})
return pipeline(response.body as any, res)
}
Common Mistakes
1. HIGH: Using browser APIs in loaders without environment check
Loaders run on BOTH client and server with SSR. Browser-only APIs (window, document, localStorage) throw on the server.
// WRONG — crashes on server
loader: async () => {
const token = localStorage.getItem('token')
return fetchData(token)
}
// CORRECT — guard with environment check
loader: async () => {
const token =
typeof window !== 'undefined' ? localStorage.getItem('token') : null
return fetchData(token)
}
2. MEDIUM: Using hash fragments for server-rendered content
Hash fragments (#section) are never sent to the server. Conditional rendering based on hash causes hydration mismatches.
// WRONG — server has no hash, client does → mismatch
component: () => {
const hash = window.location.hash
return hash === '#admin' ? <AdminPanel /> : <UserPanel />
}
// CORRECT — use search params for server-visible state
validateSearch: z.object({ view: fallback(z.enum(['admin', 'user']), 'user') }),
component: () => {
const { view } = Route.useSearch()
return view === 'admin' ? <AdminPanel /> : <UserPanel />
}
3. CRITICAL: Generating Next.js or Remix SSR patterns
TanStack Router does NOT use getServerSideProps, getStaticProps, App Router page.tsx, or Remix-style server-only loader exports.
// WRONG — Next.js patterns
export async function getServerSideProps() {
return { props: { data: await fetchData() } }
}
// WRONG — Remix patterns
export async function loader({ request }: LoaderFunctionArgs) {
return json({ data: await fetchData() })
}
// CORRECT — TanStack Router pattern
export const Route = createFileRoute('/data')({
loader: async () => {
const data = await fetchData()
return { data }
},
component: DataPage,
})
function DataPage() {
const { data } = Route.useLoaderData()
return <div>{data}</div>
}
Tension: Client-First Loaders vs SSR
TanStack Router loaders are client-first by design. When SSR is enabled, they run in both environments. This means:
- Browser APIs work by default (client-only) but break under SSR
- Database access does NOT belong in loaders (unlike Remix/Next) — use API routes
- For server-only data logic with SSR, use TanStack Start's server functions
See router-core/data-loading for loader fundamentals.
Cross-References
- router-core/data-loading — SSR changes where loaders execute
- compositions/router-query — SSR dehydration/hydration with TanStack Query