TanStack Start 中文文档
服务器与执行

服务器组件(Server Components)

警告

服务器组件是实验性功能!API 将来可能会有所调整。

服务器组件(Server Components)让你在服务器上渲染 React 组件,并把它们流式传输给客户端。重依赖不进打包产物、数据获取就在组件里、敏感逻辑永远不会到达浏览器。

安装配置

服务器组件默认不开启。 先完成下面三步:

1. 安装构建工具专属的 RSC 依赖

npm install -D @vitejs/plugin-rsc
# or
pnpm add -D @vitejs/plugin-rsc
# or
yarn add -D @vitejs/plugin-rsc
# or
bun add -D @vitejs/plugin-rsc

2. 配置你的构建工具

更新构建工具配置,在 TanStack Start 插件中启用 RSC:

vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import rsc from '@vitejs/plugin-rsc'

export default defineConfig({
  plugins: [
    tanstackStart({
      rsc: {
        enabled: true,
      },
    }),
    rsc(),
    viteReact(),
  ],
})

版本要求: React 19+,Vite 7+ 或 Rsbuild 2+

快速上手

在 TanStack Start 中,你通常在一个服务器函数里创建服务端渲染的 UI,然后通过路由的 loader 返回它。

有两个高层的 RSC 辅助函数:

  • renderServerComponent(<Element />) 返回一个可渲染的值(renderable value),可以像 {Renderable} 一样内联使用。
  • createCompositeComponent((props) => <Element />) 返回一个复合源(composite source),通过 <CompositeComponent src={...} /> 渲染(支持插槽 slots)。

Renderable(无插槽)

import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
import { renderServerComponent } from '@tanstack/react-start/rsc'

function Greeting() {
  return <h1>Hello from RSC</h1>
}

const getGreeting = createServerFn().handler(async () => {
  const Renderable = await renderServerComponent(<Greeting />)
  return { Renderable }
})

export const Route = createFileRoute('/')({
  loader: async () => {
    const { Renderable } = await getGreeting()
    return { Greeting: Renderable }
  },
  component: HomePage,
})

function HomePage() {
  const { Greeting } = Route.useLoaderData()
  return <>{Greeting}</>
}

Composite(带插槽)

import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
import {
  CompositeComponent,
  createCompositeComponent,
} from '@tanstack/react-start/rsc'

const getCard = createServerFn().handler(async () => {
  const src = await createCompositeComponent(
    (props: { children?: React.ReactNode }) => (
      <div className="card">
        <h2>Server-rendered header</h2>
        <div>{props.children}</div>
      </div>
    ),
  )

  return { src }
})

export const Route = createFileRoute('/')({
  loader: async () => ({
    Card: await getCard(),
  }),
  component: HomePage,
})

function HomePage() {
  const { Card } = Route.useLoaderData()

  return (
    <CompositeComponent src={Card.src}>
      <Counter />
    </CompositeComponent>
  )
}

译者注:什么是「客户端主导组合」?

这是 Start 的 RSC 思路与 Next.js 不同的地方:在 Start 里,服务器组件的输出被当作「数据」交给客户端——客户端负责组合(在 <CompositeComponent> 里填 children、render props 等插槽),服务器只负责交付 UI 片段。所以叫「客户端主导组合(client-led composition)」。你无需学习一套新的「组件缓存」语义,缓存、流式、插槽都按你熟悉的模式来。

为什么用服务器组件?

  • 更小的打包体积。 Markdown 解析器、语法高亮器、重型库都在服务器上运行。只有渲染好的 HTML 会到达客户端。
  • 就近取数(Colocated data fetching)。 直接在你需要数据的组件里获取数据。
  • 默认安全。 API 密钥、数据库查询、业务逻辑永远不会出现在客户端打包产物中。
  • 渐进式流式传输。 UI 随着渲染逐步流向浏览器。用户能立刻看到内容。

传递 Props 与组合

renderServerComponent 返回的 renderable 值不支持插槽。

要接收客户端提供的 props(「插槽」),使用 createCompositeComponent 并通过 <CompositeComponent src={...} /> 渲染。

插槽在服务器组件的 props 上声明。有三种插槽类型:

