BlogBlog · trpc

tRPC v11 với Next.js App Router: API type-safe end-to-end không cần codegen

Dựng tRPC v11 trong Next.js App Router: router có context và protectedProcedure, route handler fetch adapter, TanStack React Query kiểu queryOptions, prefetch từ Server Component, xử lý TRPCError, test bằng caller và khi nào không nên dùng.

Part of Tech Radar series
March 29, 2026
25 min read
By Leo Pham
trpctypescriptnextjsapireact-query
Share this post:

Tình huống quen thuộc trong một dự án Next.js: backend đổi field dueDate thành dueAt, type phía frontend vẫn là interface viết tay từ ba tháng trước, TypeScript không kêu tiếng nào, và màn hình hóa đơn hiện "Invalid Date" trên production. Với REST bạn vá chuyện này bằng OpenAPI + codegen, với GraphQL bằng schema + codegen. Cả hai cách đều thêm một bước build và một nguồn sự thật thứ hai.

tRPC bỏ hẳn bước đó. Router viết bằng TypeScript chính là contract. Client import type của router, nên đổi tên field ở server là compile error ở client ngay trong editor. Bài này là cách mình dựng tRPC v11 trong Next.js App Router: server, route handler, client với integration TanStack React Query mới, prefetch từ Server Component, xử lý lỗi, test, và những trường hợp mình không chọn tRPC.

Code trong bài đã đối chiếu với tài liệu chính thức của tRPC v11 (bản 11.23.x trên npm tại thời điểm cập nhật, tháng 11/2026), Next.js 15/16, TanStack Query v5 và zod 4.

TL;DR

  • tRPC dành cho TypeScript monorepo nơi một team sở hữu cả client lẫn server. Nó không phải công cụ để làm public API.
  • v11 (công bố 21/03/2025) đi kèm integration @trpc/tanstack-react-query mới: bạn dùng thẳng useQuery(trpc.x.queryOptions()) của TanStack, không còn wrapper hook.
  • Next.js App Router: một route handler app/api/trpc/[trpc]/route.ts dùng fetch adapter, cộng createTRPCOptionsProxy để prefetch trong Server Component.
  • transformer: superjson phải đặt ở initTRPC và trên từng link. Dehydrate/hydrate cũng phải dùng superjson.
  • Lỗi: ném TRPCError với code chuẩn, che message của lỗi 500 ở production, map code sang thông báo ở client.

tRPC v11: cái gì thực sự thay đổi

