Next.js App Router로 설정하기
서버 렌더링의 다양한 유형과 피해야 할 함정을 이해하려면 TanStack React Query의 Advanced Server Rendering 문서를 읽는 것을 권장합니다.
권장 파일 구조
graphql.├── src│ ├── app│ │ ├── api│ │ │ └── trpc│ │ │ └── [trpc]│ │ │ └── route.ts # <-- tRPC HTTP handler│ │ ├── layout.tsx # <-- mount TRPCReactProvider│ │ └── page.tsx # <-- server component│ ├── trpc│ │ ├── init.ts # <-- tRPC server init & context│ │ ├── routers│ │ │ ├── _app.ts # <-- main app router│ │ │ ├── post.ts # <-- sub routers│ │ │ └── [..]│ │ ├── client.tsx # <-- client hooks & provider│ │ ├── query-client.ts # <-- shared QueryClient factory│ │ └── server.tsx # <-- server-side caller│ └── [..]└── [..]
graphql.├── src│ ├── app│ │ ├── api│ │ │ └── trpc│ │ │ └── [trpc]│ │ │ └── route.ts # <-- tRPC HTTP handler│ │ ├── layout.tsx # <-- mount TRPCReactProvider│ │ └── page.tsx # <-- server component│ ├── trpc│ │ ├── init.ts # <-- tRPC server init & context│ │ ├── routers│ │ │ ├── _app.ts # <-- main app router│ │ │ ├── post.ts # <-- sub routers│ │ │ └── [..]│ │ ├── client.tsx # <-- client hooks & provider│ │ ├── query-client.ts # <-- shared QueryClient factory│ │ └── server.tsx # <-- server-side caller│ └── [..]└── [..]
기존 Next.js App Router 프로젝트에 tRPC 추가하기
1. 의존성 설치
- npm
- yarn
- pnpm
- bun
- deno
npm install @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query@latest zod client-only server-only
yarn add @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query@latest zod client-only server-only
pnpm add @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query@latest zod client-only server-only
bun add @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query@latest zod client-only server-only
deno add npm:@trpc/server npm:@trpc/client npm:@trpc/tanstack-react-query npm:@tanstack/react-query@latest npm:zod npm:client-only npm:server-only
AI 코딩 에이전트를 사용하는 경우, 더 나은 코드 생성을 위해 tRPC 스킬을 설치하세요:
bashnpx @tanstack/intent@latest install
bashnpx @tanstack/intent@latest install
2. tRPC 라우터 생성
trpc/init.ts에서 initTRPC 함수를 사용하여 tRPC 백엔드를 초기화하고 첫 번째 라우터를 생성합니다. 여기서는 간단한 "hello world" 라우터와 프로시저를 만들 것입니다. tRPC API 생성에 대한 더 자세한 정보는 Quickstart guide 및 Backend usage docs를 참고하세요.
trpc/init.tstsimport {initTRPC } from '@trpc/server';/*** This context creator accepts `headers` so it can be reused in both* the RSC server caller (where you pass `next/headers`) and the* API route handler (where you pass the request headers).*/export constcreateTRPCContext = async (opts : {headers :Headers }) => {// const user = await auth(opts.headers);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.constt =initTRPC .context <Awaited <ReturnType <typeofcreateTRPCContext >>>().create ({/*** @see https://trpc.io/docs/server/data-transformers*/// transformer: superjson,});// Base router and procedure helpersexport constcreateTRPCRouter =t .router ;export constcreateCallerFactory =t .createCallerFactory ;export constbaseProcedure =t .procedure ;
trpc/init.tstsimport {initTRPC } from '@trpc/server';/*** This context creator accepts `headers` so it can be reused in both* the RSC server caller (where you pass `next/headers`) and the* API route handler (where you pass the request headers).*/export constcreateTRPCContext = async (opts : {headers :Headers }) => {// const user = await auth(opts.headers);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.constt =initTRPC .context <Awaited <ReturnType <typeofcreateTRPCContext >>>().create ({/*** @see https://trpc.io/docs/server/data-transformers*/// transformer: superjson,});// Base router and procedure helpersexport constcreateTRPCRouter =t .router ;export constcreateCallerFactory =t .createCallerFactory ;export constbaseProcedure =t .procedure ;
trpc/routers/_app.tstsimport {z } from 'zod';import {baseProcedure ,createTRPCRouter } from '../init';export constappRouter =createTRPCRouter ({hello :baseProcedure .input (z .object ({text :z .string (),}),).query ((opts ) => {return {greeting : `hello ${opts .input .text }`,};}),});// export type definition of APIexport typeAppRouter = typeofappRouter ;
trpc/routers/_app.tstsimport {z } from 'zod';import {baseProcedure ,createTRPCRouter } from '../init';export constappRouter =createTRPCRouter ({hello :baseProcedure .input (z .object ({text :z .string (),}),).query ((opts ) => {return {greeting : `hello ${opts .input .text }`,};}),});// export type definition of APIexport typeAppRouter = typeofappRouter ;
3. API 라우트 핸들러 생성
App Router에서는 tRPC 요청을 처리하기 위해 fetch adapter를 사용합니다. GET과 POST를 모두 내보내는 라우트 핸들러를 생성하세요:
app/api/trpc/[trpc]/route.tstsimport {fetchRequestHandler } from '@trpc/server/adapters/fetch';import {createTRPCContext } from './trpc/init';import {appRouter } from './trpc/routers/_app';consthandler = (req :Request ) =>fetchRequestHandler ({endpoint : '/api/trpc',req ,router :appRouter ,createContext : () =>createTRPCContext ({headers :req .headers }),});export {handler asGET ,handler asPOST };
app/api/trpc/[trpc]/route.tstsimport {fetchRequestHandler } from '@trpc/server/adapters/fetch';import {createTRPCContext } from './trpc/init';import {appRouter } from './trpc/routers/_app';consthandler = (req :Request ) =>fetchRequestHandler ({endpoint : '/api/trpc',req ,router :appRouter ,createContext : () =>createTRPCContext ({headers :req .headers }),});export {handler asGET ,handler asPOST };
App Router는 Pages Router에서 사용되는 Next.js 전용 어댑터 대신 fetch adapter(fetchRequestHandler를 통해)를 사용합니다. 이는 App Router의 라우트 핸들러가 Web 표준 Request 및 Response 객체에 기반하기 때문입니다.
4. Query Client 팩토리 생성
QueryClient 인스턴스를 생성하는 함수를 내보내는 공유 파일 trpc/query-client.ts를 생성하세요.
trpc/query-client.tstsimport {defaultShouldDehydrateQuery ,QueryClient ,} from '@tanstack/react-query';importsuperjson from 'superjson';export functionmakeQueryClient () {return newQueryClient ({defaultOptions : {queries : {staleTime : 30 * 1000,},dehydrate : {// serializeData: superjson.serialize,shouldDehydrateQuery : (query ) =>defaultShouldDehydrateQuery (query ) ||query .state .status === 'pending',},hydrate : {// deserializeData: superjson.deserialize,},},});}
trpc/query-client.tstsimport {defaultShouldDehydrateQuery ,QueryClient ,} from '@tanstack/react-query';importsuperjson from 'superjson';export functionmakeQueryClient () {return newQueryClient ({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 전송 프로토콜은 네트워크를 통한 프롬리스(hydrating)를 지원하므로,defaultShouldDehydrateQuery함수를 확장하여 아직 대기 중인 쿼리도 포함하도록 합니다. 이를 통해 트리 상단의 서버 컴포넌트에서 사전 가져오기를 시작하고, 더 아래쪽의 클라이언트 컴포넌트에서 해당 프롬리스를 소비할 수 있습니다.serializeData및deserializeData(선택 사항): 이전 단계에서 data transformer를 설정했다면, 이 옵션을 설정하여 서버-클라이언트 경계를 넘어 쿼리 클라이언트를 수화(hydrate)할 때 데이터가 올바르게 직렬화되도록 보장합니다.
5. Client Components용 tRPC 클라이언트 생성
trpc/client.tsx 파일은 클라이언트 컴포넌트에서 tRPC API를 소비할 때의 진입점입니다. 여기에서 tRPC 라우터의 타입 정의를 가져오고 createTRPCContext를 사용하여 타입 안전한 훅을 생성합니다. 또한 이 파일에서 컨텍스트 프로바이더도 내보냅니다.
trpc/client.tsxtsx'use client';// ^-- to make sure we can mount the Provider from a server componentimport type {QueryClient } from '@tanstack/react-query';import {QueryClientProvider } from '@tanstack/react-query';import {createTRPCClient ,httpBatchLink } from '@trpc/client';import {createTRPCContext } from '@trpc/tanstack-react-query';import {useState } from 'react';import {makeQueryClient } from './query-client';import type {AppRouter } from './routers/_app';export const {TRPCProvider ,useTRPC } =createTRPCContext <AppRouter >();letbrowserQueryClient :QueryClient ;functiongetQueryClient () {if (typeofwindow === 'undefined') {// Server: always make a new query clientreturnmakeQueryClient ();}// Browser: make a new query client if we don't already have one// This is very important, so we don't re-make a new client if React// suspends during the initial render. This may not be needed if we// have a suspense boundary BELOW the creation of the query clientif (!browserQueryClient )browserQueryClient =makeQueryClient ();returnbrowserQueryClient ;}functiongetUrl () {constbase = (() => {if (typeofwindow !== 'undefined') return '';if (process .env .VERCEL_URL ) return `https://${process .env .VERCEL_URL }`;return 'http://localhost:3000';})();return `${base }/api/trpc`;}export functionTRPCReactProvider (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 boundaryconstqueryClient =getQueryClient ();const [trpcClient ] =useState (() =>createTRPCClient <AppRouter >({links : [httpBatchLink ({// transformer: superjson, <-- if you use a data transformerurl :getUrl (),}),],}),);return (<QueryClientProvider client ={queryClient }><TRPCProvider trpcClient ={trpcClient }queryClient ={queryClient }>{props .children }</TRPCProvider ></QueryClientProvider >);}
trpc/client.tsxtsx'use client';// ^-- to make sure we can mount the Provider from a server componentimport type {QueryClient } from '@tanstack/react-query';import {QueryClientProvider } from '@tanstack/react-query';import {createTRPCClient ,httpBatchLink } from '@trpc/client';import {createTRPCContext } from '@trpc/tanstack-react-query';import {useState } from 'react';import {makeQueryClient } from './query-client';import type {AppRouter } from './routers/_app';export const {TRPCProvider ,useTRPC } =createTRPCContext <AppRouter >();letbrowserQueryClient :QueryClient ;functiongetQueryClient () {if (typeofwindow === 'undefined') {// Server: always make a new query clientreturnmakeQueryClient ();}// Browser: make a new query client if we don't already have one// This is very important, so we don't re-make a new client if React// suspends during the initial render. This may not be needed if we// have a suspense boundary BELOW the creation of the query clientif (!browserQueryClient )browserQueryClient =makeQueryClient ();returnbrowserQueryClient ;}functiongetUrl () {constbase = (() => {if (typeofwindow !== 'undefined') return '';if (process .env .VERCEL_URL ) return `https://${process .env .VERCEL_URL }`;return 'http://localhost:3000';})();return `${base }/api/trpc`;}export functionTRPCReactProvider (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 boundaryconstqueryClient =getQueryClient ();const [trpcClient ] =useState (() =>createTRPCClient <AppRouter >({links : [httpBatchLink ({// transformer: superjson, <-- if you use a data transformerurl :getUrl (),}),],}),);return (<QueryClientProvider client ={queryClient }><TRPCProvider trpcClient ={trpcClient }queryClient ={queryClient }>{props .children }</TRPCProvider ></QueryClientProvider >);}
애플리케이션의 루트 레이아웃에 프로바이더를 마운트하세요:
app/layout.tsxtsximport {TRPCReactProvider } from '~/trpc/client';export default functionRootLayout ({children ,}:Readonly <{children :React .ReactNode ;}>) {return (<html lang ="en"><body ><TRPCReactProvider >{children }</TRPCReactProvider ></body ></html >);}
app/layout.tsxtsximport {TRPCReactProvider } from '~/trpc/client';export default functionRootLayout ({children ,}:Readonly <{children :React .ReactNode ;}>) {return (<html lang ="en"><body ><TRPCReactProvider >{children }</TRPCReactProvider ></body ></html >);}
6. Server Components용 tRPC 콜러 생성
서버 컴포넌트에서 쿼리를 사전 가져오려면 라우터에서 프록시를 생성합니다. 라우터가 별도의 서버에 있는 경우 클라이언트를 전달할 수도 있습니다.
trpc/server.tsxtsximport 'server-only'; // <-- ensure this file cannot be imported from the clientimport {createTRPCOptionsProxy } from '@trpc/tanstack-react-query';import {headers } from 'next/headers';import {cache } from 'react';import {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 constgetQueryClient =cache (makeQueryClient );export consttrpc =createTRPCOptionsProxy ({ctx : async () =>createTRPCContext ({headers : awaitheaders (),}),router :appRouter ,queryClient :getQueryClient ,});// If your router is on a separate server, pass a client instead:// createTRPCOptionsProxy({// client: createTRPCClient({ links: [httpLink({ url: '...' })] }),// queryClient: getQueryClient,// });
trpc/server.tsxtsximport 'server-only'; // <-- ensure this file cannot be imported from the clientimport {createTRPCOptionsProxy } from '@trpc/tanstack-react-query';import {headers } from 'next/headers';import {cache } from 'react';import {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 constgetQueryClient =cache (makeQueryClient );export consttrpc =createTRPCOptionsProxy ({ctx : async () =>createTRPCContext ({headers : awaitheaders (),}),router :appRouter ,queryClient :getQueryClient ,});// If your router is on a separate server, pass a client instead:// createTRPCOptionsProxy({// client: createTRPCClient({ links: [httpLink({ url: '...' })] }),// queryClient: getQueryClient,// });
7. API 요청 수행
준비가 완료되었습니다! 이제 서버 컴포넌트에서 쿼리를 사전 가져오고 클라이언트 컴포넌트에서 이를 사용할 수 있습니다.
Server Component에서 사전 가져오기
app/page.tsxtsximport {dehydrate ,HydrationBoundary } from '@tanstack/react-query';import {getQueryClient ,trpc } from '~/trpc/server';import {ClientGreeting } from './client-greeting';export default async functionHome () {constqueryClient =getQueryClient ();voidqueryClient .prefetchQuery (trpc .hello .queryOptions ({text : 'world',}),);return (<HydrationBoundary state ={dehydrate (queryClient )}><ClientGreeting /></HydrationBoundary >);}
app/page.tsxtsximport {dehydrate ,HydrationBoundary } from '@tanstack/react-query';import {getQueryClient ,trpc } from '~/trpc/server';import {ClientGreeting } from './client-greeting';export default async functionHome () {constqueryClient =getQueryClient ();voidqueryClient .prefetchQuery (trpc .hello .queryOptions ({text : 'world',}),);return (<HydrationBoundary state ={dehydrate (queryClient )}><ClientGreeting /></HydrationBoundary >);}
Client Component에서 데이터 사용
app/client-greeting.tsxtsx'use client';// <-- hooks can only be used in client componentsimport {useQuery } from '@tanstack/react-query';import {useTRPC } from '~/trpc/client';export functionClientGreeting () {consttrpc =useTRPC ();constgreeting =useQuery (trpc .hello .queryOptions ({text : 'world' }));if (!greeting .data ) return <div >Loading...</div >;return <div >{greeting .data .greeting }</div >;}
app/client-greeting.tsxtsx'use client';// <-- hooks can only be used in client componentsimport {useQuery } from '@tanstack/react-query';import {useTRPC } from '~/trpc/client';export functionClientGreeting () {consttrpc =useTRPC ();constgreeting =useQuery (trpc .hello .queryOptions ({text : 'world' }));if (!greeting .data ) return <div >Loading...</div >;return <div >{greeting .data .greeting }</div >;}
이를 더 간결하게 만들기 위해 prefetch 및 HydrateClient 헬퍼 함수를 생성할 수 있습니다:
trpc/server.tsxtsxexport functionHydrateClient (props : {children :React .ReactNode }) {constqueryClient =getQueryClient ();return (<HydrationBoundary state ={dehydrate (queryClient )}>{props .children }</HydrationBoundary >);}export functionprefetch <T extendsReturnType <TRPCQueryOptions <any>>>(queryOptions :T ,) {constqueryClient =getQueryClient ();if (queryOptions .queryKey [1]?.type === 'infinite') {voidqueryClient .prefetchInfiniteQuery (queryOptions as any);} else {voidqueryClient .prefetchQuery (queryOptions );}}
trpc/server.tsxtsxexport functionHydrateClient (props : {children :React .ReactNode }) {constqueryClient =getQueryClient ();return (<HydrationBoundary state ={dehydrate (queryClient )}>{props .children }</HydrationBoundary >);}export functionprefetch <T extendsReturnType <TRPCQueryOptions <any>>>(queryOptions :T ,) {constqueryClient =getQueryClient ();if (queryOptions .queryKey [1]?.type === 'infinite') {voidqueryClient .prefetchInfiniteQuery (queryOptions as any);} else {voidqueryClient .prefetchQuery (queryOptions );}}
그런 다음 다음과 같이 사용할 수 있습니다:
tsximport {HydrateClient ,prefetch ,trpc } from '~/trpc/server';import {ClientGreeting } from './client-greeting';functionHome () {prefetch (trpc .hello .queryOptions ({text : 'world' }));return (<HydrateClient ><ClientGreeting /></HydrateClient >);}
tsximport {HydrateClient ,prefetch ,trpc } from '~/trpc/server';import {ClientGreeting } from './client-greeting';functionHome () {prefetch (trpc .hello .queryOptions ({text : 'world' }));return (<HydrateClient ><ClientGreeting /></HydrateClient >);}
Suspense 활용하기
Suspense와 Error Boundaries를 사용하여 로딩 및 오류 상태를 처리하는 방식을 선호할 수 있습니다. useSuspenseQuery 훅을 사용하면 이를 구현할 수 있습니다.
app/page.tsxtsximport {HydrateClient ,prefetch ,trpc } from '~/trpc/server';import {Suspense } from 'react';import {ErrorBoundary } from 'react-error-boundary';import {ClientGreeting } from './client-greeting';export default async functionHome () {prefetch (trpc .hello .queryOptions ());return (<HydrateClient ><ErrorBoundary fallback ={<div >Something went wrong</div >}><Suspense fallback ={<div >Loading...</div >}><ClientGreeting /></Suspense ></ErrorBoundary ></HydrateClient >);}
app/page.tsxtsximport {HydrateClient ,prefetch ,trpc } from '~/trpc/server';import {Suspense } from 'react';import {ErrorBoundary } from 'react-error-boundary';import {ClientGreeting } from './client-greeting';export default async functionHome () {prefetch (trpc .hello .queryOptions ());return (<HydrateClient ><ErrorBoundary fallback ={<div >Something went wrong</div >}><Suspense fallback ={<div >Loading...</div >}><ClientGreeting /></Suspense ></ErrorBoundary ></HydrateClient >);}
app/client-greeting.tsxtsx'use client';import {useSuspenseQuery } from '@tanstack/react-query';import {useTRPC } from '~/trpc/client';export functionClientGreeting () {consttrpc =useTRPC ();const {data } =useSuspenseQuery (trpc .hello .queryOptions ());return <div >{data .greeting }</div >;}
app/client-greeting.tsxtsx'use client';import {useSuspenseQuery } from '@tanstack/react-query';import {useTRPC } from '~/trpc/client';export functionClientGreeting () {consttrpc =useTRPC ();const {data } =useSuspenseQuery (trpc .hello .queryOptions ());return <div >{data .greeting }</div >;}
서버 컴포넌트에서 데이터 가져오기
서버 컴포넌트에서 데이터에 접근해야 하는 경우, 서버 콜러를 생성하여 직접 사용하는 것을 권장합니다. 이 방식은 쿼리 클라이언트와 분리되어 있으며 데이터를 캐시에 저장하지 않습니다. 즉, 서버 컴포넌트에서 데이터를 사용한 후 클라이언트에서 해당 데이터가 사용 가능할 것으로 기대할 수 없습니다. 이는 의도된 동작이며, Advanced Server Rendering 가이드에서 자세히 설명되어 있습니다.
trpc/server.tsxtsximport {headers } from 'next/headers';import {createTRPCContext } from './init';import {appRouter } from './routers/_app';// ...export constcaller =appRouter .createCaller (async () =>createTRPCContext ({headers : awaitheaders () }),);
trpc/server.tsxtsximport {headers } from 'next/headers';import {createTRPCContext } from './init';import {appRouter } from './routers/_app';// ...export constcaller =appRouter .createCaller (async () =>createTRPCContext ({headers : awaitheaders () }),);
app/page.tsxtsximport {caller } from '~/trpc/server';export default async functionHome () {constgreeting = awaitcaller .hello ();return <div >{greeting .greeting }</div >;}
app/page.tsxtsximport {caller } from '~/trpc/server';export default async functionHome () {constgreeting = awaitcaller .hello ();return <div >{greeting .greeting }</div >;}
서버와 클라이언트 컴포넌트 양쪽에서 데이터를 사용해야 하고, Advanced Server Rendering
가이드에 설명된 트레이드오프를 충분히 이해하는 경우에만, prefetch 대신 fetchQuery를 사용하여 서버에서 데이터를 처리하고 클라이언트로 하이드레이션할 수 있습니다:
app/page.tsxtsximport {getQueryClient ,HydrateClient ,trpc } from '~/trpc/server';import {ClientGreeting } from './client-greeting';export default async functionHome () {constqueryClient =getQueryClient ();constgreeting = awaitqueryClient .fetchQuery (trpc .hello .queryOptions ());// Do something with greeting on the serverreturn (<HydrateClient ><ClientGreeting /></HydrateClient >);}
app/page.tsxtsximport {getQueryClient ,HydrateClient ,trpc } from '~/trpc/server';import {ClientGreeting } from './client-greeting';export default async functionHome () {constqueryClient =getQueryClient ();constgreeting = awaitqueryClient .fetchQuery (trpc .hello .queryOptions ());// Do something with greeting on the serverreturn (<HydrateClient ><ClientGreeting /></HydrateClient >);}
다음 단계
- tRPC 기반 서버 액션을 정의하는 Server Actions 학습하기
- 클라이언트 컴포넌트에서 queries 및 mutations 학습하기
- 더 고급 서버 사이드 패턴을 위한 server-side calls 탐색하기
- 구독을 포함한 전체 App Router 예제를 확인하려면 SSE Chat example 살펴보기