ssr

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SSR (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 (
getServerSideProps
, App Router, server components) or Remix patterns (server-only loader exports). TanStack Router has its own SSR API.
警告:SSR API 处于实验阶段。它们与 TanStack Start 共享内部实现,可能会发生变更。生产环境推荐使用 TanStack Start 实现 SSR——仅在与现有服务集成时才使用手动 SSR 配置。
关键提示:TanStack Router 是客户端优先的。Loader 默认在客户端运行。启用 SSR 后,Loader 会在客户端和服务端同时运行,不像 Remix/Next.js 的 Loader 那样仅在服务端运行。参见 router-core/data-loading。
关键提示:不要生成 Next.js 模式(
getServerSideProps
、App Router、服务端组件)或 Remix 模式(仅服务端的 loader 导出)。TanStack Router 有自己的 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.
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
)

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)
}
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
实现自定义封装)

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} />,
    }),
  )
}
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
)

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)
}
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
实现自定义封装)

tsx
// 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
.
tsx
// 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)和流式标记会自动生效。

Document Head Management

文档头部管理

Use the
head
route option to manage
<title>
,
<meta>
,
<link>
, and
<style>
tags. Render
<HeadContent />
in
<head>
and
<Scripts />
in
<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
title
and
meta
tags override parent tags with the same
name
/
property
:
tsx
// 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>
}
子路由的
title
和
meta
标签会覆盖父路由中具有相同
name
/
property
的标签:
tsx
// 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
<HeadContent />
at the top of the component tree:
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
scripts
(separate from
head.scripts
) to inject scripts into
<body>
before the app entry point:
tsx
export const Route = createRootRoute({
  scripts: () => [{ children: 'console.log("runs before hydration")' }],
})
The
<Scripts />
component renders these. Place it at the end of
<body>
.
使用
scripts
(与
head.scripts
分开)将脚本注入到
<body>
中应用入口点之前的位置:
tsx
export const Route = createRootRoute({
  scripts: () => [{ children: 'console.log("runs before hydration")' }],
})
<Scripts />
组件会渲染这些脚本,请将其放置在
<body>
的末尾。

ScriptOnce for Pre-Hydration Scripts

用于预注水脚本的 ScriptOnce

ScriptOnce
renders a
<script>
during SSR that executes immediately and self-removes. On client navigation, it does nothing (no duplicate execution).
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
<html>
), use
suppressHydrationWarning
on the element:
tsx
<html lang="en" suppressHydrationWarning>
ScriptOnce
会在 SSR 期间渲染一个
<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>
添加类名),请在该元素上使用
suppressHydrationWarning
:
tsx
<html lang="en" suppressHydrationWarning>

Express Integration Example

Express 集成示例

createRequestHandler
expects a Web API
Request
and returns a Web API
Response
. For Express, convert between formats:
tsx
// 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)
}
createRequestHandler
接收 Web API 标准的
Request
对象,并返回 Web API 标准的
Response
对象。对于 Express,需要在两种格式之间进行转换:
tsx
// 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 (
window
,
document
,
localStorage
) throw on the server.
tsx
// 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(
window
、
document
、
localStorage
)在服务端会抛出错误。
tsx
// 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 (
#section
) are never sent to the server. Conditional rendering based on hash causes hydration mismatches.
tsx
// 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 />
}
哈希片段(
#section
)永远不会发送到服务端。基于哈希的条件渲染会导致注水不匹配。
tsx
// 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
getServerSideProps
,
getStaticProps
, App Router
page.tsx
, Remix-style server-only
loader
exports, or anything from
react-router-dom
.
TanStack Router 不使用
getServerSideProps
、
getStaticProps
、App Router 的
page.tsx
、Remix 风格的仅服务端
loader
导出,也不使用任何来自
react-router-dom
的 API。

Wrong 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.tsx
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.tsx

Wrong 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
src/pages/
,
app/layout.tsx
,
react-router-dom
, 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.
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 输出中看到
src/pages/
、
app/layout.tsx
、
react-router-dom
或上述任何内容,说明 Agent 生成的是错误框架的代码。构建要么会失败,要么会产生重复的
/
路由,在运行时发生冲突。

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 脱水/注水