본문으로 건너뛰기
버전: 11.x

React Server Components로 설정하기

이 문서는 'Classic' React Query 통합에 대한 문서입니다. 여전히 지원되지만, TanStack React Query를 사용하여 새로운 tRPC 프로젝트를 시작하는 권장 방식은 아닙니다. 대신 새로운 TanStack React Query Integration 사용을 권장합니다.

Next.js를 사용 중이신가요? 권장되는 접근 방식을 확인하려면 전용 Next.js App Router 설정 가이드를 참조하세요.

이 가이드는 Next.js App Router와 같은 React Server Components(RSC) 프레임워크에서 tRPC를 사용하는 방법에 대한 개요입니다. RSC 자체만으로도 tRPC가 해결하도록 설계된 많은 문제를 해결하므로, tRPC가 전혀 필요하지 않을 수 있습니다.

tRPC와 RSC를 통합하는 단 하나의 정답은 없습니다. 이 가이드를 시작점으로 삼아 프로젝트의 요구 사항과 선호에 맞게 조정하세요.

정보

Server Actions와 함께 tRPC를 사용하는 방법을 찾고 있다면 Server Actions 가이드를 참조하세요.

주의

진행하기 전에 React Query의 Advanced Server Rendering 문서를 읽고 다양한 서버 렌더링 유형과 피해야 할 함정을 이해하세요.

기존 프로젝트에 tRPC 추가하기

1. 의존성 설치

npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query@latest zod client-only server-only

2. tRPC 라우터 생성

trpc/init.ts에서 initTRPC 함수를 사용하여 tRPC 백엔드를 초기화하고 첫 번째 라우터를 생성합니다. 여기서는 간단한 "hello world" 라우터와 프로시저를 만들지만, tRPC API 생성에 대한 더 자세한 정보는 tRPC 정보를 위해 Quickstart guideBackend usage docs를 참조해야 합니다.

정보

여기서 사용된 파일 이름은 tRPC에서 강제하지 않습니다. 원하는 파일 구조를 사용할 수 있습니다.

샘플 백엔드 보기
trpc/init.ts
ts
import { initTRPC } from '@trpc/server';
import { cache } from 'react';
 
export const createTRPCContext = cache(async () => {
/**
* @see: https://trpc.io/docs/server/context
*/
return { userId: 'user_123' };
});
 
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.create({
/**
* @see https://trpc.io/docs/server/data-transformers
*/
// transformer: superjson,
});
 
// Base router and procedure helpers
export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const baseProcedure = t.procedure;
trpc/init.ts
ts
import { initTRPC } from '@trpc/server';
import { cache } from 'react';
 
export const createTRPCContext = cache(async () => {
/**
* @see: https://trpc.io/docs/server/context
*/
return { userId: 'user_123' };
});
 
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.create({
/**
* @see https://trpc.io/docs/server/data-transformers
*/
// transformer: superjson,
});
 
// Base router and procedure helpers
export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const baseProcedure = t.procedure;

trpc/routers/_app.ts
ts
import { z } from 'zod';
import { baseProcedure, createTRPCRouter } from '../init';
 
export const appRouter = createTRPCRouter({
hello: baseProcedure
.input(
z.object({
text: z.string(),
}),
)
.query((opts) => {
return {
greeting: `hello ${opts.input.text}`,
};
}),
});
 
// export type definition of API
export type AppRouter = typeof appRouter;
trpc/routers/_app.ts
ts
import { z } from 'zod';
import { baseProcedure, createTRPCRouter } from '../init';
 
export const appRouter = createTRPCRouter({
hello: baseProcedure
.input(
z.object({
text: z.string(),
}),
)
.query((opts) => {
return {
greeting: `hello ${opts.input.text}`,
};
}),
});
 
// export type definition of API
export type AppRouter = typeof appRouter;

노트