插槽类型用途服务器能否传数据?
children简单的组合
Render props服务器向客户端渲染的内容传数据
组件 props传入接收服务器数据的可复用组件

Children 插槽

把客户端组件作为 children 传入。简单、熟悉,但服务器无法向它们传数据:

import {
  CompositeComponent,
  createCompositeComponent,
} from '@tanstack/react-start/rsc'

const getCard = createServerFn().handler(async () => {
  const src = await createCompositeComponent(
    (props: { children?: React.ReactNode }) => (
      <div className="card">
        <h2>Server-rendered header</h2>
        <div>{props.children}</div>
      </div>
    ),
  )

  return { src }
})

function MyPage() {
  const { src } = Route.useLoaderData()

  return (
    <CompositeComponent src={src}>
      {/* Client components with full interactivity */}
      <Counter />
      <button onClick={() => alert('Clicked!')}>Click me</button>
    </CompositeComponent>
  )
}

Render Props

当服务器需要向客户端渲染的内容传数据时,使用 render props:

import {
  CompositeComponent,
  createCompositeComponent,
} from '@tanstack/react-start/rsc'

const getPost = createServerFn()
  .validator(z.object({ postId: z.string() }))
  .handler(async ({ data }) => {
    const post = await db.posts.findById(data.postId)

    const src = await createCompositeComponent(
      (props: {
        children?: React.ReactNode
        renderActions?: (data: {
          postId: string
          authorId: string
        }) => React.ReactNode
      }) => (
        <article>
          <h1>{post.title}</h1>
          <p>{post.body}</p>
          <footer>
            {props.renderActions?.({
              postId: post.id,
              authorId: post.authorId,
            })}
          </footer>
          {props.children}
        </article>
      ),
    )

    return { src }
  })

function PostPage() {
  const { src } = Route.useLoaderData()

  return (
    <CompositeComponent
      src={src}
      renderActions={({ postId, authorId }) => (
        <PostActions postId={postId} authorId={authorId} />
      )}
    >
      <Comments />
    </CompositeComponent>
  )
}

组件 Props

把 React 组件作为 props 传入。在客户端,传入的 props 会用服务器提供的数据渲染:

import {
  CompositeComponent,
  createCompositeComponent,
} from '@tanstack/react-start/rsc'

const getProductCard = createServerFn()
  .validator(z.object({ productId: z.string() }))
  .handler(async ({ data }) => {
    const product = await db.products.findById(data.productId)

    const src = await createCompositeComponent(
      ({
        AddToCart,
      }: {
        AddToCart: React.ComponentType<{ productId: string; price: number }>
      }) => (
        <div className="product-card">
          <h2>{product.name}</h2>
          <p>${product.price}</p>
          <AddToCart productId={product.id} price={product.price} />
        </div>
      ),
    )

    return { src }
  })

// Client component with interactivity
function AddToCartButton({
  productId,
  price,
}: {
  productId: string
  price: number
}) {
  const [added, setAdded] = React.useState(false)

  return (
    <button onClick={() => setAdded(true)}>
      {added ? '✓ Added!' : `Add to Cart - $${price}`}
    </button>
  )
}

function ProductPage() {
  const { src } = Route.useLoaderData()

  return <CompositeComponent src={src} AddToCart={AddToCartButton} />
}

组件 props 在以下场景很有用:

  • 你想传入可复用的客户端组件
  • 组件需要自己的 state 或事件处理器
  • 相比 render prop 回调,你更偏好组件组合

三种插槽类型可以组合使用。createCompositeComponent 为插槽 props 提供完整的类型安全。

缓存

服务器组件与 TanStack Router 的内置缓存配合工作。缓存 key 是路由路径加上参数:

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => ({
    Post: await getPost({ data: { postId: params.postId } }),
  }),
  component: PostPage,
})

导航到 /posts/abc,再到 /posts/xyz,然后返回 /posts/abc——缓存的组件会瞬间渲染。

staleTime 控制新鲜度:

export const Route = createFileRoute('/posts/$postId')({
  staleTime: 10_000, // Fresh for 10 seconds
  loader: async ({ params }) => ({
    Post: await getPost({ data: { postId: params.postId } }),
  }),
  component: PostPage,
})

当缓存 key 需要超出路由参数时,使用 loaderDeps

