关于 ssr
介绍 TanStack Router 的流式与非流式服务端渲染、水合及文档元数据管理,适用于集成 SSR 的开发者;文档建议生产环境使用 TanStack Start。
主要功能
- 01流式与非流式 SSR
- 02服务端渲染与客户端水合
- 03Loader 数据反序列化
- 04基于路由管理 head
- 05Express 集成示例
适用场景
- 01为 TanStack Router 应用添加 SSR
- 02流式输出延迟加载的 Loader 内容
- 03按路由设置文档元数据
- 04将渲染接入 Express
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ tanstack/ router --skill ssr安装后在 Claude Code 中输入/ssr确认已加载。
详细介绍
SSR(服务器端渲染)
WARNING:SSR API 目前处于实验阶段。它们与 TanStack Start 共用内部实现,未来可能会发生变化。TanStack Start 是在生产环境中进行 SSR 的推荐方式——仅在与现有服务集成时才使用手动 SSR 设置。
CRITICAL:TanStack Router 是客户端优先的。Loader 默认在客户端运行。启用 SSR 后,Loader 会同时在客户端和服务器端运行。它们不像 Remix/Next.js 的 Loader 那样仅在服务器端运行。请参阅 router-core/data-loading。
CRITICAL:不要生成 Next.js 模式(
getServerSideProps、App Router、服务器组件)或 Remix 模式(仅服务器端的 Loader 导出)。TanStack Router 有自己的 SSR API。
概念
SSR 有两种形式:
- 非流式:在服务器端渲染完整页面,作为一个 HTML 响应发送,然后在客户端进行水合。
- 流式:立即发送首屏关键内容;其余内容在解析完成后逐步流式传输。
主要行为:
- 在服务器端会自动使用内存历史记录(没有
window)。 - Loader 数据会在服务器端自动反水合,并在客户端水合。
- 数据序列化开箱即支持
Date、Error、FormData和undefined。
设置:共享 Router 工厂
Router 必须在服务器端和客户端以相同方式创建。在共享文件中导出一个工厂函数:
// 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>
}
}
非流式 SSR
服务器端入口(使用 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)
}
服务器端入口(使用 renderRouterToString 添加自定义包装器)
// 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} />,
}),
)
}
客户端入口
// 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} />)
流式 SSR
服务器端入口(使用 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)
}
服务器端入口(使用 renderRouterToStream 添加自定义包装器)
// 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} />,
}),
)
}
流式传输是自动的——使用 defaultStreamHandler 或 renderRouterToStream 时,延迟数据(Loader 中未等待的 Promise)和流式标记会自动正常工作。
文档头部管理
使用路由选项 head 管理 <title>、<meta>、<link> 和 <style> 标签。在 <head> 中渲染 <HeadContent />,并在 <body> 中渲染 <Scripts />。
带 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>
)
}
各路由的 Head(嵌套去重)
子路由的 title 和 meta 标签会覆盖父路由中具有相同 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(无法完全控制 HTML)
对于没有服务器端渲染 HTML 的 SPA,请在组件树顶部渲染 <HeadContent />:
import { createRootRoute, HeadContent, Outlet } from '@tanstack/react-router'
const rootRoute = createRootRoute({
head: () => ({
meta: [{ title: 'My SPA' }],
}),
component: () => (
<>
<HeadContent />
<Outlet />
</>
),
})
Body 脚本
使用 scripts(与 head.scripts 分开)在应用入口点之前向 <body> 注入脚本:
export const Route = createRootRoute({
scripts: () => [{ children: 'console.log("runs before hydration")' }],
})
<Scripts /> 组件会渲染这些脚本。请将它放在 <body> 末尾。
用于预水合脚本的 ScriptOnce
ScriptOnce 会在 SSR 期间渲染一个 <script>,使其立即执行并自行移除。在客户端导航时,它不会执行任何操作(不会重复执行)。
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> 添加类),请在该元素上使用 suppressHydrationWarning:
<html lang="en" suppressHydrationWarning>
Express 集成示例
createRequestHandler 接受 Web API Request 并返回 Web API Response。对于 Express,请在两种格式之间进行转换:
// 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)
}
常见错误
1. HIGH:在 Loader 中使用浏览器 API,但未检查运行环境
启用 SSR 时,Loader 会同时在客户端和服务器端运行。仅限浏览器使用的 API(window、document、localStorage)会在服务器端抛出错误。
// 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:使用哈希片段渲染服务器端内容
哈希片段(#section)不会发送到服务器。基于哈希进行条件渲染会导致水合不匹配。
// 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:生成 Next.js、Remix 或 React Router DOM 模式
TanStack Router 不使用 getServerSideProps、getStaticProps、App Router 的 page.tsx、Remix 风格的仅服务器端 loader 导出,或 react-router-dom 中的任何内容。
错误的文件结构
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.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'
错误的 loader/数据获取模式
// 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 的输出中看到 src/pages/、app/layout.tsx、react-router-dom 或上述任何内容,说明 Agent 正在为错误的框架生成代码。构建要么会失败,要么会生成重复的 / 路由,在运行时发生冲突。
矛盾:客户端优先的 Loader 与 SSR
TanStack Router 的 loader 在设计上以客户端为先。启用 SSR 时,它们会在两种环境中运行。这意味着:
- 浏览器 API 默认可用(仅限客户端),但在 SSR 下会出错
- 数据库访问不应放在 loader 中(与 Remix/Next 不同)——请使用 API 路由
- 对于启用 SSR 的仅限服务端的数据逻辑,请使用 TanStack Start 的服务端函数
请参阅 router-core/data-loading,了解 loader 基础知识。
交叉引用
- router-core/data-loading — SSR 会改变 loader 的执行位置
@tanstack/react-router#compositions/router-query— 使用 TanStack Query 进行 SSR 脱水/水合