mcpField
S
ssr

ssr

github.com/tanstack开发15.3k192 次安装4 天前更新
代码仓库

关于 ssr

介绍 TanStack Router 的流式与非流式服务端渲染、水合及文档元数据管理,适用于集成 SSR 的开发者;文档建议生产环境使用 TanStack Start。

主要功能

  1. 01流式与非流式 SSR
  2. 02服务端渲染与客户端水合
  3. 03Loader 数据反序列化
  4. 04基于路由管理 head
  5. 05Express 集成示例

适用场景

  1. 01为 TanStack Router 应用添加 SSR
  2. 02流式输出延迟加载的 Loader 内容
  3. 03按路由设置文档元数据
  4. 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 脱水/水合

评论

支持 Markdown