export const Route = createFileRoute('/posts/$postId')({
  loaderDeps: ({ search }) => ({ tab: search.tab }),
  loader: async ({ params, deps }) => ({
    Post: await getPost({ data: { postId: params.postId, tab: deps.tab } }),
  }),
  component: PostPage,
})

TanStack Query

要获得更细粒度的控制,使用 TanStack Query:

import { useSuspenseQuery, useQueryClient } from '@tanstack/react-query'

const postQueryOptions = (postId: string) => ({
  queryKey: ['post', postId],
  structuralSharing: false, // Required - RSC values must not be merged
  queryFn: () => getPost({ data: { postId } }),
  staleTime: 5 * 60 * 1000,
})

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ context, params }) => {
    // Prefetch during SSR - data reused on client without refetch
    await context.queryClient.ensureQueryData(postQueryOptions(params.postId))
  },
  component: PostPage,
})

function PostPage() {
  const { postId } = Route.useParams()
  const queryClient = useQueryClient()

  const { data } = useSuspenseQuery(postQueryOptions(postId))

  const handleRefresh = () => {
    // Manually refetch the RSC
    queryClient.refetchQueries({ queryKey: ['post', postId] })
  }

  return <CompositeComponent src={data.src} />
}

重要

用 React Query 缓存服务器组件时,一定要设置 structuralSharing: false。否则 React Query 可能尝试在多次 fetch 之间合并 RSC 值,这会导致错误。

失效(Invalidation)

数据变更后要重新获取服务器组件,使用 router.invalidate()

import { useRouter } from '@tanstack/react-router'

function PostPage() {
  const router = useRouter()
  const { Post } = Route.useLoaderData()

  const handleUpdate = async () => {
    await updatePost({ data: { ... } })
    // Refetch the route's loader, including the RSC
    router.invalidate()
  }

  return (
    <CompositeComponent
      src={Post.src}
      renderActions={() => (
        <button onClick={handleUpdate}>Update Post</button>
      )}
    />
  )
}

当服务器函数修改了 RSC 展示的数据时,这个模式很有用。变更完成后,router.invalidate() 会触发加载器重新运行,获取包含最新数据的服务器组件。

与选择性 SSR 组合

当你需要服务端渲染的内容、但路由组件本身要仅客户端渲染时,服务器组件可以与选择性 SSR搭配使用。

示例:ssr: 'data-only'

服务器获取 RSC,但路由组件在客户端渲染:

export const Route = createFileRoute('/dashboard')({
  ssr: 'data-only',
  loader: async () => ({
    Dashboard: await getDashboard(),
  }),
  component: DashboardPage,
})

function DashboardPage() {
  const { Dashboard } = Route.useLoaderData()
  const [width, setWidth] = React.useState(0)

  React.useEffect(() => {
    setWidth(window.innerWidth) // Browser API
  }, [])

  return (
    <Dashboard
      renderChart={({ data }) => <ResponsiveChart data={data} width={width} />}
    />
  )
}

这在以下场景很有用:

  • 服务器组件获取数据并渲染静态内容
  • 路由组件需要 windowlocalStorage 或其他浏览器 API
  • 你想在客户端渲染之前让服务器组件数据就绪

示例:ssr: false

加载器和组件都在客户端运行:

export const Route = createFileRoute('/canvas')({
  ssr: false,
  loader: async () => {
    const savedState = localStorage.getItem('canvas-state')
    return { Tools: await getDrawingTools({ data: { savedState } }) }
  },
  component: CanvasPage,
})

当加载器本身就需要浏览器 API 时,使用这种方式。

高级模式

并行获取多个服务器组件

当一个页面需要几个独立的服务器组件时,用 Promise.all 并行获取它们。每个组件用各自的数据独立渲染。

什么时候用: 组件来自相互独立的数据源,没有共享依赖。当每个组件有独立的获取逻辑时,能最大化并发度。

const getArticleA = createServerFn().handler(async () => {
  const article = await db.articles.findById('a')
  return renderServerComponent(<Article data={article} />)
})

const getArticleB = createServerFn().handler(async () => {
  const article = await db.articles.findById('b')
  return renderServerComponent(<Article data={article} />)
})