백엔드 어댑터는 프레임워크와 API 라우트 설정 방식에 따라 달라집니다. 다음 예제는 Next.js에서 fetch adapter를 사용하여 /api/trpc/*에 GET 및 POST 라우트를 설정합니다.

app/api/trpc/[trpc]/route.ts
ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createTRPCContext } from '../../../../trpc/init';
import { appRouter } from '../../../../trpc/routers/_app';
 
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
});
 
export { handler as GET, handler as POST };
app/api/trpc/[trpc]/route.ts
ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createTRPCContext } from '../../../../trpc/init';
import { appRouter } from '../../../../trpc/routers/_app';
 
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
});
 
export { handler as GET, handler as POST };

3. Query Client 팩토리 생성

QueryClient 인스턴스를 생성하는 함수를 내보내는 공유 파일 trpc/query-client.ts 파일을 생성합니다.

trpc/query-client.ts
ts
import {
defaultShouldDehydrateQuery,
QueryClient,
} from '@tanstack/react-query';
import superjson from 'superjson';
 
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
},
dehydrate: {
// serializeData: superjson.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
hydrate: {
// deserializeData: superjson.deserialize,
},
},
});
}
trpc/query-client.ts
ts
import {
defaultShouldDehydrateQuery,
QueryClient,
} from '@tanstack/react-query';
import superjson from 'superjson';
 
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
},
dehydrate: {
// serializeData: superjson.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
hydrate: {
// deserializeData: superjson.deserialize,
},
},
});
}

여기서 몇 가지 기본 옵션을 설정합니다:

  • staleTime: SSR을 사용할 때, 클라이언트에서 즉시 다시 가져오기를 피하기 위해 staleTime을 0보다 큰 기본값으로 설정하는 것이 일반적입니다.
  • shouldDehydrateQuery: 쿼리를 탈수화(dehydrate)할지 여부를 결정하는 함수입니다. RSC 전송 프로토콜은 네트워크를 통한 Promise 수화(hydrate)를 지원하므로, defaultShouldDehydrateQuery 함수를 확장하여 아직 대기 중인 쿼리도 포함하도록 합니다. 이를 통해 트리 상단의 서버 컴포넌트에서 사전 가져오기를 시작하고, 트리 하단의 클라이언트 컴포넌트에서 해당 Promise를 소비할 수 있습니다.
  • serializeDatadeserializeData (선택 사항): 이전 단계에서 data transformer를 설정했다면, 이 옵션을 설정하여 서버-클라이언트 경계를 넘어 Query Client를 수화할 때 데이터가 올바르게 직렬화되도록 합니다.

4. Client Components용 tRPC 클라이언트 생성

trpc/client.tsx 파일은 클라이언트 컴포넌트에서 tRPC API를 소비할 때의 진입점입니다. 여기에서 tRPC 라우터의 타입 정의를 가져오고 createTRPCReact를 사용하여 타입 안전한 훅을 생성합니다. 또한 이 파일에서 컨텍스트 프로바이더를 내보냅니다.

trpc/client.tsx
tsx
'use client';
 
// ^-- to make sure we can mount the Provider from a server component
import type { QueryClient } from '@tanstack/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import React, { useState } from 'react';
import { makeQueryClient } from './query-client';
import type { AppRouter } from './routers/_app';
 
export const trpc = createTRPCReact<AppRouter>();
 
let clientQueryClientSingleton: QueryClient;
function getQueryClient() {
if (typeof window === 'undefined') {
// Server: always make a new query client
return makeQueryClient();
}
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= makeQueryClient());
}
 
function getUrl() {
const base = (() => {
if (typeof window !== 'undefined') return '';
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return 'http://localhost:3000';
})();
return `${base}/api/trpc`;
}
 
export function TRPCProvider(
props: Readonly<{
children: React.ReactNode;
}>,
) {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient();
 
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
// transformer: superjson, <-- if you use a data transformer
url: getUrl(),
}),
],
}),
);
 
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</trpc.Provider>
);
}
trpc/client.tsx
tsx
'use client';
 
// ^-- to make sure we can mount the Provider from a server component
import type { QueryClient } from '@tanstack/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import React, { useState } from 'react';
import { makeQueryClient } from './query-client';
import type { AppRouter } from './routers/_app';
 
export const trpc = createTRPCReact<AppRouter>();
 
let clientQueryClientSingleton: QueryClient;
function getQueryClient() {
if (typeof window === 'undefined') {
// Server: always make a new query client
return makeQueryClient();
}
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= makeQueryClient());
}
 
function getUrl() {
const base = (() => {
if (typeof window !== 'undefined') return '';
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return 'http://localhost:3000';
})();
return `${base}/api/trpc`;
}
 
export function TRPCProvider(
props: Readonly<{
children: React.ReactNode;
}>,
) {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient();
 
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
// transformer: superjson, <-- if you use a data transformer
url: getUrl(),
}),
],
}),
);
 
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</trpc.Provider>
);
}

프로바이더를 애플리케이션 루트에 마운트합니다(예: Next.js를 사용할 경우 app/layout.tsx).

5. Server Components용 tRPC 콜러 생성

서버 컴포넌트에서 쿼리를 사전 가져오려면 tRPC 콜러를 사용합니다. @trpc/react-query/rsc 모듈은 React Query 클라이언트와 통합되는 createCaller 주위의 얇은 래퍼를 내보냅니다.

trpc/server.tsx
tsx
import 'server-only'; // <-- ensure this file cannot be imported from the client
 
import { createHydrationHelpers } from '@trpc/react-query/rsc';
import { cache } from 'react';
import { createCallerFactory, createTRPCContext } from './init';
import { makeQueryClient } from './query-client';
import { appRouter } from './routers/_app';
 
// IMPORTANT: Create a stable getter for the query client that
// will return the same client during the same request.
export const getQueryClient = cache(makeQueryClient);
const caller = createCallerFactory(appRouter)(createTRPCContext);
 
export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>(
caller,
getQueryClient,
);
trpc/server.tsx
tsx
import 'server-only'; // <-- ensure this file cannot be imported from the client
 
import { createHydrationHelpers } from '@trpc/react-query/rsc';
import { cache } from 'react';
import { createCallerFactory, createTRPCContext } from './init';
import { makeQueryClient } from './query-client';
import { appRouter } from './routers/_app';
 
// IMPORTANT: Create a stable getter for the query client that
// will return the same client during the same request.
export const getQueryClient = cache(makeQueryClient);
const caller = createCallerFactory(appRouter)(createTRPCContext);
 
export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>(
caller,
getQueryClient,
);

API 사용

이제 앱에서 tRPC API를 사용할 수 있습니다. 다른 React 앱처럼 클라이언트 컴포넌트에서 React Query 훅을 사용할 수 있고, 컴포넌트 트리 상단의 서버 컴포넌트에서 쿼리를 미리 가져와 RSC의 장점도 활용할 수 있습니다. 이 패턴은 일반적으로 로더로 구현하는 "render as you fetch"와 같습니다. useQueryuseSuspenseQuery 훅에서 데이터가 필요해질 때까지 기다리지 않고 요청을 가능한 한 일찍 시작하는 방식입니다.

app/page.tsx
tsx
import { trpc, HydrateClient } from '../trpc/server';
import { ClientGreeting } from './client-greeting';
 
export default async function Home() {
void trpc.hello.prefetch();
 
return (
<HydrateClient>
<div>...</div>
{/** ... */}
<ClientGreeting />
</HydrateClient>
);
}
app/page.tsx
tsx
import { trpc, HydrateClient } from '../trpc/server';
import { ClientGreeting } from './client-greeting';
 