Những mốc có thể kiểm chứng:

  • 17/02/2025: tRPC giới thiệu integration TanStack React Query mới (package @trpc/tanstack-react-query). Thay vì trpc.invoice.list.useQuery() bọc quanh React Query, procedure giờ sinh ra queryOptions/mutationOptions để bạn đưa thẳng vào useQuery, useSuspenseQuery, useMutation của TanStack.
  • 21/03/2025: tRPC v11 chính thức. Các điểm chính theo bài công bố: hỗ trợ TanStack Query v5, input/output không phải JSON (FormData, Blob, File), httpBatchStreamLink để stream response, subscription qua Server-Sent Events, cú pháp router rút gọn, helper prefetch cho React Server Components, và bỏ chế độ .interop() của v9.
  • Hiện tại: các package @trpc/* đang ở 11.23.x, peer dependency yêu cầu TypeScript >=5.7.2. @trpc/tanstack-react-query yêu cầu @tanstack/react-query v5 (^5.80.3).

Hai thay đổi hay làm người nâng cấp từ v10 vấp: transformer chuyển từ createTRPCClient sang từng link (đặt sai chỗ sẽ gặp lỗi "The transformer property has moved to httpLink/httpBatchLink/wsLink"), và integration mới không có hook kiểu classic (trpc.x.useQuery(), trpc.useUtils()). Integration classic @trpc/react-query vẫn còn, nhưng với dự án mới mình chọn integration mới. Lý do: mọi thứ của TanStack Query (useSuspenseQuery, queryClient.prefetchQuery, invalidateQueries) dùng được đúng như tài liệu TanStack, không phải học thêm một lớp API riêng của tRPC.

tRPC là gì, và không là gì

tRPC là một lớp RPC mỏng trên HTTP. Server định nghĩa procedure (query, mutation, subscription) bằng TypeScript. Client gọi procedure như gọi hàm, và type của input/output được suy ra trực tiếp từ typeof appRouter. Không có file schema, không có bước generate.

Điều kiện tiên quyết là client phải import được type đó. Thực tế nghĩa là client và server nằm cùng repo (hoặc monorepo), cùng là TypeScript, và được deploy gần như đồng thời. tRPC không cho bạn:

  • Một contract độc lập ngôn ngữ để đưa cho partner hay team mobile native (Swift/Kotlin).
  • URL ổn định, có version, hợp với tài liệu công khai và cache CDN.
  • Một graph cho client tự chọn field như GraphQL.
Tiêu chí tRPC REST + OpenAPI GraphQL
Nguồn sự thật của contract Type của router TypeScript File OpenAPI (hoặc sinh từ code) Schema SDL
Codegen Không cần Cần cho client có type Cần cho client có type
Client không phải TypeScript Không thực tế Tốt: generator cho mọi ngôn ngữ Tốt
Public API cho bên thứ ba Không phù hợp Tốt nhất Được
HTTP/CDN caching Hạn chế (query qua GET, URL khó đọc) Tự nhiên Cần persisted queries
Versioning Không có, client và server phải khớp /v1, Sunset header @deprecated theo field
Chi phí khởi động Thấp nhất Trung bình Cao (DataLoader, cost limit)
Hợp nhất với Next.js full-stack, admin nội bộ Public API, nhiều ngôn ngữ Nhiều client, BFF

So sánh sâu hơn giữa REST và GraphQL trên cùng một bài toán có ở bài GraphQL vs REST.

Server: initTRPC, context, zod và protectedProcedure

Cấu trúc file mình dùng (Next.js với thư mục src/, alias @/*):

src/
  app/api/trpc/[trpc]/route.ts   # route handler
  trpc/init.ts                   # initTRPC, procedure, middleware (không import gì của Next)
  trpc/context.ts                # createTRPCContext: đọc cookie, session
  trpc/routers/_app.ts           # ghép router
  trpc/routers/invoice.ts
  trpc/query-client.ts           # QueryClient dùng chung server/client
  trpc/client.tsx                # 'use client': Provider, useTRPC
  trpc/server.tsx                # 'server-only': prefetch, HydrateClient, caller
  server/repositories/           # truy cập DB, chỉ chạy trên server

Mình tách init.ts khỏi context.ts có chủ đích: init.ts không import next/headers, nên test với Vitest import router thoải mái mà không cần môi trường Next.

// src/trpc/init.ts
import { initTRPC, TRPCError } from '@trpc/server'
import superjson from 'superjson'
import { ZodError, z } from 'zod'
import type { Repositories, SessionUser } from '@/server/repositories/types'

export type Context = { user: SessionUser | null; repos: Repositories }

const t = initTRPC.context<Context>().create({
  transformer: superjson, // Date, Map, BigInt đi qua dây mà vẫn giữ kiểu
  errorFormatter({ shape, error }) {
    const hideMessage = error.code === 'INTERNAL_SERVER_ERROR' && process.env.NODE_ENV === 'production'
    return {
      ...shape,
      message: hideMessage ? 'Internal server error' : shape.message,
      data: {
        ...shape.data,
        zodError: error.code === 'BAD_REQUEST' && error.cause instanceof ZodError ? z.flattenError(error.cause) : null,
      },
    }
  },
})

export const createTRPCRouter = t.router
export const createCallerFactory = t.createCallerFactory
export const publicProcedure = t.procedure

const timing = t.middleware(async ({ path, type, next }) => {
  const start = performance.now()
  const result = await next()
  if (!result.ok) {
    console.error({ path, type, ms: Math.round(performance.now() - start), code: result.error.code })
  }
  return result
})

export const protectedProcedure = t.procedure.use(timing).use(async ({ ctx, next }) => {
  if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Cần đăng nhập' })
  return next({ ctx: { user: ctx.user } }) // từ đây ctx.user có type non-null
})

Dòng hideMessage xử lý một chỗ rò rỉ ít người để ý. Khi procedure ném một Error thường (ví dụ lỗi driver DB), tRPC bọc nó thành INTERNAL_SERVER_ERROR và giữ nguyên message gốc. Stack trace chỉ có ở môi trường dev, nhưng message thì đi tới client ở mọi môi trường. Message của lỗi DB thường chứa tên bảng, tên constraint, có khi cả giá trị dữ liệu.

// src/trpc/context.ts
import { cookies } from 'next/headers'
import { cache } from 'react'
import { repositories } from '@/server/repositories'
import { getSessionUser } from '@/server/repositories/session'
import type { Context } from './init'

// cache(): trong cùng một request RSC, mọi lần gọi dùng chung một context
export const createTRPCContext = cache(async (): Promise<Context> => {
  const sid = (await cookies()).get('sid')?.value
  return { user: sid ? await getSessionUser(sid) : null, repos: repositories }
})

Router hóa đơn, với zod validate input:

// src/trpc/routers/invoice.ts
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { createTRPCRouter, protectedProcedure } from '../init'

const InvoiceStatus = z.enum(['draft', 'unpaid', 'paid', 'void'])

export const invoiceRouter = createTRPCRouter({
  list: protectedProcedure
    .input(z.object({
      status: InvoiceStatus.optional(),
      cursor: z.uuid().nullish(),
      limit: z.number().int().min(1).max(100).default(20),
    }))
    .query(async ({ ctx, input }) => {
      const rows = await ctx.repos.invoices.list({
        ownerId: ctx.user.id, status: input.status, cursor: input.cursor ?? null, limit: input.limit + 1,
      })
      const items = rows.slice(0, input.limit)
      return { items, nextCursor: rows.length > input.limit ? items.at(-1)!.id : null }
    }),

  byId: protectedProcedure
    .input(z.object({ id: z.uuid() }))
    .query(async ({ ctx, input }) => {
      const invoice = await ctx.repos.invoices.findById(input.id)
      // Không thuộc user này thì cũng trả NOT_FOUND, không để lộ ID tồn tại
      if (!invoice || invoice.ownerId !== ctx.user.id) throw new TRPCError({ code: 'NOT_FOUND', message: 'Không tìm thấy hóa đơn' })
      return invoice
    }),

  markPaid: protectedProcedure
    .input(z.object({ id: z.uuid(), paidAt: z.date() })) // z.date() chạy được nhờ superjson
    .mutation(async ({ ctx, input }) => {
      const invoice = await ctx.repos.invoices.findById(input.id)
      if (!invoice || invoice.ownerId !== ctx.user.id) throw new TRPCError({ code: 'NOT_FOUND' })
      if (invoice.status !== 'unpaid') {
        throw new TRPCError({ code: 'CONFLICT', message: `Hóa đơn đang ở trạng thái "${invoice.status}"` })
      }
      return ctx.repos.invoices.update(input.id, { status: 'paid', paidAt: input.paidAt })
    }),
})
// src/trpc/routers/_app.ts
import { createTRPCRouter, publicProcedure } from '../init'
import { invoiceRouter } from './invoice'
import { projectRouter } from './project'

export const appRouter = createTRPCRouter({
  invoice: invoiceRouter,
  project: projectRouter,
  health: { ping: publicProcedure.query(() => 'pong') }, // cú pháp rút gọn của v11: object lồng nhau
})

export type AppRouter = typeof appRouter

Nếu muốn ghép phẳng nhiều router (không namespace) thì dùng t.mergeRouters. Hàm này ném lỗi khi hai router trùng tên procedure, và chỉ ghép được router tạo từ cùng một instance t. Router lớn ít dùng có thể bọc bằng lazy(() => import('./reports.js')) của @trpc/server để giảm cold start. Lưu ý là cách này chỉ giảm thời gian load lúc runtime, không giảm thời gian type-check.

Next.js App Router: route handler với fetch adapter

// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { createTRPCContext } from '@/trpc/context'
import { appRouter } from '@/trpc/routers/_app'

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => createTRPCContext(),
    onError({ path, error }) {
      if (error.code === 'INTERNAL_SERVER_ERROR') {
        console.error(`tRPC /${path ?? '?'} failed`, error.cause ?? error) // log đầy đủ ở server
      }
    },
  })

export { handler as GET, handler as POST }

Query đi bằng GET, mutation đi bằng POST. [trpc] là dynamic segment chứa tên procedure, hoặc nhiều tên cách nhau bằng dấu phẩy khi batch. createTRPCContext đọc cookies() nên route này luôn dynamic, và đó là điều bạn muốn với dữ liệu theo user.

Client: TanStack React Query, links và superjson

// src/trpc/query-client.ts
import { defaultShouldDehydrateQuery, QueryClient } from '@tanstack/react-query'
import { TRPCClientError } from '@trpc/client'
import superjson from 'superjson'

export function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 30 * 1000, // > 0 để client không refetch ngay sau khi hydrate
        retry: (failureCount, error) => {
          const status = error instanceof TRPCClientError ? (error.data as { httpStatus?: number } | undefined)?.httpStatus : undefined
          if (status !== undefined && status < 500) return false // 4xx: retry vô ích
          return failureCount < 2
        },
      },
      dehydrate: {
        serializeData: superjson.serialize,
        // dehydrate cả query đang pending để promise được stream xuống client
        shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
      },
      hydrate: { deserializeData: superjson.deserialize },
    },
  })
}
// src/trpc/client.tsx
'use client'

import type { QueryClient } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { createTRPCClient, httpBatchLink, httpBatchStreamLink, loggerLink, splitLink } from '@trpc/client'
import { createTRPCContext } from '@trpc/tanstack-react-query'
import { useState } from 'react'
import superjson from 'superjson'
import { makeQueryClient } from './query-client'
import type { AppRouter } from './routers/_app' // import TYPE, không kéo code server vào bundle

export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>()

let browserQueryClient: QueryClient | undefined
function getQueryClient() {
  if (typeof window === 'undefined') return makeQueryClient() // server: mỗi request một client
  return (browserQueryClient ??= makeQueryClient()) // browser: một client duy nhất
}

function getUrl() {
  if (typeof window !== 'undefined') return '/api/trpc'
  const base = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000'
  return `${base}/api/trpc`
}

export function TRPCReactProvider({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient()
  const [trpcClient] = useState(() =>
    createTRPCClient<AppRouter>({
      links: [
        loggerLink({
          enabled: (op) => process.env.NODE_ENV === 'development' || (op.direction === 'down' && op.result instanceof Error),
        }),
        splitLink({
          condition: (op) => op.type === 'mutation',
          // mutation có thể cần set cookie/header: stream link không làm được việc đó
          true: httpBatchLink({ url: getUrl(), transformer: superjson }),
          // query: mỗi kết quả trong batch về ngay khi xong, không chờ query chậm nhất
          false: httpBatchStreamLink({ url: getUrl(), transformer: superjson, maxURLLength: 2083 }),
        }),
      ],
    }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
        {children}
      </TRPCProvider>
    </QueryClientProvider>
  )
}

Bọc <TRPCReactProvider> quanh {children} trong src/app/layout.tsx. Vài điều đáng giải thích ở đoạn trên:

  • Vì sao dùng splitLink? httpBatchStreamLink gửi header response ngay khi stream bắt đầu, nên procedure không thể set cookie hay header sau đó (tài liệu tRPC ghi rõ giới hạn này). Mutation như đăng nhập hay đổi workspace thường cần set cookie, nên đi qua httpBatchLink.
  • maxURLLength: query batch đi bằng GET, input encode vào URL. Batch 15 query có input lớn dễ vượt giới hạn của proxy, gây lỗi 414 hoặc 413. Đặt maxURLLength thì link tự tách batch.
  • superjson ở bốn chỗ: initTRPC, từng link, serializeData và deserializeData. Thiếu một chỗ là Date thành string ở đúng chỗ đó, mà TypeScript vẫn báo là Date.

Component dùng như TanStack Query bình thường:

// src/app/(dashboard)/invoices/invoice-list.tsx
'use client'

import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useTRPC } from '@/trpc/client'
import { toUserMessage } from '@/trpc/errors'

export function InvoiceList({ status }: { status: 'unpaid' | 'paid' }) {
  const trpc = useTRPC()
  const queryClient = useQueryClient()
  const { data } = useSuspenseQuery(trpc.invoice.list.queryOptions({ status }))

  const markPaid = useMutation(
    trpc.invoice.markPaid.mutationOptions({
      onSuccess: () => queryClient.invalidateQueries(trpc.invoice.pathFilter()), // mọi query trong invoice.*
    }),
  )

  return (
    <section>
      <ul>
        {data.items.map((inv) => (
          <li key={inv.id}>
            {inv.number}, hạn {inv.dueDate.toLocaleDateString('vi-VN')}
            <button disabled={markPaid.isPending} onClick={() => markPaid.mutate({ id: inv.id, paidAt: new Date() })}>
              Đã thu tiền
            </button>
          </li>
        ))}
      </ul>
      {markPaid.error && <p role="alert">{toUserMessage(markPaid.error)}</p>}
    </section>
  )
}

inv.dueDate.toLocaleDateString chạy được vì superjson giữ Date là Date. Thử đổi dueDate thành dueAt trong repository: dòng này đỏ ngay trong editor. Đó chính là lời hứa của tRPC.

Muốn UI phản hồi ngay khi bấm nút thay vì chờ server, bạn làm optimistic update bằng đúng API của TanStack. Điểm khác là query key lấy từ trpc.invoice.list.queryKey(input), nên updater được type-check theo output thật của procedure:

const listKey = trpc.invoice.list.queryKey({ status })

const markPaidOptimistic = useMutation(
  trpc.invoice.markPaid.mutationOptions({
    onMutate: async ({ id }) => {
      await queryClient.cancelQueries({ queryKey: listKey })
      const previous = queryClient.getQueryData(listKey)
      queryClient.setQueryData(listKey, (old) =>
        old ? { ...old, items: old.items.filter((inv) => inv.id !== id) } : old, // biến mất khỏi danh sách "chưa thu"
      )
      return { previous }
    },
    onError: (_err, _input, context) => queryClient.setQueryData(listKey, context?.previous), // rollback
    onSettled: () => queryClient.invalidateQueries(trpc.invoice.pathFilter()),
  }),
)

Gõ sai old.item thay vì old.items là compile error. Với REST viết tay, chỗ này thường là any.

Gọi procedure từ Server Component: prefetch, hydrate và caller

// src/trpc/server.tsx
import 'server-only' // import file này từ client component sẽ làm build fail

import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { createTRPCOptionsProxy, type TRPCQueryOptions } from '@trpc/tanstack-react-query'
import { cache } from 'react'
import { createTRPCContext } from './context'
import { makeQueryClient } from './query-client'
import { appRouter } from './routers/_app'

// Cùng một request RSC → cùng một QueryClient
export const getQueryClient = cache(makeQueryClient)

export const trpc = createTRPCOptionsProxy({
  ctx: createTRPCContext,
  router: appRouter,
  queryClient: getQueryClient,
})

// Gọi thẳng procedure, không qua HTTP, không vào cache của React Query
export const caller = appRouter.createCaller(createTRPCContext)

export function HydrateClient({ children }: { children: React.ReactNode }) {
  return <HydrationBoundary state={dehydrate(getQueryClient())}>{children}</HydrationBoundary>
}

export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>(queryOptions: T) {
  const queryClient = getQueryClient()
  if (queryOptions.queryKey[1]?.type === 'infinite') void queryClient.prefetchInfiniteQuery(queryOptions as any)
  else void queryClient.prefetchQuery(queryOptions)
}

Trang danh sách prefetch mà không await. Promise được dehydrate ở trạng thái pending, stream xuống client, và useSuspenseQuery nhận đúng promise đó thay vì gọi lại API:

// src/app/(dashboard)/invoices/page.tsx
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { HydrateClient, prefetch, trpc } from '@/trpc/server'
import { InvoiceList } from './invoice-list'

export default function InvoicesPage() {
  prefetch(trpc.invoice.list.queryOptions({ status: 'unpaid' })) // input phải giống hệt bên client

  return (
    <HydrateClient>
      <h1>Hóa đơn chưa thu</h1>
      <ErrorBoundary fallback={<p>Không tải được danh sách hóa đơn.</p>}>
        <Suspense fallback={<p>Đang tải…</p>}>
          <InvoiceList status="unpaid" />
        </Suspense>
      </ErrorBoundary>
    </HydrateClient>
  )
}

Trang chi tiết cần dữ liệu ngay trên server, để trả 404 hoặc dựng metadata. Lúc này dùng caller:

// src/app/(dashboard)/invoices/[id]/page.tsx
import { TRPCError } from '@trpc/server'
import { notFound } from 'next/navigation'
import { caller } from '@/trpc/server'

export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params // Next.js 15+: params là Promise
  const invoice = await caller.invoice.byId({ id }).catch((err: unknown) => {
    if (err instanceof TRPCError && (err.code === 'NOT_FOUND' || err.code === 'BAD_REQUEST')) notFound()
    throw err
  })
  return (
    <article>
      <h1>Hóa đơn {invoice.number}</h1>
      <p>Tổng: {invoice.totalAmount.toLocaleString('vi-VN')} đ</p>
    </article>
  )
}

Quy tắc chọn: dùng prefetch khi dữ liệu sẽ được client component dùng và có thể bị invalidate sau mutation. Dùng caller khi chỉ server cần dữ liệu (redirect, notFound, metadata, render tĩnh). Kết quả của caller không nằm trong cache của React Query, nên client component cần cùng dữ liệu đó sẽ fetch lại. Nếu cần cả hai, dùng await getQueryClient().fetchQuery(trpc.invoice.byId.queryOptions({ id })): bạn vừa có dữ liệu trên server, vừa hydrate được xuống client.

Hai cái bẫy với prefetch. Thứ nhất, input ở server và client phải giống hệt nhau, vì { status: 'unpaid' } và { status: 'unpaid', limit: 20 } là hai query key khác nhau. Lệch input là client fetch lại từ đầu và bạn mất toàn bộ lợi ích của prefetch. Thứ hai, staleTime mặc định của TanStack Query là 0, tức là dữ liệu vừa hydrate bị coi là cũ và refetch ngay. Vì vậy makeQueryClient đặt 30 giây.

Xử lý lỗi: TRPCError code và mapping ở client

Code HTTP Mình dùng khi
BAD_REQUEST 400 Input sai (zod tự sinh khi validate thất bại)
UNAUTHORIZED 401 Chưa đăng nhập hoặc session hết hạn
FORBIDDEN 403 Đăng nhập rồi nhưng không đủ quyền
NOT_FOUND 404 Không có, hoặc thuộc người khác
CONFLICT 409 Trạng thái không cho phép (hóa đơn đã thu)
PRECONDITION_FAILED 412 Version lệch khi cập nhật lạc quan
TOO_MANY_REQUESTS 429 Rate limit
INTERNAL_SERVER_ERROR 500 Mọi lỗi không phải TRPCError (tRPC tự bọc)

Ở client, mình gom mapping vào một chỗ thay vì if (error.message.includes(...)) rải khắp component:

// src/trpc/errors.ts
import { TRPCClientError } from '@trpc/client'
import type { AppRouter } from './routers/_app'

export function isTRPCClientError(cause: unknown): cause is TRPCClientError<AppRouter> {
  return cause instanceof TRPCClientError
}

export function toUserMessage(error: unknown): string {
  if (!isTRPCClientError(error)) return 'Không kết nối được tới server, thử lại sau.'
  switch (error.data?.code) {
    case 'UNAUTHORIZED':
      return 'Phiên đăng nhập đã hết, vui lòng đăng nhập lại.'
    case 'FORBIDDEN':
      return 'Bạn không có quyền thực hiện thao tác này.'
    case 'NOT_FOUND':
      return 'Dữ liệu không còn tồn tại.'
    case 'CONFLICT':
      return error.message // message nghiệp vụ do server viết sẵn cho người dùng
    case 'BAD_REQUEST': {
      const fieldErrors = Object.values(error.data.zodError?.fieldErrors ?? {}).flat()
      return fieldErrors[0] ?? 'Dữ liệu không hợp lệ.'
    }
    default:
      return 'Có lỗi xảy ra, thử lại sau.'
  }
}

error.data.zodError có type đầy đủ, vì tRPC suy ra shape lỗi từ errorFormatter. Với form, zodError.fieldErrors.email cho bạn lỗi theo từng field mà không phải parse chuỗi. Với UNAUTHORIZED toàn cục, thêm queryCache: new QueryCache({ onError }) vào makeQueryClient để redirect về trang đăng nhập. Nhớ kiểm tra typeof window !== 'undefined', vì hàm này cũng chạy trên server.

Test procedure bằng caller

Không cần dựng HTTP server hay mock fetch. Caller chạy toàn bộ middleware và zod validation như request thật, chỉ bỏ qua tầng HTTP:

// src/trpc/routers/invoice.test.ts
import { describe, expect, it } from 'vitest'
import { createFakeRepositories } from '@/server/repositories/fake'
import { createCallerFactory } from '../init'
import { appRouter } from './_app'

const createCaller = createCallerFactory(appRouter)
const alice = { id: '6f1c1b8e-2a4d-4c55-9a0e-1d2f3a4b5c6d', email: 'alice@acme.vn' }
const INVOICE_ID = '0e7d9a52-1c4b-4d8e-b6a1-5e2f0c9d8b34'

describe('invoice router', () => {
  it('chặn người chưa đăng nhập', async () => {
    const caller = createCaller({ user: null, repos: createFakeRepositories() })
    await expect(caller.invoice.list({})).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
  })

  it('không cho đánh dấu đã thu hai lần', async () => {
    const repos = createFakeRepositories({
      invoices: [{ id: INVOICE_ID, ownerId: alice.id, number: 'INV-0042', status: 'paid', totalAmount: 1_500_000, dueDate: new Date('2026-11-30') }],
    })
    const caller = createCaller({ user: alice, repos })
    await expect(caller.invoice.markPaid({ id: INVOICE_ID, paidAt: new Date() })).rejects.toMatchObject({ code: 'CONFLICT' })
  })

  it('không lộ hóa đơn của người khác', async () => {
    const repos = createFakeRepositories({
      invoices: [{ id: INVOICE_ID, ownerId: 'someone-else', number: 'INV-0043', status: 'unpaid', totalAmount: 900_000, dueDate: new Date('2026-12-15') }],
    })
    const caller = createCaller({ user: alice, repos })
    await expect(caller.invoice.byId({ id: INVOICE_ID })).rejects.toMatchObject({ code: 'NOT_FOUND' })
  })

  it('validate input bằng zod', async () => {
    const caller = createCaller({ user: alice, repos: createFakeRepositories() })
    await expect(caller.invoice.byId({ id: 'khong-phai-uuid' })).rejects.toMatchObject({ code: 'BAD_REQUEST' })
  })
})

Context là object thường, nên bạn truyền fake repository vào là xong. Đây là lý do mình đặt repos trong context thay vì import thẳng Prisma client trong router. Tài liệu tRPC cũng nhắc một điều: đừng dùng createCaller để gọi procedure từ bên trong procedure khác, vì nó chạy lại context, middleware và validation. Logic dùng chung nên tách ra hàm thường.

Migrate từ v10 hoặc từ integration classic

Nếu đang chạy v10, thứ tự an toàn là: nâng tất cả package @trpc/* lên ^11 cùng lúc (phiên bản các package phải khớp nhau), nâng TanStack Query lên v5, chuyển transformer xuống từng link, rồi để TypeScript chỉ ra phần còn lại. Với TanStack Query v5, việc tốn công nhất thường chỉ là đổi isLoading thành isPending. Middleware nào đọc rawInput thì đổi sang getRawInput(), vì input giờ được đọc lười (lazy).

Chuyển từ integration classic sang integration mới có codemod chính thức:

git stash            # codemod từ chối chạy khi working tree còn thay đổi chưa commit
npx @trpc/upgrade    # chọn "Migrate Hooks to xxxOptions API" và "Migrate context provider setup"
npx tsc --noEmit     # sửa nốt những chỗ codemod bỏ sót

Codemod vẫn đang được hoàn thiện. Nó gỡ @trpc/react-query sau khi chạy (thêm --skipTanstackQuery nếu muốn giữ lại để migrate dần) và không xử lý mọi pattern, ví dụ useQueries hay các wrapper tự viết quanh hook tRPC. Bảng đối chiếu cho phần còn lại:

Classic (@trpc/react-query) Integration mới
trpc.a.useQuery(input) useQuery(trpc.a.queryOptions(input))
trpc.a.useMutation() useMutation(trpc.a.mutationOptions())
trpc.a.useInfiniteQuery(input, opts) useInfiniteQuery(trpc.a.infiniteQueryOptions(input, opts))
utils.a.invalidate(input) queryClient.invalidateQueries(trpc.a.queryFilter(input))
utils.r.invalidate() queryClient.invalidateQueries(trpc.r.pathFilter())
utils.a.setData(input, updater) queryClient.setQueryData(trpc.a.queryKey(input), updater)
utils.client useTRPCClient()

Với codebase lớn, mình không migrate trong một PR. Mount cả hai Provider với cùng một QueryClient, chuyển từng màn hình, và xóa trpc.Provider classic khi grep không còn useUtils.

Những lỗi thường gặp

Kéo code server vào bundle client.

// Sai: client component import giá trị, kéo cả router, repository, driver DB vào bundle
import { appRouter } from '@/trpc/routers/_app'
// Đúng: chỉ import type, bị xóa hoàn toàn khi build
import type { AppRouter } from '@/trpc/routers/_app'

Thêm import 'server-only' vào trpc/server.tsx và mọi file trong server/repositories. Bật rule @typescript-eslint/consistent-type-imports để import type trở thành mặc định.

Router lớn làm type-check chậm. Mỗi procedure trả về type suy ra từ ORM (ví dụ query Prisma với include lồng ba tầng) khiến TypeScript phải suy luận lại type đó ở mọi chỗ dùng. Khi editor bắt đầu lag, hãy đo trước:

npx tsc --noEmit --extendedDiagnostics | grep -E "Check time|Instantiations|Memory used"
npx tsc --noEmit --generateTrace ./ts-trace   # mở bằng Perfetto hoặc @typescript/analyze-trace

Cách sửa theo thứ tự hiệu quả với mình: trả DTO có type khai báo tường minh thay vì nguyên object ORM, tách router sang package riêng trong monorepo với project references để client dùng .d.ts đã emit, và chia router theo domain để mỗi file nhỏ.

Version skew khi deploy. Tab trình duyệt mở từ hôm qua vẫn chạy bundle cũ, trong khi server đã có router mới. tRPC không có versioning, nên thay đổi input phải additive (field mới để optional). Đổi tên procedure thì giữ tên cũ thêm một chu kỳ deploy.

Thiếu superjson ở một đầu. Server có transformer: superjson nhưng link thì không: input Date thành chuỗi ISO, zod z.date() trả BAD_REQUEST, và bạn mất nửa ngày debug một lỗi "không thể xảy ra".

Mỗi integration một QueryClient khi migrate dần. Classic và integration mới chạy song song được, và query key của hai bên giống hệt nhau. Nhưng nếu trpc.Provider (classic) và TRPCProvider (mới) nhận hai QueryClient khác nhau thì cache bị chia đôi: mutation viết theo kiểu mới invalidate xong, màn hình còn dùng hook classic vẫn hiện dữ liệu cũ. Truyền cùng một queryClient và cùng một trpcClient cho cả hai Provider.

Khi nào chọn tRPC và khi nào không

Nên dùng ngay nếu:

  • Ứng dụng Next.js full-stack hoặc monorepo TypeScript, một team sở hữu cả frontend lẫn backend.
  • API chỉ phục vụ frontend của chính bạn: dashboard, admin, SaaS nội bộ.
  • Team đã quen TanStack Query. Integration mới gần như không có gì phải học thêm.

Nên chờ hoặc chọn thứ khác nếu:

  • Có consumer bên ngoài hoặc app mobile native. Hãy dùng REST + OpenAPI, có thể đặt song song với tRPC trong cùng app Next.js.
  • Frontend và backend ở hai repo, deploy độc lập, do hai team quản lý. Version skew sẽ thành vấn đề hằng tuần.
  • Phần lớn tương tác là mutation đơn giản từ form, không cần client cache. Server Actions của Next.js là đủ và ít thành phần hơn.
  • Cần cache CDN cho dữ liệu public. Route handler REST bình thường làm việc này tốt hơn.

Câu hỏi mình nhận nhiều nhất là "đã có Server Actions rồi thì cần tRPC làm gì?". Hai thứ giải hai bài khác nhau:

  • Server Actions sinh ra cho mutation gắn với form. Chúng chạy được cả khi JavaScript chưa tải xong (progressive enhancement) và gọi revalidatePath/revalidateTag ngay trong action. Nhưng chúng luôn là POST, không có cache phía client, và tài liệu Next.js khuyên không dùng chúng để fetch dữ liệu vì client gửi và chờ từng action một.
  • tRPC mạnh ở phần đọc dữ liệu tương tác: cache theo query key, prefetch, infinite scroll, polling, optimistic update, invalidate theo namespace. Đó là những thứ dashboard cần mà Server Actions không có.

Trong app thực tế, mình dùng cả hai: form tạo/sửa đơn giản đi qua Server Actions, còn màn hình danh sách nhiều filter, cập nhật liên tục và nhiều tương tác thì đi qua tRPC. Cả hai cùng gọi một tầng service ở server/, nên logic nghiệp vụ không bị viết hai lần.

Kết luận của mình: tRPC v11 kết hợp với integration TanStack mới là cách nhanh nhất để có API type-safe end-to-end trong một codebase Next.js, và v11 đã đủ chín cho production. Nhưng ranh giới của nó rất rõ: một ngôn ngữ, một repo, một team. Ra khỏi ranh giới đó, hãy chọn công cụ có contract độc lập với ngôn ngữ.

Checklist

  • Cài đúng bộ package: @trpc/server, @trpc/client, @trpc/tanstack-react-query, @tanstack/react-query v5, zod, superjson, server-only. TypeScript từ 5.7.2 trở lên.
  • init.ts không import gì của Next. Context có type rõ ràng và chứa repository để test dễ dàng.
  • protectedProcedure thu hẹp ctx.user thành non-null. Resource của người khác trả NOT_FOUND.
  • errorFormatter thêm zodError và che message INTERNAL_SERVER_ERROR ở production.
  • superjson có ở initTRPC, từng link, serializeData và deserializeData.
  • Query dùng httpBatchStreamLink với maxURLLength. Mutation cần cookie đi qua httpBatchLink.
  • Server Component dùng prefetch + HydrateClient với input giống hệt client. caller chỉ dùng khi server cần dữ liệu.
  • Client có một hàm toUserMessage map theo error.data.code. Query không retry khi gặp 4xx.
  • Mỗi procedure có test bằng createCallerFactory cho các ca: chưa đăng nhập, sai quyền, input sai, xung đột trạng thái.
  • Đo tsc --extendedDiagnostics định kỳ. Procedure trả DTO tường minh, không trả nguyên object ORM.

Đọc thêm

Last updated: March 29, 2026

Comments