const getSidebar = createServerFn().handler(async () => {
  const trending = await db.articles.getTrending()
  return renderServerComponent(<Sidebar items={trending} />)
})

export const Route = createFileRoute('/news')({
  loader: async () => {
    const [ArticleA, ArticleB, Sidebar] = await Promise.all([
      getArticleA(),
      getArticleB(),
      getSidebar(),
    ])
    return { ArticleA, ArticleB, Sidebar }
  },
  component: NewsPage,
})

function NewsPage() {
  const { ArticleA, ArticleB, Sidebar } = Route.useLoaderData()

  return (
    <div className="grid">
      <main>
        {ArticleA}
        {ArticleB}
      </main>
      <aside>{Sidebar}</aside>
    </div>
  )
}

每个服务器函数并发执行。全部完成后页面才渲染。

打包多个组件

当多个服务器组件共享数据、或应该一起获取时,可以从一个服务器函数中返回它们。这样能减少网络往返次数。

什么时候用: 组件共享获取的数据、需要同一个缓存 key,或应该一起失效。能减少数据库查询和网络往返。

使用 Promise.all

创建多次 renderServerComponentcreateCompositeComponent 调用,并把它们作为一个对象返回:

const getPageLayout = createServerFn().handler(async () => {
  // Fetch shared data once
  const user = await db.users.getCurrent()
  const config = await db.config.get()

  // Create multiple components that share this data
  const [Header, Content, Footer] = await Promise.all([
    renderServerComponent(
      <header>
        <Logo />
        <nav>
          {config.navItems.map((item) => (
            <NavLink key={item.id} {...item} />
          ))}
        </nav>
        <UserMenu name={user.name} />
      </header>,
    ),
    renderServerComponent(
      <main>
        <h1>Welcome, {user.name}</h1>
        <Dashboard stats={user.stats} />
      </main>,
    ),
    renderServerComponent(
      <footer>
        <span>{config.copyright}</span>
        {config.footerLinks.map((link) => (
          <a key={link.id} href={link.url}>
            {link.label}
          </a>
        ))}
      </footer>,
    ),
  ])

  return { Header, Content, Footer }
})

export const Route = createFileRoute('/dashboard')({
  loader: async () => await getPageLayout(),
  component: DashboardPage,
})

function DashboardPage() {
  const { Header, Content, Footer } = Route.useLoaderData()

  return (
    <>
      {Header}
      {Content}
      {Footer}
    </>
  )
}

使用嵌套结构

或者,从一个服务器函数返回嵌套的对象结构。

  • 当你只需要 renderable(无插槽)时,用 renderServerComponent
  • 当你想让每个部分都接受插槽时,用 createCompositeComponent
const getPageLayout = createServerFn().handler(async () => {
  const user = await db.users.getCurrent()
  const config = await db.config.get()

  const [Header, Content, Footer] = await Promise.all([
    createCompositeComponent((props: { children?: React.ReactNode }) => (
      <header>
        <Logo />
        <nav>
          {config.navItems.map((item) => (
            <NavLink key={item.id} {...item} />
          ))}
        </nav>
        <UserMenu name={user.name} />
        {props.children}
      </header>
    )),
    createCompositeComponent(
      (props: { renderActions?: () => React.ReactNode }) => (
        <main>
          <h1>Welcome, {user.name}</h1>
          <Dashboard stats={user.stats} />
          {props.renderActions?.()}
        </main>
      ),
    ),
    createCompositeComponent(() => (
      <footer>
        <span>{config.copyright}</span>
        {config.footerLinks.map((link) => (
          <a key={link.id} href={link.url}>
            {link.label}
          </a>
        ))}
      </footer>
    )),
  ])

  return { Header, Content, Footer }
})

export const Route = createFileRoute('/dashboard')({
  loader: async () => ({
    Layout: await getPageLayout(),
  }),
  component: DashboardPage,
})

用点号记法渲染嵌套的复合组件:

import { CompositeComponent } from '@tanstack/react-start/rsc'

function DashboardPage() {
  const { Layout } = Route.useLoaderData()

  return (
    <>
      <CompositeComponent src={Layout.Header}>
        <button onClick={() => setMenuOpen(true)}>Menu</button>
      </CompositeComponent>
      <CompositeComponent
        src={Layout.Content}
        renderActions={() => <ActionButtons />}
      />
      <CompositeComponent src={Layout.Footer} />
    </>
  )
}

