본문으로 건너뛰기

Server Components

[!WARNING] Server Components는 실험적 기능입니다! API가 개선될 수 있습니다.

Server Components를 사용하면 서버에서 React 컴포넌트를 렌더링하고 클라이언트로 스트리밍할 수 있습니다. 무거운 의존성은 번들에 포함되지 않고, 데이터 가져오기는 컴포넌트 내에서 처리되며, 민감한 로직은 브라우저에 절대 전달되지 않습니다.

설정

Server Components는 기본적으로 활성화되어 있지 않습니다. 먼저 다음 세 단계를 완료합니다:

1. 빌드 도구별 RSC 의존성 설치

Vite

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

Rsbuild

Rsbuild에는 별도의 RSC 플러그인이 필요하지 않습니다. Rsbuild React 종속성이 설치되어 있는지 확인합니다:

npm install -D @rsbuild/core @rsbuild/plugin-react

2. 빌드 도구 구성

TanStack Start 플러그인에서 RSC를 활성화하도록 빌드 도구 구성을 업데이트합니다:

Vite

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(),
],
})

Rsbuild

rsbuild.config.ts
import { defineConfig } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild'

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

요구 사항: React 19+, Vite 7+ 또는 Rsbuild 2+

빠른 시작

TanStack Start에서는 일반적으로 서버 함수에서 서버 렌더링 UI를 생성한 다음, 라우트 loader을 통해 반환합니다.

두 가지 상위 수준 RSC 헬퍼가 있습니다:

  • renderServerComponent(<Element />){Renderable}처럼 인라인으로 사용할 수 있는 렌더링 가능한 값을 반환합니다.
  • createCompositeComponent((props) => <Element />)<CompositeComponent src={...} />을 통해 렌더링되는 복합 소스를 반환합니다(슬롯 지원).

렌더링 가능(슬롯 없음)

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

복합(슬롯)

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

서버 컴포넌트를 사용하는 이유

  • 더 작은 번들. Markdown 파서, 구문 강조 도구, 무거운 라이브러리는 서버에서 실행됩니다. 렌더링된 HTML만 클라이언트에 전달됩니다.
  • 함께 배치된 데이터 가져오기. 데이터가 필요한 컴포넌트에서 직접 데이터를 가져옵니다.
  • 기본적으로 안전함. API 키, 데이터베이스 쿼리, 비즈니스 로직은 클라이언트 번들에 절대 포함되지 않습니다.
  • 점진적 스트리밍. UI는 렌더링되는 동시에 브라우저로 스트리밍됩니다. 사용자는 콘텐츠를 즉시 볼 수 있습니다.

Props 전달 및 컴포지션

renderServerComponent에서 반환된 렌더링 가능한 값은 슬롯을 지원하지 않습니다.

클라이언트에서 제공하는 props("슬롯")를 받으려면 createCompositeComponent을 사용하고 <CompositeComponent src={...} />으로 렌더링합니다.

슬롯은 서버 컴포넌트 props에 선언됩니다. 슬롯에는 세 가지 유형이 있습니다:

슬롯 유형사용 사례서버가 데이터를 전달할 수 있나요?
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>
)
}

렌더 프롭

서버가 클라이언트에서 렌더링되는 콘텐츠에 데이터를 전달해야 할 때 렌더 프롭을 사용합니다:

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

컴포넌트 프롭

React 컴포넌트를 프롭으로 전달합니다. 클라이언트에서는 전달된 프롭이 서버에서 제공한 데이터와 함께 렌더링됩니다:

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

컴포넌트 프롭은 다음과 같은 경우에 유용합니다:

  • 재사용 가능한 클라이언트 컴포넌트를 전달하려는 경우
  • 컴포넌트에 자체 상태 또는 이벤트 핸들러가 필요한 경우
  • 렌더 프롭 콜백보다 컴포넌트 컴포지션을 선호하는 경우

세 가지 슬롯 유형을 모두 조합할 수 있습니다. createCompositeComponent는 슬롯 프롭에 완전한 타입 안전성을 제공합니다.

캐싱

서버 컴포넌트는 TanStack Router에 내장된 캐싱과 함께 작동합니다. 캐시 키는 라우트 경로와 매개변수의 조합입니다:

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

라우트 매개변수 이외의 캐시 키에는 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} />
}

[!IMPORTANT] React Query로 서버 컴포넌트를 캐싱할 때는 항상 structuralSharing: false를 설정합니다. 설정하지 않으면 React Query가 페치 간에 RSC 값을 병합하려고 시도하여 오류가 발생할 수 있습니다.

무효화

데이터가 변경된 후 서버 컴포넌트를 다시 페치하려면 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} />}
/>
)
}

다음과 같은 경우에 유용합니다:

  • 서버 컴포넌트가 데이터를 가져와 정적 콘텐츠를 렌더링합니다
  • 라우트 컴포넌트에 window, localStorage 또는 기타 브라우저 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>
)
}

각 서버 함수는 동시에 실행됩니다. 모두 완료되면 페이지가 렌더링됩니다.

여러 컴포넌트 묶기

여러 서버 컴포넌트가 데이터를 공유하거나 함께 가져와야 할 때 단일 서버 함수에서 반환합니다. 이렇게 하면 네트워크 왕복 횟수가 줄어듭니다.

사용 시점: 가져온 데이터를 공유하거나, 단일 캐시 키가 필요하거나, 함께 무효화되어야 하는 컴포넌트에 사용합니다. 데이터베이스 쿼리와 네트워크 왕복 횟수를 줄입니다.

