· trpc / nextjs / typescript
Hướng dẫn cài đặt tRPC với Next.js App Router (2026)
tRPC v11 hoạt động với Next.js 15 App Router. Bao gồm fetchRequestHandler, SSR prefetch, QueryClient pattern và năm lỗi phổ biến khi bắt đầu.
Bởi Ethan
2.176 từ · 11 phút đọc
tRPC v11 chạy được với Next.js 15 App Router, nhưng cách cài đặt khác hoàn toàn so với Pages Router đến mức tài liệu chính thức bỏ sót khá nhiều chỗ. Hướng dẫn này đi từ đầu đến cuối: từ bước cài package đến khi prefetch pattern chạy ổn định — phiên bản package cụ thể, từng file, và những điểm dễ sai khiến bạn mất cả buổi chiều.
Tóm lại: App Router dùng fetchRequestHandler từ @trpc/server/adapters/fetch, không phải @trpc/next. Phía client dùng @trpc/tanstack-react-query, không phải @trpc/react-query. Nhầm hai điểm này là không có gì chạy được.
Bài này dành cho ai
Developer đã quen với REST hoặc OpenAPI và đang cân nhắc xem tRPC có giúp đơn giản hóa stack Next.js của mình không. Bài này giả định bạn dùng TypeScript strict mode và đã biết sơ qua React Query. Nếu chưa từng dùng React Query, hãy đọc hướng dẫn nhanh của TanStack Query trước — tầng client của tRPC được xây trên nền đó.
Nếu bạn vẫn đang cân nhắc giữa tRPC và GraphQL trước khi bắt đầu, hãy xem so sánh tRPC vs GraphQL trước.
Yêu cầu trước khi bắt đầu
- Node.js 18 trở lên
- TypeScript 5.7+ với
"strict": truetrongtsconfig.json— tRPC sẽ mất khả năng suy luận kiểu nếu thiếu - Dự án Next.js 15 đã bật App Router — nếu bạn vẫn đang dùng Pages Router, hướng dẫn chuyển đổi Pages Router sang App Router có toàn bộ quy trình
- pnpm, npm, hoặc bun
Bước 1: Cài đặt package
npm install @trpc/[email protected] @trpc/[email protected] @trpc/[email protected] @tanstack/[email protected] zod server-only client-only
Tên package quan trọng. @trpc/tanstack-react-query là integration cho App Router — nhắm vào React Query v5 và cung cấp pattern hook useTRPC. @trpc/next dùng cho Pages Router. @trpc/react-query là package cũ dựa trên React Query v4. Cả hai cái đó đều không đúng cho App Router.
server-only và client-only là hai npm package nhỏ tự throw lỗi lúc build nếu file server bị import vào client hoặc ngược lại. Dùng chúng như lớp bảo vệ cứng cho các file tRPC phía server.
Lỗi hay gặp: cài @trpc/react-query thay vì @trpc/tanstack-react-query sẽ kéo theo kiểu React Query v4 không tương thích với v5, và thiếu method queryOptions() mà pattern App Router cần.
Bước 2: Tạo cấu trúc thư mục
src/
├── app/
│ ├── api/trpc/[trpc]/route.ts
│ ├── layout.tsx
│ └── page.tsx
└── trpc/
├── init.ts
├── query-client.ts
├── server.tsx
├── client.tsx
└── routers/
└── _app.ts
Giữ toàn bộ file tRPC trong thư mục trpc/. Việc tách server.tsx (helper chỉ chạy server) và client.tsx (client provider) là có chủ ý — để ngăn code server lọt vào client bundle.
Bước 3: Khởi tạo tRPC backend
// trpc/init.ts
import { initTRPC } from '@trpc/server';
import { cache } from 'react';
export const createTRPCContext = cache(async (opts: { headers: Headers }) => {
// Add auth here — e.g. read session from cookies
return { userId: 'user_123', headers: opts.headers };
});
const t = initTRPC
.context<Awaited<ReturnType<typeof createTRPCContext>>>()
.create();
export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const baseProcedure = t.procedure;
Hàm cache() của React loại bỏ các lần gọi trùng lặp trong cùng một request. Nếu thiếu nó, mỗi server component gọi createTRPCContext sẽ hit auth provider một lần riêng. Có nó, tất cả dùng chung một kết quả.
Bước 4: Định nghĩa router
// trpc/routers/_app.ts
import { z } from 'zod';
import { baseProcedure, createTRPCRouter } from '../init';
export const appRouter = createTRPCRouter({
hello: baseProcedure
.input(z.object({ text: z.string() }))
.query(({ input }) => ({
greeting: `hello ${input.text}`,
})),
});
export type AppRouter = typeof appRouter;
Type export AppRouter là thứ đảm bảo type safety end-to-end từ server xuống client. Mọi file client đều import type này — không bao giờ import runtime router object.
Bước 5: Gắn HTTP adapter
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '~/trpc/routers/_app';
import { createTRPCContext } from '~/trpc/init';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createTRPCContext({ headers: req.headers }),
});
export { handler as GET, handler as POST };
App Router dùng Web Fetch API chuẩn. fetchRequestHandler xử lý điều đó. Pages Router dùng createNextApiHandler từ @trpc/next, vốn dựa vào IncomingMessage của Node.js — cái đó không hoạt động ở đây. Export cả GET và POST; tRPC mặc định dùng GET cho query và POST cho mutation.
Lỗi hay gặp: nếu chỉ export POST, các query procedure sẽ bị lỗi vì mặc định chúng dùng GET. Bạn sẽ thấy 405 Method Not Allowed trong browser console.
Bước 6: Thiết lập QueryClient factory
// trpc/query-client.ts
import {
defaultShouldDehydrateQuery,
QueryClient,
} from '@tanstack/react-query';
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
},
});
}
Hai điểm quan trọng: staleTime: 30 * 1000 ngăn client refetch ngay dữ liệu vừa được prefetch từ server. Không có nó, mọi query đã SSR-prefetch đều bị refetch lại lúc mount — bạn trả chi phí round-trip hai lần. Phần mở rộng shouldDehydrateQuery bao gồm thêm trạng thái pending để streaming hydration hoạt động: server có thể khởi động query, gửi promise đang chờ xuống client, và client tiếp tục từ chỗ server bỏ dở.
Bước 7: Xây dựng helper phía server
// trpc/server.tsx
import 'server-only';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { cache } from 'react';
import { headers } from 'next/headers';
import { createTRPCContext } from './init';
import { makeQueryClient } from './query-client';
import { appRouter } from './routers/_app';
export const getQueryClient = cache(makeQueryClient);
export const trpc = createTRPCOptionsProxy({
ctx: async () => createTRPCContext({ headers: await headers() }),
router: appRouter,
queryClient: getQueryClient,
});
export const caller = appRouter.createCaller(
async () => createTRPCContext({ headers: await headers() }),
);
export function HydrateClient({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
);
}
Ba export phục vụ mục đích khác nhau:
trpclà proxy phía server để prefetch — nó nạp dữ liệu vào QueryClient trước khi React render client tree.callerbỏ qua hoàn toàn lớp HTTP và gọi procedure trực tiếp. Dùng khi bạn cần dữ liệu server không cần truyền xuống client (tạo metadata, static props, v.v.).HydrateClientbọc các client subtree và truyền trạng thái QueryClient đã prefetch qua hydration boundary của React.
import 'server-only' ở đầu file sẽ throw lỗi build nếu file này vô tình bị import từ client component. Đây chính là lớp bảo vệ bạn cần — nếu secret hay kết nối DB nằm trong file này, chúng không thể lọt ra ngoài.
Bước 8: Thiết lập client provider
// trpc/client.tsx
'use client';
import 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>();
let browserQueryClient: QueryClient | undefined;
function getQueryClient() {
if (typeof window === 'undefined') return makeQueryClient();
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
function getUrl() {
if (typeof window !== 'undefined') return '/api/trpc';
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}/api/trpc`;
return 'http://localhost:3000/api/trpc';
}
export function TRPCReactProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: getUrl() })],
}),
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{children}
</TRPCProvider>
</QueryClientProvider>
);
}
Hàm getQueryClient phân nhánh theo typeof window: trên server luôn tạo instance mới (tươi mỗi request), trên browser dùng lại singleton. Đây là pattern đúng — dùng singleton phía server sẽ khiến query cache rò rỉ giữa các request.
useTRPC là thứ client component import để lấy tRPC proxy an toàn về kiểu. Nó thay thế pattern trpc.x.useQuery() từ v10.
Bước 9: Gắn provider vào layout
// app/layout.tsx
import { TRPCReactProvider } from '~/trpc/client';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<TRPCReactProvider>{children}</TRPCReactProvider>
</body>
</html>
);
}
Một provider bọc toàn bộ app. Đó là tất cả những gì layout.tsx cần làm.
Bước 10: Prefetch trong Server Component
// app/page.tsx
import { getQueryClient, trpc, HydrateClient } from '~/trpc/server';
export default async function Home() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(trpc.hello.queryOptions({ text: 'world' }));
return (
<HydrateClient>
<Greeting />
</HydrateClient>
);
}
void prefetchQuery(...) kích hoạt query mà không chặn quá trình render RSC. Dữ liệu được stream xuống client qua cơ chế hydration của React khi sẵn sàng. Nếu bạn cần dữ liệu trước khi trang có thể render — không chấp nhận loading skeleton — dùng await queryClient.fetchQuery(...) thay thế. Cái này chặn TTFB cho đến khi query xong.
Bọc client subtree trong <HydrateClient>. Nếu không, dữ liệu đã prefetch không bao giờ đến được client QueryClient và client sẽ fetch lại từ đầu.
Bước 11: Dùng tRPC trong Client Component
// components/greeting.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '~/trpc/client';
export function Greeting() {
const trpc = useTRPC();
const { data } = useQuery(trpc.hello.queryOptions({ text: 'world' }));
if (!data) return <div>Loading…</div>;
return <div>{data.greeting}</div>;
}
Pattern v11 là useQuery(trpc.x.queryOptions(...)) — không phải trpc.x.useQuery(...) như v10. Method queryOptions() trả về object options chuẩn của TanStack Query, nghĩa là bạn có thể dùng trực tiếp mọi hook của React Query: useQuery, useSuspenseQuery, useInfiniteQuery, useMutation.
Nếu RSC cha đã prefetch query này, client component nhận dữ liệu ngay từ hydration boundary — không có hiện tượng loading flicker.
// Biến thể Suspense — không cần loading state khi đã prefetch
import { useSuspenseQuery } from '@tanstack/react-query';
export function GreetingSuspense() {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.hello.queryOptions({ text: 'world' }));
return <div>{data.greeting}</div>;
}
Những cạm bẫy thường gặp
1. Sai package. @trpc/next bao bọc các quy ước Pages Router (IncomingMessage của Node.js, createNextApiHandler). Nó không hoạt động trong App Router. Dùng fetchRequestHandler từ @trpc/server/adapters/fetch.
2. QueryClient singleton trên server. Factory makeQueryClient() phải được gọi một lần mỗi request trên server, không chia sẻ giữa các request. Dùng chung singleton sẽ khiến query cache của user này rò rỉ vào response của user khác. Wrapper cache() trong server.tsx đảm bảo mỗi request có một instance riêng.
3. Thiếu staleTime. staleTime mặc định của React Query là 0. Nếu không đặt tối thiểu 30 giây trong QueryClient defaults, client coi dữ liệu đã SSR-prefetch là cũ ngay lúc mount và refetch lại. Chi phí round-trip của SSR prefetch trở nên vô nghĩa.
4. shouldDehydrateQuery thiếu pending. Filter dehydration mặc định chỉ serialize các query đã settled. Nếu bạn dùng void prefetch() (non-blocking), query có thể vẫn đang pending khi React serialize hydration boundary. Thiếu extension pending, query đó bị bỏ và client phải bắt đầu lại từ đầu.
5. Vị trí transformer thay đổi trong v11. Trong tRPC v10, bạn truyền superjson làm transformer toàn cục trên createTRPCClient. Trong v11, transformer được cấu hình per link: httpBatchLink({ url: '/api/trpc', transformer: superjson }). Nếu bạn đang migrate từ v10 và kiểu Date đang ra dạng string, đây là nguyên nhân.
Gọi trực tiếp phía server (không qua HTTP)
Khi dữ liệu không cần đến client — metadata trang, nội dung hoàn toàn tĩnh — bỏ qua QueryClient và gọi router trực tiếp:
import { caller } from '~/trpc/server';
export async function generateMetadata() {
const result = await caller.hello({ text: 'world' });
return { title: result.greeting };
}
caller bỏ qua hoàn toàn lớp HTTP. Không có network round-trip, không có overhead serialization, và không cần <HydrateClient>. Kết quả ở lại phía server.
Kết luận
Đó là toàn bộ stack tRPC v11 + App Router. Setup này trải rộng hơn so với v10 — sáu file thay vì hai — nhưng mỗi file có nhiệm vụ rõ ràng: init.ts phụ trách backend, server.tsx phụ trách SSR, client.tsx phụ trách ranh giới client. Khi cấu trúc đã ổn định, thêm procedure chỉ là thay đổi một file.
Về deployment, Vercel xử lý stack này không cần cấu hình thêm — pattern fetchRequestHandler tương thích với cả serverless lẫn edge runtime. Cloudflare Workers cũng hoạt động qua cùng Fetch API adapter; điểm duy nhất cần chú ý là đảm bảo server-only tương thích với cấu hình edge runtime của bạn.
Vì tầng client của tRPC chạy trên TanStack Query, nếu bạn vẫn đang cân nhắc giữa TanStack Query và SWR cho phần còn lại của ứng dụng, hãy xem so sánh TanStack Query vs SWR.