或者从加载器数据中解构它们:

import { CompositeComponent } from '@tanstack/react-start/rsc'

function DashboardPage() {
  const { Header, Content, Footer } = Route.useLoaderData().Layout

  return (
    <>
      <CompositeComponent src={Header}>
        <button onClick={() => setMenuOpen(true)}>Menu</button>
      </CompositeComponent>
      <CompositeComponent
        src={Content}
        renderActions={() => <ActionButtons />}
      />
      <CompositeComponent src={Footer} />
    </>
  )
}

每个嵌套组件独立接收自己的插槽 props。传给 <CompositeComponent src={Header}>children 只影响那个组件,不影响其他组件。

三个组件共享来自同一次数据库查询的 user 和 config 数据。

延迟组件加载

await 服务器组件的 Promise,直接返回它们。客户端用 React.use() 配合 Suspense,让每个组件在解析完成后渲染。

什么时候用: 组件的数据延迟不同,希望更快的结果先渲染,而不是被最慢的查询卡住。避免等待最慢的查询。

import { Suspense, use } from 'react'

const getDashboardBundle = createServerFn().handler(() => ({
  // Fast - resolves in ~100ms
  QuickStats: (async () => {
    const stats = await cache.getStats() // Fast cache hit
    return renderServerComponent(<StatsCard data={stats} />)
  })(),

  // Medium - resolves in ~500ms
  RecentActivity: (async () => {
    const activity = await db.activity.getRecent()
    return renderServerComponent(<ActivityFeed items={activity} />)
  })(),

  // Slow - resolves in ~2000ms
  Analytics: (async () => {
    const data = await analytics.computeMetrics() // Expensive query
    return renderServerComponent(<AnalyticsChart data={data} />)
  })(),
}))

export const Route = createFileRoute('/dashboard')({
  loader: () => getDashboardBundle(),
  component: DashboardPage,
})

function DashboardPage() {
  const { QuickStats, RecentActivity, Analytics } = Route.useLoaderData()

  return (
    <div>
      <Suspense fallback={<Skeleton />}>
        <Deferred promise={QuickStats} />
      </Suspense>

      <Suspense fallback={<Skeleton />}>
        <Deferred promise={RecentActivity} />
      </Suspense>

      <Suspense fallback={<Skeleton />}>
        <Deferred promise={Analytics} />
      </Suspense>
    </div>
  )
}

function Deferred({ promise }: { promise: Promise<unknown> }) {
  const Renderable = use(promise)
  return <>{Renderable}</>
}

QuickStats 最先出现,RecentActivity 随后,Analytics 最后加载。用户看到的是渐进呈现的内容,而不是等待所有东西。

服务器组件内部的 Suspense

直接在服务器组件内部使用 React 的 Suspense,让组件的各部分在就绪后流式呈现。

什么时候用: 单个服务器组件里有多个异步子组件,希望它们独立流式呈现。把相关的 UI 保留在一个组件里,同时允许渐进式渲染。

async function SlowMetric({ label, delay }: { label: string; delay: number }) {
  await new Promise((resolve) => setTimeout(resolve, delay))
  const value = await db.metrics.get(label)

  return (
    <div className="metric">
      <span>{label}</span>
      <span>{value.toLocaleString()}</span>
    </div>
  )
}

const getAnalyticsDashboard = createServerFn().handler(() =>
  renderServerComponent(
    <div className="dashboard">
      <h1>Analytics</h1>

      <div className="metrics-grid">
        <Suspense fallback={<MetricSkeleton label="Active Users" />}>
          <SlowMetric label="Active Users" delay={500} />
        </Suspense>

        <Suspense fallback={<MetricSkeleton label="Revenue" />}>
          <SlowMetric label="Revenue" delay={1500} />
        </Suspense>

        <Suspense fallback={<MetricSkeleton label="Conversion" />}>
          <SlowMetric label="Conversion" delay={2500} />
        </Suspense>
      </div>
    </div>,
  ),
)

每个指标独立流式呈现。仪表盘外壳立刻出现,然后各个指标随着数据加载逐个弹出。