export default async function Home() {
void trpc.hello.prefetch();
 
return (
<HydrateClient>
<div>...</div>
{/** ... */}
<ClientGreeting />
</HydrateClient>
);
}
app/client-greeting.tsx
tsx
'use client';
 
// <-- hooks can only be used in client components
import { trpc } from '../trpc/client';
 
export function ClientGreeting() {
const greeting = trpc.hello.useQuery();
if (!greeting.data) return <div>Loading...</div>;
return <div>{greeting.data.greeting}</div>;
}
app/client-greeting.tsx
tsx
'use client';
 
// <-- hooks can only be used in client components
import { trpc } from '../trpc/client';
 
export function ClientGreeting() {
const greeting = trpc.hello.useQuery();
if (!greeting.data) return <div>Loading...</div>;
return <div>{greeting.data.greeting}</div>;
}

Suspense 활용

Suspense와 Error Boundaries를 사용하여 로딩 및 오류 상태를 처리하는 방식을 선호할 수 있습니다. useSuspenseQuery 훅을 사용하면 이를 구현할 수 있습니다.

app/page.tsx
tsx
import { trpc, HydrateClient } from '../trpc/server';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ClientGreeting } from './client-greeting';
 
export default async function Home() {
void trpc.hello.prefetch();
 
return (
<HydrateClient>
<div>...</div>
{/** ... */}
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<div>Loading...</div>}>
<ClientGreeting />
</Suspense>
</ErrorBoundary>
</HydrateClient>
);
}
app/page.tsx
tsx
import { trpc, HydrateClient } from '../trpc/server';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ClientGreeting } from './client-greeting';
 
export default async function Home() {
void trpc.hello.prefetch();
 
return (
<HydrateClient>
<div>...</div>
{/** ... */}
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<div>Loading...</div>}>
<ClientGreeting />
</Suspense>
</ErrorBoundary>
</HydrateClient>
);
}
app/client-greeting.tsx
tsx
'use client';
 
import { trpc } from '../trpc/client';
 
export function ClientGreeting() {
const [data] = trpc.hello.useSuspenseQuery();
return <div>{data.greeting}</div>;
}
app/client-greeting.tsx
tsx
'use client';
 
import { trpc } from '../trpc/client';
 
export function ClientGreeting() {
const [data] = trpc.hello.useSuspenseQuery();
return <div>{data.greeting}</div>;
}

서버 컴포넌트에서 데이터 가져오기

서버 컴포넌트에서 데이터에 접근해야 하는 경우, .prefetch()를 사용하는 대신 일반 서버 콜러와 마찬가지로 프로시저를 직접 호출할 수 있습니다. 이 방법은 쿼리 클라이언트와 분리되어 있으며 데이터를 캐시에 저장하지 않습니다. 따라서 서버 컴포넌트에서 데이터를 사용한 후 클라이언트에서 해당 데이터가 사용 가능할 것으로 기대할 수 없습니다. 이는 의도된 동작이며, 고급 서버 렌더링 가이드에서 자세히 설명되어 있습니다.

app/page.tsx
tsx
import { trpc } from '../trpc/server';
 
export default async function Home() {
// Use the caller directly without using `.prefetch()`
const greeting = await trpc.hello();
// ^? { greeting: string }
 
return <div>{greeting.greeting}</div>;
}
app/page.tsx
tsx
import { trpc } from '../trpc/server';
 
export default async function Home() {
// Use the caller directly without using `.prefetch()`
const greeting = await trpc.hello();
// ^? { greeting: string }
 
return <div>{greeting.greeting}</div>;
}