ssr
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSSR (Server-Side Rendering)
SSR(服务端渲染)
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 (, App Router, server components) or Remix patterns (server-only loader exports). TanStack Router has its own SSR API.getServerSideProps
警告:SSR API 处于实验阶段。它们与 TanStack Start 共享内部实现,可能会发生变更。生产环境推荐使用 TanStack Start 实现 SSR——仅在与现有服务集成时才使用手动 SSR 配置。
关键提示:TanStack Router 是客户端优先的。Loader 默认在客户端运行。启用 SSR 后,Loader 会在客户端和服务端同时运行,不像 Remix/Next.js 的 Loader 那样仅在服务端运行。参见 router-core/data-loading。
关键提示:不要生成 Next.js 模式(、App Router、服务端组件)或 Remix 模式(仅服务端的 loader 导出)。TanStack Router 有自己的 SSR API。getServerSideProps
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, andFormDataout of the box.undefined
SSR 分为两种模式:
- 非流式:完整页面在服务端渲染,作为单个 HTML 响应发送,然后在客户端进行注水。
- 流式:关键首屏内容立即发送,剩余内容在解析完成后逐步流式传输。
核心特性:
- 服务端会自动使用内存历史记录(无需 对象)。
window - Loader 数据会在服务端自动脱水,在客户端自动注水。
- 数据序列化开箱支持 、
Date、Error和FormData类型。undefined
Setup: Shared Router Factory
配置:共享路由工厂
The router must be created identically on server and client. Export a factory function from a shared file:
tsx
// 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>
}
}路由实例必须在服务端和客户端以相同的方式创建。请从共享文件中导出一个工厂函数:
tsx
// 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
非流式 SSR
Server Entry (using defaultRenderHandler
)
defaultRenderHandler服务端入口(使用 defaultRenderHandler
)
defaultRenderHandlertsx
// 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)
}tsx
// 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)
renderRouterToString服务端入口(使用 renderRouterToString
实现自定义封装)
renderRouterToStringtsx
// 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} />,
}),
)
}tsx
// 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
客户端入口
tsx
// 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} />)tsx
// 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
流式 SSR
Server Entry (using defaultStreamHandler
)
defaultStreamHandler服务端入口(使用 defaultStreamHandler
)
defaultStreamHandlertsx
// 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)
}tsx
// 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)
renderRouterToStream服务端入口(使用 renderRouterToStream
实现自定义封装)
renderRouterToStreamtsx
// 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 or .
defaultStreamHandlerrenderRouterToStreamtsx
// 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} />,
}),
)
}流式传输是自动的——使用 或 时,延迟数据(loader 中未等待的 Promise)和流式标记会自动生效。
defaultStreamHandlerrenderRouterToStreamDocument Head Management
文档头部管理
Use the route option to manage , , , and tags. Render in and in .
head<title><meta><link><style><HeadContent /><head><Scripts /><body>使用 路由配置项管理 、、 和 标签。在 中渲染 ,在 中渲染 。
head<title><meta><link><style><head><HeadContent /><body><Scripts />Root Route with Head
带头部配置的根路由
tsx
// 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>
)
}tsx
// 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 and tags override parent tags with the same /:
titlemetanamepropertytsx
// 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>
}子路由的 和 标签会覆盖父路由中具有相同 / 的标签:
titlemetanamepropertytsx
// 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)
SPA 头部配置(无完整 HTML 控制权)
For SPAs without server-rendered HTML, render at the top of the component tree:
<HeadContent />tsx
import { createRootRoute, HeadContent, Outlet } from '@tanstack/react-router'
const rootRoute = createRootRoute({
head: () => ({
meta: [{ title: 'My SPA' }],
}),
component: () => (
<>
<HeadContent />
<Outlet />
</>
),
})对于没有服务端渲染 HTML 的 SPA,在组件树的顶部渲染 :
<HeadContent />tsx
import { createRootRoute, HeadContent, Outlet } from '@tanstack/react-router'
const rootRoute = createRootRoute({
head: () => ({
meta: [{ title: 'My SPA' }],
}),
component: () => (
<>
<HeadContent />
<Outlet />
</>
),
})Body Scripts
页面脚本
Use (separate from ) to inject scripts into before the app entry point:
scriptshead.scripts<body>tsx
export const Route = createRootRoute({
scripts: () => [{ children: 'console.log("runs before hydration")' }],
})The component renders these. Place it at the end of .
<Scripts /><body>使用 (与 分开)将脚本注入到 中应用入口点之前的位置:
scriptshead.scripts<body>tsx
export const Route = createRootRoute({
scripts: () => [{ children: 'console.log("runs before hydration")' }],
})<Scripts /><body>ScriptOnce for Pre-Hydration Scripts
用于预注水脚本的 ScriptOnce
ScriptOnce<script>tsx
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 ), use on the element:
<html>suppressHydrationWarningtsx
<html lang="en" suppressHydrationWarning>ScriptOnce<script>tsx
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}
</>
)
}如果脚本会修改 DOM(例如给 添加类名),请在该元素上使用 :
<html>suppressHydrationWarningtsx
<html lang="en" suppressHydrationWarning>Express Integration Example
Express 集成示例
createRequestHandlerRequestResponsetsx
// 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)
}createRequestHandlerRequestResponsetsx
// 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
1. 高风险:在 loader 中使用浏览器 API 而不做环境判断
Loaders run on BOTH client and server with SSR. Browser-only APIs (, , ) throw on the server.
windowdocumentlocalStoragetsx
// 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)
}启用 SSR 后,loader 会在客户端和服务端同时运行。仅浏览器可用的 API(、、)在服务端会抛出错误。
windowdocumentlocalStoragetsx
// 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
2. 中风险:对服务端渲染的内容使用哈希片段
Hash fragments () are never sent to the server. Conditional rendering based on hash causes hydration mismatches.
#sectiontsx
// 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 />
}哈希片段()永远不会发送到服务端。基于哈希的条件渲染会导致注水不匹配。
#sectiontsx
// 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, Remix, or React Router DOM patterns
3. 严重错误:生成 Next.js、Remix 或 React Router DOM 模式的代码
TanStack Router does NOT use , , App Router , Remix-style server-only exports, or anything from .
getServerSidePropsgetStaticPropspage.tsxloaderreact-router-domTanStack Router 不使用 、、App Router 的 、Remix 风格的仅服务端 导出,也不使用任何来自 的 API。
getServerSidePropsgetStaticPropspage.tsxloaderreact-router-domWrong file structures
错误的文件结构
text
WRONG (Next.js Pages Router):
src/pages/index.tsx
src/pages/_app.tsx
src/pages/posts/[id].tsx
WRONG (Next.js App Router):
app/layout.tsx
app/page.tsx
app/posts/[id]/page.tsx
WRONG (Next.js custom App):
_app/index.tsx
pages/_app.tsx, pages/_document.tsx
CORRECT (TanStack Router file-based routing):
src/routes/__root.tsx
src/routes/index.tsx
src/routes/posts/$postId.tsxtext
WRONG (Next.js Pages Router):
src/pages/index.tsx
src/pages/_app.tsx
src/pages/posts/[id].tsx
WRONG (Next.js App Router):
app/layout.tsx
app/page.tsx
app/posts/[id]/page.tsx
WRONG (Next.js custom App):
_app/index.tsx
pages/_app.tsx, pages/_document.tsx
CORRECT (TanStack Router file-based routing):
src/routes/__root.tsx
src/routes/index.tsx
src/routes/posts/$postId.tsxWrong imports
错误的导入
tsx
// WRONG — react-router-dom is a different library
import {
Link,
useNavigate,
BrowserRouter,
Route,
Routes,
} from 'react-router-dom'
// WRONG — Next.js Link/router
import Link from 'next/link'
import { useRouter } from 'next/router' // Pages Router
import { useRouter } from 'next/navigation' // App Router
// CORRECT — everything routing-related lives in @tanstack/react-router
import {
Link,
useNavigate,
useRouter,
useLocation,
redirect,
} from '@tanstack/react-router'tsx
// WRONG — react-router-dom is a different library
import {
Link,
useNavigate,
BrowserRouter,
Route,
Routes,
} from 'react-router-dom'
// WRONG — Next.js Link/router
import Link from 'next/link'
import { useRouter } from 'next/router' // Pages Router
import { useRouter } from 'next/navigation' // App Router
// CORRECT — everything routing-related lives in @tanstack/react-router
import {
Link,
useNavigate,
useRouter,
useLocation,
redirect,
} from '@tanstack/react-router'Wrong loader/data-fetching patterns
错误的 loader/数据获取模式
tsx
// WRONG — Next.js Pages Router
export async function getServerSideProps() {
return { props: { data: await fetchData() } }
}
// WRONG — Remix
export async function loader({ request }: LoaderFunctionArgs) {
return json({ data: await fetchData() })
}
// CORRECT — TanStack Router
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>
}If you see , , , or any of the above in agent output, the agent is generating for the wrong framework. The build will either fail or produce duplicate routes that conflict at runtime.
src/pages/app/layout.tsxreact-router-dom/tsx
// WRONG — Next.js Pages Router
export async function getServerSideProps() {
return { props: { data: await fetchData() } }
}
// WRONG — Remix
export async function loader({ request }: LoaderFunctionArgs) {
return json({ data: await fetchData() })
}
// CORRECT — TanStack Router
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>
}如果你在 Agent 输出中看到 、、 或上述任何内容,说明 Agent 生成的是错误框架的代码。构建要么会失败,要么会产生重复的 路由,在运行时发生冲突。
src/pages/app/layout.tsxreact-router-dom/Tension: Client-First Loaders vs SSR
设计权衡:客户端优先的 Loader 与 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.
TanStack Router 的 loader 设计上是客户端优先的。启用 SSR 后,它们会在两个环境中运行。这意味着:
- 浏览器 API 默认可用(仅客户端),但在 SSR 下会报错
- 数据库访问不应该放在 loader 中(与 Remix/Next 不同)——请使用 API 路由
- 对于 SSR 下仅服务端运行的数据逻辑,请使用 TanStack Start 的服务端函数
有关 loader 的基础知识,请参见 router-core/data-loading。
Cross-References
相关参考
- router-core/data-loading — SSR changes where loaders execute
- compositions/router-query — SSR dehydration/hydration with TanStack Query
- router-core/data-loading — SSR 改变了 loader 的执行位置
- compositions/router-query — 结合 TanStack Query 的 SSR 脱水/注水