用异步生成器流式传输

用异步生成器逐个流式传输服务器组件。客户端在生成器产生每个组件时接收并渲染它。

什么时候用: 无界或很大的结果集,希望条目增量渲染。当每个条目的处理时间不同,或总数未知时很有用。

import {
  CompositeComponent,
  createCompositeComponent,
} from '@tanstack/react-start/rsc'

const streamNotifications = createServerFn().handler(async function* () {
  // Yield initial batch immediately
  const recent = await db.notifications.getRecent(3)
  for (const notification of recent) {
    yield await createCompositeComponent<{
      renderActions?: (data: { id: string }) => React.ReactNode
    }>((props) => (
      <div className="notification">
        <h3>{notification.title}</h3>
        <p>{notification.message}</p>
        {props.renderActions?.({ id: notification.id })}
      </div>
    ))
  }

  // Stream older notifications with delays
  const older = await db.notifications.getOlder(5)
  for (const notification of older) {
    await new Promise((resolve) => setTimeout(resolve, 300))
    yield await createCompositeComponent<{
      renderActions?: (data: { id: string }) => React.ReactNode
    }>((props) => (
      <div className="notification">
        <h3>{notification.title}</h3>
        <p>{notification.message}</p>
        {props.renderActions?.({ id: notification.id })}
      </div>
    ))
  }
})

export const Route = createFileRoute('/notifications')({
  component: NotificationsPage,
})

function NotificationsPage() {
  const [notifications, setNotifications] = React.useState<Array<unknown>>([])
  const [isStreaming, setIsStreaming] = React.useState(false)

  const startStreaming = React.useCallback(async () => {
    setNotifications([])
    setIsStreaming(true)

    const stream = await streamNotifications()
    for await (const notification of stream) {
      setNotifications((prev) => [...prev, notification])
    }

    setIsStreaming(false)
  }, [])

  return (
    <div>
      <button onClick={startStreaming} disabled={isStreaming}>
        {isStreaming ? 'Streaming...' : 'Load Notifications'}
      </button>

      {notifications.map((notificationSrc, i) => (
        <CompositeComponent
          key={i}
          src={notificationSrc}
          renderActions={({ id }) => (
            <button onClick={() => markAsRead(id)}>Mark read</button>
          )}
        />
      ))}
    </div>
  )
}

通知逐个出现。前三条立刻显示,然后更多内容继续流入。每个都通过 render props 支持客户端交互。

错误处理

服务器组件中的错误——无论是数据获取还是渲染期间——都会传播到客户端。

路由级错误

如果服务器组件在 loader 中加载失败,路由的 errorComponent 会渲染:

export const Route = createFileRoute('/')({
  loader: async () => ({
    // If this fails, the errorComponent renders
    Greeting: await getGreeting(),
  }),
  errorComponent: ({ error }) => <div>Failed to load: {error.message}</div>,
  component: HomePage,
})

组件级错误

要隔离错误(比如防止单个失败的 widget 拖垮整个页面),你必须使用延迟加载

让 loader 返回 Promise 而不是 await 它,路由组件会立即渲染。如果 Promise 之后被拒绝,组件内的 ErrorBoundary 会捕获它。

// 1. Loader returns a Promise (don't await!)
export const Route = createFileRoute('/dashboard')({
  loader: () => ({
    // If this fails, only the specific ErrorBoundary below catches it
    WidgetPromise: getWidget(),
  }),
  component: DashboardPage,
})

// 2. Component handles the potential failure
function DashboardPage() {
  const { WidgetPromise } = Route.useLoaderData()

  return (
    <ErrorBoundary fallback={<div>Widget unavailable</div>}>
      <React.Suspense fallback={<Skeleton />}>
        <Deferred promise={WidgetPromise} />
      </React.Suspense>
    </ErrorBoundary>
  )
}

技巧

使用 React.cache

React.cache 可以在服务器组件内部用于请求级记忆化(memoization)。当多个组件需要同一个开销大的计算时很有用:

import { cache } from 'react'

const getUser = cache(async (userId: string) => {
  return db.users.findById(userId)
})

// Both components share the same cached result within a single request
async function UserHeader() {
  const user = await getUser('123') // Fetches from DB
  return <h1>{user.name}</h1>
}