Promise.all 사용하기

여러 renderServerComponent 또는 createCompositeComponent 호출을 생성하고 객체로 반환합니다:

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

중첩 구조 사용

또는 단일 서버 함수에서 중첩된 객체 구조를 반환합니다.

  • 렌더링 가능한 요소만 필요한 경우(슬롯 없음) 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은 해당 컴포넌트에만 영향을 주며 다른 컴포넌트에는 영향을 주지 않습니다.

세 컴포넌트 모두 단일 데이터베이스 쿼리에서 가져온 동일한 사용자 및 구성 데이터를 공유합니다.

지연된 컴포넌트 로딩

서버 컴포넌트를 기다리는 대신 해당 Promise를 반환합니다. 클라이언트는 Suspense과 함께 React.use()을 사용하여 각 컴포넌트가 해결되는 즉시 렌더링합니다.

사용 시점: 데이터 지연 시간이 서로 달라 느린 결과가 완료되기 전에 빠른 결과를 렌더링해야 하는 컴포넌트에 사용합니다. 가장 느린 쿼리로 인한 차단을 방지합니다.

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

각 지표는 독립적으로 스트리밍됩니다. 대시보드 셸이 즉시 표시된 다음, 데이터가 로드되는 대로 지표가 나타납니다.

비동기 제너레이터를 사용한 스트리밍

비동기 제너레이터를 사용하여 서버 컴포넌트를 한 번에 하나씩 스트리밍합니다. 클라이언트는 각 컴포넌트가 yield될 때 이를 받아 렌더링합니다.

사용 시점: 항목을 점진적으로 렌더링해야 하는 무제한 또는 대규모 결과 집합에 사용합니다. 항목별 처리 시간이 서로 다르거나 전체 개수를 미리 알 수 없을 때 유용합니다.

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

알림이 하나씩 표시됩니다. 처음 세 개가 즉시 표시된 다음 더 많은 알림이 스트리밍됩니다. 각 알림은 렌더 props를 통해 클라이언트 상호작용을 지원합니다.

오류 처리

Server Components에서 데이터 가져오기 또는 렌더링 중 발생한 오류는 클라이언트로 전파됩니다.

라우트 수준 오류

Server Component를 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,
})

컴포넌트 수준 오류

오류를 격리하려면(예: 단일 위젯의 실패로 페이지 전체가 중단되지 않도록 하려면) **지연 로딩**을 사용해야 합니다.

로더에서 Promise를 기다리지 않고 반환하면 Route Component가 즉시 렌더링됩니다. 이후 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는 서버 컴포넌트 내부에서 요청 범위 메모이제이션을 지원합니다. 여러 컴포넌트에 동일한 고비용 연산이 필요할 때 유용합니다:

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는 서버 컴포넌트에서 작동합니다. 스타일은 추출되어 클라이언트로 전송됩니다:

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>)

렌더 prop 인수는 직렬화할 수 있어야 합니다

렌더 prop 및/또는 컴포넌트를 통해 슬롯에 전달되는 인수는 React의 Flight 프로토콜을 거칩니다. 문자열, 숫자, 불리언, null, 일반 객체, 배열과 같이 직렬화할 수 있는 값만 사용합니다.

작동 방식

서버 컴포넌트가 슬롯 prop에 접근하면 프록시에 접근하게 됩니다:

  • props.children을 읽으면 "호출자가 제공하는 모든 자식"을 위한 자리 표시자가 생성됩니다.
  • props.renderFn(args)을 호출하면 args을 기록하는 자리 표시자가 생성됩니다.

TanStack Start는 이러한 자리 표시자가 포함된 React Flight 스트림을 전송합니다. 클라이언트에서는 렌더링할 때 전달한 실제 props로 자리 표시자가 대체됩니다.

현재 상태

Server Components는 TanStack Start에서 실험적 기능이며 v1 초기까지도 이 상태로 유지됩니다.

직렬화: React의 네이티브 Flight 프로토콜을 사용합니다. TanStack Start의 사용자 지정 직렬화는 아직 server components에서 사용할 수 없습니다. 기본형, Dates, React 요소는 작동합니다. 사용자 지정 직렬화는 향후 릴리스에서 제공될 예정입니다.

API: RSC 헬퍼 API는 개선될 수 있습니다.

궁금한 점이 있나요? 이슈를 등록하거나 Discord에 참여하세요.

저수준 Flight 스트림 API

고급 사용 사례(사용자 지정 스트리밍 프로토콜, API 라우트 통합, 외부 RSC 인식 시스템)를 위해 TanStack Start는 저수준 Flight 스트림 API를 제공합니다. 대부분의 경우 캐싱과 스트리밍을 처리하고 (컴포지트의 경우) 슬롯도 자동으로 처리하는 고수준 헬퍼를 사용하는 것이 좋습니다.

@tanstack/react-start/rsc에서 가져옵니다:

함수사용 가능한 위치설명
renderToReadableStream서버 함수에서만React 요소를 Flight 스트림으로 렌더링합니다
createFromFetch클라이언트Promise<Response>의 Flight 스트림을 디코딩합니다
createFromReadableStream클라이언트/SSRReadableStream의 Flight 스트림을 디코딩합니다

createFromFetch은 fetch 프로미스를 직접 받아 내부에서 본문 스트림을 추출하는 createFromReadableStream의 편의 래퍼입니다.

예시

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