Next.js App Router와 tRPC 연동 설정
이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →
다양한 서버 렌더링 유형과 피해야 할 함정을 이해하려면 TanStack React Query의 고급 서버 렌더링 문서를 읽어보시길 권장합니다.
추천 파일 구조
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 생성에 대한 자세한 내용은 퀵스타트 가이드와 백엔드 사용 문서를 참조하세요.
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 어댑터를 사용합니다. 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 어댑터(fetchRequestHandler 통해)를 사용합니다. 이는 App Router 라우트 핸들러가 웹 표준 Request 및 Response 객체를 기반으로 하기 때문입니다.
4. Query Client 팩토리 생성
trpc/query-client.ts 공유 파일을 생성하고 QueryClient 인스턴스를 생성하는 함수를 내보내세요.
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 환경에서는 클라이언트에서 즉시 재요청(refetch)하는 것을 방지하기 위해 일반적으로 기본 staleTime을 0보다 큰 값으로 설정합니다. -
shouldDehydrateQuery: 쿼리 dehydrate 여부를 결정하는 함수입니다. RSC 전송 프로토콜은 네트워크를 통해 프로미스 하이드레이션을 지원하므로,defaultShouldDehydrateQuery함수를 확장하여 아직 처리 중인(pending) 쿼리도 포함시킵니다. 이렇게 하면 상위 트리의 서버 컴포넌트에서 프리페칭을 시작한 후, 하위 트리의 클라이언트 컴포넌트에서 해당 프로미스를 소비할 수 있습니다. -
serializeData및deserializeData(선택 사항): 이전 단계에서 데이터 변환기를 설정했다면, 서버-클라이언트 경계를 넘어 Query Client를 하이드레이션할 때 데이터가 올바르게 직렬화되도록 이 옵션을 설정하세요.
5. 클라이언트 컴포넌트용 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. 서버 컴포넌트용 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 요청 실행하기
이제 모든 설정이 완료되었습니다! 서버 컴포넌트에서 쿼리를 프리페치하고 클라이언트 컴포넌트에서 사용할 수 있습니다.
서버 컴포넌트에서 프리페칭하기
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 >);}
클라이언트 컴포넌트에서 데이터 사용하기
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)와 에러 바운더리(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 >;}
서버 컴포넌트에서 데이터 가져오기
서버 컴포넌트에서 데이터에 접근해야 하는 경우 서버 호출자를 생성해 직접 사용하는 것을 권장합니다. 이 방법은 쿼리 클라이언트와 분리되어 있으며 데이터를 캐시에 저장하지 않습니다. 즉, 서버 컴포넌트에서 데이터를 사용하더라도 클라이언트에서 해당 데이터를 사용할 수 있다고 기대할 수 없습니다. 이는 의도된 동작이며 고급 서버 렌더링 가이드에서 자세히 설명합니다.
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 >;}
정말로 서버와 클라이언트 컴포넌트 모두에서 데이터를 사용해야 하고 고급 서버 렌더링 가이드에 설명된 트레이드오프를 이해한다면, 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 >);}