async function UserSidebar() {
  const user = await getUser('123') // Returns cached result
  return <aside>{user.bio}</aside>
}

服务器组件中的路由链接

TanStack Router 的 Link 组件可以在服务器组件内部工作。链接会被序列化,并在客户端水合以支持客户端导航:

import { Link } from '@tanstack/react-router'

const getNavigation = createServerFn().handler(async () => {
  const pages = await db.pages.list()

  return renderServerComponent(
    <nav>
      {pages.map((page) => (
        <Link key={page.id} to="/pages/$pageId" params={{ pageId: page.id }}>
          {page.title}
        </Link>
      ))}
    </nav>,
  )
})

服务器组件中的 CSS

CSS Modules 和全局 CSS 导入可以在服务器组件中工作。样式会被提取并发送给客户端:

import styles from './Card.module.css'

const getCard = createServerFn().handler(async () => {
  return renderServerComponent(
    <div className={styles.card}>
      <h2 className={styles.title}>Server Rendered</h2>
    </div>,
  )
})

规则与限制

服务器上的插槽是不透明的

服务器无法检查插槽内容。React.Children.map()cloneElement()props.children 上不会生效:

// Won't work - children is a placeholder on the server
createCompositeComponent((props: { children?: React.ReactNode }) => (
  <div>
    {React.Children.map(props.children, (child) =>
      React.cloneElement(child, { extra: 'prop' }),
    )}
  </div>
))

// Do this instead - use render props
createCompositeComponent<{
  renderItem?: (data: { extra: string }) => React.ReactNode
}>((props) => <div>{props.renderItem?.({ extra: 'prop' })}</div>)

Render prop 参数必须可序列化

通过 render props 或组件传给插槽的参数会经过 React 的 Flight 协议。只能使用可序列化的值:字符串、数字、布尔值、null、普通对象和数组。

工作原理

当服务器组件访问插槽 props 时,它访问的是一个代理(proxy):

  • 读取 props.children 会创建一个「调用方提供什么 children」的占位符
  • 调用 props.renderFn(args) 会创建一个记录 args 的占位符

TanStack Start 发送一个带这些占位符的 React Flight 流。在客户端,占位符会被你渲染时传入的实际 props 替换。

当前状态

服务器组件在 TanStack Start 中仍是实验性的,并且会一直保持到 v1 早期。

序列化: 使用 React 原生的 Flight 协议。TanStack Start 的自定义序列化还不能用于服务器组件。基本类型、Date 和 React 元素可用。自定义序列化会在未来的版本中提供。

API: RSC 辅助函数的 API 将来可能会有所调整。

有问题?提交 issue 或加入 Discord

底层 Flight 流 API

对于高级场景(自定义流式协议、API 路由集成、外部 RSC 感知系统),TanStack Start 暴露了底层的 Flight 流 API。大多数情况下,请优先使用高层辅助函数,它们会自动处理缓存、流式传输,以及(对于复合组件)插槽。

@tanstack/react-start/rsc 导入:

函数可用环境说明
renderToReadableStream仅服务器函数把 React 元素渲染为 Flight 流
createFromFetch客户端Promise<Response> 解码 Flight 流
createFromReadableStream客户端/SSRReadableStream 解码 Flight 流

createFromFetchcreateFromReadableStream 的便捷包装,它直接接收一个 fetch promise,并在内部提取 body 流。

示例

// src/routes/api/rsc.ts - API route with Flight stream
import { createAPIFileRoute } from '@tanstack/react-start/api'
import { createServerFn } from '@tanstack/react-start'
import { renderToReadableStream } from '@tanstack/react-start/rsc'

const getFlightStream = createServerFn({ method: 'GET' }).handler(async () => {
  return renderToReadableStream(<div>Server Rendered Content</div>)
})

export const APIRoute = createAPIFileRoute('/api/rsc')({
  GET: async () => {
    const stream = await getFlightStream()
    return new Response(stream, {
      headers: { 'Content-Type': 'text/x-component' },
    })
  },
})
// Client: fetch and decode the Flight stream
import { createFromFetch } from '@tanstack/react-start/rsc'

async function fetchRSC() {
  return createFromFetch(fetch('/api/rsc'))
}

On this page