Đi đến nội dung
·26 phút đọc

tRPC: Type Safety End-to-End Không Cần Nghi Lễ

Cách tRPC loại bỏ vấn đề API contract, hoạt động với Next.js App Router, xử lý auth middleware, file uploads và subscriptions. Cấu hình tRPC thực tế từ đầu.

Chia sẻ:X / TwitterLinkedIn

Bạn biết kịch bản này rồi. Bạn đổi tên field trong API response. Bạn cập nhật type ở backend. Bạn deploy. Rồi frontend hỏng trong production vì ai đó quên cập nhật lời gọi fetch ở dòng 247 của Dashboard.tsx. Field giờ là undefined, component render trống, và hệ thống error tracking sáng đèn lúc 2 giờ sáng.

Đây là vấn đề API contract. Đây không phải vấn đề công nghệ. Đây là vấn đề phối hợp. Và dù bao nhiêu Swagger doc hay GraphQL schema cũng không sửa được thực tế rằng type frontend và backend có thể lệch nhau âm thầm.

tRPC sửa điều này bằng cách từ chối để chúng lệch. Không có file schema. Không có bước code generation. Không có contract riêng biệt để duy trì. Bạn viết hàm TypeScript trên server, và client biết chính xác type input và output tại thời điểm compile. Nếu bạn đổi tên field, frontend sẽ không compile cho đến khi bạn sửa.

Đó là lời hứa. Để tôi cho bạn xem nó thực sự hoạt động ra sao, nơi nào nó tỏa sáng, và nơi nào bạn tuyệt đối không nên dùng nó.

Vấn Đề, Chính Xác Hơn#

Hãy xem hầu hết team xây dựng API thế nào ngày nay.

REST + OpenAPI: Bạn viết endpoint. Có thể thêm Swagger annotation. Có thể sinh client SDK từ OpenAPI spec. Nhưng spec là artifact riêng biệt. Nó có thể cũ đi. Bước generation là thêm một thứ trong CI pipeline có thể hỏng hoặc bị quên. Và type được sinh ra thường xấu — những con quái vật paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"] lồng sâu.

GraphQL: Type safety tốt hơn, nhưng nghi lễ khổng lồ. Bạn viết schema bằng SDL. Bạn viết resolver. Bạn sinh type từ schema. Bạn viết query trên client. Bạn sinh type từ query. Ít nhất hai bước code generation, một file schema, và một build step mà ai cũng phải nhớ chạy. Cho team đã kiểm soát cả frontend lẫn backend, đây là rất nhiều infrastructure cho vấn đề có giải pháp đơn giản hơn.

Gọi fetch thủ công: Cách tiếp cận phổ biến nhất và nguy hiểm nhất. Bạn viết fetch("/api/users"), cast kết quả thành User[], và hy vọng. Không có safety tại compile-time. Type assertion là lời nói dối bạn kể với TypeScript.

typescript
// Lời nói dối mọi frontend developer đã kể
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // hy vọng cái này đúng

tRPC tiếp cận hoàn toàn khác. Thay vì mô tả API trong format riêng biệt và sinh type, bạn viết hàm TypeScript thuần trên server và import type trực tiếp vào client. Không có bước generation. Không có file schema. Không có lệch.

Khái Niệm Cốt Lõi#

Trước khi cài đặt, hãy hiểu mô hình tư duy.

Router#

Router tRPC là tập hợp các procedure được nhóm lại. Hãy nghĩ như controller trong MVC, ngoại trừ nó chỉ là object thuần với type inference tích hợp.

typescript
import { initTRPC } from "@trpc/server";
 
const t = initTRPC.create();
 
export const appRouter = t.router({
  user: t.router({
    list: t.procedure.query(/* ... */),
    byId: t.procedure.input(/* ... */).query(/* ... */),
    create: t.procedure.input(/* ... */).mutation(/* ... */),
  }),
  post: t.router({
    list: t.procedure.query(/* ... */),
    publish: t.procedure.input(/* ... */).mutation(/* ... */),
  }),
});
 
export type AppRouter = typeof appRouter;

Export type AppRouter đó là toàn bộ phép thuật. Client import type này — không phải runtime code, chỉ type — và có đầy đủ autocompletion và type checking cho mọi procedure.

Procedure#

Procedure là một endpoint đơn lẻ. Có ba loại:

  • Query: Thao tác đọc. Map sang HTTP GET semantics. Được cache bởi TanStack Query.
  • Mutation: Thao tác ghi. Map sang HTTP POST. Không cache.
  • Subscription: Stream thời gian thực. Dùng WebSocket.

Context#

Context là dữ liệu request-scoped có sẵn cho mọi procedure. Kết nối database, user đã xác thực, request header — bất cứ thứ gì bạn đặt trong object req của Express đều ở đây.

Middleware#

Middleware biến đổi context hoặc kiểm soát truy cập. Pattern phổ biến nhất là auth middleware kiểm tra session hợp lệ và thêm ctx.user.

Chuỗi Type Inference#

Đây là mô hình tư duy quan trọng. Khi bạn định nghĩa procedure như thế này:

typescript
t.procedure
  .input(z.object({ id: z.string() }))
  .query(({ input }) => {
    // input được type là { id: string }
    return db.user.findUnique({ where: { id: input.id } });
  });

Return type chảy suốt đến client. Nếu db.user.findUnique trả về User | null, hook useQuery của client sẽ có data được type là User | null. Không type thủ công. Không casting. Nó được infer end-to-end.

Cài Đặt Với Next.js App Router#

Hãy xây dựng từ đầu. Tôi giả sử bạn có dự án Next.js 14+ với App Router.

Cài Dependency#

bash
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod

Bước 1: Khởi Tạo tRPC Trên Server#

Tạo instance tRPC và định nghĩa context type.

typescript
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import superjson from "superjson";
import { ZodError } from "zod";
 
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
  const session = await getSession(opts.req);
 
  return {
    session,
    db: prisma,
    req: opts.req,
  };
}
 
const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});
 
export const createCallerFactory = t.createCallerFactory;
export const router = t.router;
export const publicProcedure = t.procedure;

Vài điều cần lưu ý:

  • Transformer superjson: tRPC serialize dữ liệu thành JSON mặc định, nghĩa là object Date, Map, Set, và các type non-JSON khác bị mất. superjson giữ lại chúng.
  • Error formatter: Chúng ta đính kèm lỗi Zod validation vào response để client hiển thị lỗi cấp field.
  • createTRPCContext: Hàm này chạy trên mỗi request. Đây là nơi bạn parse session, thiết lập kết nối database, và xây dựng context object.

Bước 2: Định Nghĩa Router#

typescript
// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
 
export const userRouter = router({
  me: protectedProcedure.query(async ({ ctx }) => {
    const user = await ctx.db.user.findUnique({
      where: { id: ctx.user.id },
      select: {
        id: true,
        name: true,
        email: true,
        image: true,
        createdAt: true,
      },
    });
 
    if (!user) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "User not found",
      });
    }
 
    return user;
  }),
 
  updateProfile: protectedProcedure
    .input(
      z.object({
        name: z.string().min(1).max(100),
        bio: z.string().max(500).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const updated = await ctx.db.user.update({
        where: { id: ctx.user.id },
        data: {
          name: input.name,
          bio: input.bio,
        },
      });
 
      return updated;
    }),
 
  byId: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ ctx, input }) => {
      const user = await ctx.db.user.findUnique({
        where: { id: input.id },
        select: {
          id: true,
          name: true,
          image: true,
          bio: true,
        },
      });
 
      if (!user) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "User not found",
        });
      }
 
      return user;
    }),
});
typescript
// src/server/routers/_app.ts
import { router } from "../trpc";
import { userRouter } from "./user";
import { postRouter } from "./post";
import { notificationRouter } from "./notification";
 
export const appRouter = router({
  user: userRouter,
  post: postRouter,
  notification: notificationRouter,
});
 
export type AppRouter = typeof appRouter;

Bước 3: Expose Qua Next.js Route Handler#

Trong App Router, tRPC chạy như Route Handler tiêu chuẩn. Không server tùy chỉnh, không plugin Next.js đặc biệt.

typescript
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createTRPCContext } from "@/server/trpc";
 
const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: "/api/trpc",
    req,
    router: appRouter,
    createContext: createTRPCContext,
    onError:
      process.env.NODE_ENV === "development"
        ? ({ path, error }) => {
            console.error(
              `tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
            );
          }
        : undefined,
  });
 
export { handler as GET, handler as POST };

Thế thôi. Cả GET và POST đều được xử lý. Query đi qua GET (với input URL-encoded), mutation đi qua POST.

Bước 4: Cài Đặt Client#

typescript
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
 
export const trpc = createTRPCReact<AppRouter>();

Lưu ý: chúng ta import AppRouter chỉ là type. Không có server code rò rỉ vào client bundle.

Bước 5: Cài Đặt Provider#

typescript
// src/components/providers/TRPCProvider.tsx
"use client";
 
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import superjson from "superjson";
 
function getBaseUrl() {
  if (typeof window !== "undefined") return "";
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
  return `http://localhost:${process.env.PORT ?? 3000}`;
}
 
export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 5 * 60 * 1000,
            retry: 1,
          },
        },
      })
  );
 
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        loggerLink({
          enabled: (op) =>
            process.env.NODE_ENV === "development" ||
            (op.direction === "down" && op.result instanceof Error),
        }),
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
      ],
    })
  );
 
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}
typescript
// src/app/layout.tsx (phần liên quan)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <TRPCProvider>{children}</TRPCProvider>
      </body>
    </html>
  );
}

Bước 6: Sử Dụng Trong Component#

typescript
// src/app/dashboard/page.tsx
"use client";
 
import { trpc } from "@/lib/trpc";
 
export default function DashboardPage() {
  const { data: user, isLoading, error } = trpc.user.me.useQuery();
 
  if (isLoading) return <DashboardSkeleton />;
  if (error) return <ErrorDisplay message={error.message} />;
 
  return (
    <div>
      <h1>Chào mừng trở lại, {user.name}</h1>
      <p>Thành viên từ {user.createdAt.toLocaleDateString()}</p>
    </div>
  );
}

user.name đó được type đầy đủ. Nếu bạn viết sai thành user.nme, TypeScript bắt ngay. Nếu bạn đổi server trả về displayName thay vì name, mọi nơi dùng client sẽ hiện lỗi compile. Không bất ngờ runtime.

Context và Middleware#

Context và middleware là nơi tRPC từ "thủ thuật type gọn" biến thành "framework sẵn sàng production."

Tạo Context#

Hàm context chạy trên mỗi request. Đây là phiên bản thực tế:

typescript
// src/server/trpc.ts
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
 
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
  const session = await getServerSession(authOptions);
 
  return {
    session,
    user: session?.user ?? null,
    db: prisma,
    headers: Object.fromEntries(opts.req.headers),
  };
}
 
type Context = Awaited<ReturnType<typeof createTRPCContext>>;

Auth Middleware#

Pattern middleware phổ biến nhất là phân tách procedure public và protected:

typescript
// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in to perform this action",
    });
  }
 
  return next({
    ctx: {
      // Override context type — user không còn nullable
      user: ctx.user,
    },
  });
});
 
export const protectedProcedure = t.procedure.use(isAuthed);

Sau khi middleware này chạy, ctx.user trong bất kỳ protectedProcedure nào được đảm bảo non-null. Hệ thống type bắt buộc điều này. Bạn không thể vô tình truy cập ctx.user.id trong procedure public mà TypeScript không phàn nàn.

Middleware Dựa Trên Role#

Bạn có thể kết hợp middleware cho kiểm soát truy cập chi tiết hơn:

typescript
const isAdmin = t.middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
 
  if (ctx.user.role !== "ADMIN") {
    throw new TRPCError({
      code: "FORBIDDEN",
      message: "Admin access required",
    });
  }
 
  return next({
    ctx: {
      user: ctx.user,
    },
  });
});
 
export const adminProcedure = t.procedure.use(isAdmin);

Middleware Logging#

Middleware không chỉ cho auth. Đây là middleware logging hiệu năng:

typescript
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
 
  const result = await next();
 
  const duration = Date.now() - start;
 
  if (duration > 1000) {
    console.warn(`Slow ${type} ${path}: ${duration}ms`);
  }
 
  return result;
});
 
// Áp dụng cho tất cả procedure
export const publicProcedure = t.procedure.use(loggerMiddleware);

Middleware Rate Limiting#

typescript
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
 
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(20, "10 s"),
});
 
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
  const ip = ctx.headers["x-forwarded-for"] ?? "unknown";
  const { success, remaining } = await ratelimit.limit(ip);
 
  if (!success) {
    throw new TRPCError({
      code: "TOO_MANY_REQUESTS",
      message: `Rate limit exceeded. Try again later.`,
    });
  }
 
  return next();
});

Input Validation Với Zod#

tRPC dùng Zod cho input validation. Đây không phải trang trí tùy chọn — đây là cơ chế đảm bảo input an toàn trên cả client lẫn server.

Validation Cơ Bản#

typescript
const postRouter = router({
  create: protectedProcedure
    .input(
      z.object({
        title: z.string().min(1, "Title is required").max(200, "Title too long"),
        content: z.string().min(10, "Content must be at least 10 characters"),
        categoryId: z.string().uuid("Invalid category ID"),
        tags: z.array(z.string()).max(5, "Maximum 5 tags").default([]),
        published: z.boolean().default(false),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // input được type đầy đủ:
      // {
      //   title: string;
      //   content: string;
      //   categoryId: string;
      //   tags: string[];
      //   published: boolean;
      // }
 
      return ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),
});

Thủ Thuật Dual Validation#

Đây là điều tinh tế: Zod validation chạy trên cả hai phía. Ở client, tRPC validate input trước khi gửi request. Nếu input không hợp lệ, request không bao giờ rời trình duyệt. Ở server, cùng schema validate lại như biện pháp bảo mật.

Nghĩa là bạn được client-side validation miễn phí:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState } from "react";
 
export function CreatePostForm() {
  const [title, setTitle] = useState("");
  const utils = trpc.useUtils();
 
  const createPost = trpc.post.create.useMutation({
    onSuccess: () => {
      utils.post.list.invalidate();
    },
    onError: (error) => {
      // Lỗi Zod đến có cấu trúc
      if (error.data?.zodError) {
        const fieldErrors = error.data.zodError.fieldErrors;
        // fieldErrors.title?: string[]
        // fieldErrors.content?: string[]
        console.log(fieldErrors);
      }
    },
  });
 
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        createPost.mutate({
          title,
          content: "...",
          categoryId: "...",
        });
      }}
    >
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      {createPost.error?.data?.zodError?.fieldErrors.title && (
        <span className="text-red-500">
          {createPost.error.data.zodError.fieldErrors.title[0]}
        </span>
      )}
      <button type="submit" disabled={createPost.isPending}>
        {createPost.isPending ? "Creating..." : "Create Post"}
      </button>
    </form>
  );
}

Pattern Input Phức Tạp#

typescript
// Discriminated union
const searchInput = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("user"),
    query: z.string(),
    includeInactive: z.boolean().default(false),
  }),
  z.object({
    type: z.literal("post"),
    query: z.string(),
    category: z.string().optional(),
  }),
]);
 
// Input pagination dùng lại qua các procedure
const paginationInput = z.object({
  cursor: z.string().nullish(),
  limit: z.number().min(1).max(100).default(20),
});
 
const postRouter = router({
  infiniteList: publicProcedure
    .input(
      z.object({
        ...paginationInput.shape,
        category: z.string().optional(),
        sortBy: z.enum(["newest", "popular", "trending"]).default("newest"),
      })
    )
    .query(async ({ ctx, input }) => {
      const { cursor, limit, category, sortBy } = input;
 
      const posts = await ctx.db.post.findMany({
        take: limit + 1,
        cursor: cursor ? { id: cursor } : undefined,
        where: category ? { categoryId: category } : undefined,
        orderBy:
          sortBy === "newest"
            ? { createdAt: "desc" }
            : sortBy === "popular"
              ? { likes: "desc" }
              : { score: "desc" },
      });
 
      let nextCursor: string | undefined;
      if (posts.length > limit) {
        const nextItem = posts.pop();
        nextCursor = nextItem?.id;
      }
 
      return {
        posts,
        nextCursor,
      };
    }),
});

Input Tùy Chọn#

Không phải procedure nào cũng cần input. Query thường không cần:

typescript
const statsRouter = router({
  // Không cần input
  overview: publicProcedure.query(async ({ ctx }) => {
    const [userCount, postCount, commentCount] = await Promise.all([
      ctx.db.user.count(),
      ctx.db.post.count(),
      ctx.db.comment.count(),
    ]);
 
    return { userCount, postCount, commentCount };
  }),
 
  // Bộ lọc tùy chọn
  detailed: publicProcedure
    .input(
      z
        .object({
          from: z.date().optional(),
          to: z.date().optional(),
        })
        .optional()
    )
    .query(async ({ ctx, input }) => {
      const where = {
        ...(input?.from && { createdAt: { gte: input.from } }),
        ...(input?.to && { createdAt: { lte: input.to } }),
      };
 
      return ctx.db.post.groupBy({
        by: ["categoryId"],
        where,
        _count: true,
      });
    }),
});

Xử Lý Lỗi#

Xử lý lỗi của tRPC có cấu trúc, type-safe, và tích hợp gọn với cả HTTP semantics và UI phía client.

Throw Lỗi Trên Server#

typescript
import { TRPCError } from "@trpc/server";
 
const postRouter = router({
  delete: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const post = await ctx.db.post.findUnique({
        where: { id: input.id },
      });
 
      if (!post) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "Post not found",
        });
      }
 
      if (post.authorId !== ctx.user.id) {
        throw new TRPCError({
          code: "FORBIDDEN",
          message: "You can only delete your own posts",
        });
      }
 
      await ctx.db.post.delete({ where: { id: input.id } });
 
      return { success: true };
    }),
});

Mã lỗi tRPC map sang HTTP status code:

Mã tRPCHTTP StatusKhi Nào Dùng
BAD_REQUEST400Input không hợp lệ ngoài Zod validation
UNAUTHORIZED401Chưa đăng nhập
FORBIDDEN403Đã đăng nhập nhưng không đủ quyền
NOT_FOUND404Tài nguyên không tồn tại
CONFLICT409Tài nguyên trùng lặp
TOO_MANY_REQUESTS429Vượt quá rate limit
INTERNAL_SERVER_ERROR500Lỗi server không mong đợi

Error Formatting Tùy Chỉnh#

Nhớ error formatter từ setup? Đây là cách nó hoạt động thực tế:

typescript
const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
        // Thêm field tùy chỉnh
        timestamp: new Date().toISOString(),
        requestId: crypto.randomUUID(),
      },
    };
  },
});

Xử Lý Lỗi Phía Client#

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
 
export function DeletePostButton({ postId }: { postId: string }) {
  const utils = trpc.useUtils();
 
  const deletePost = trpc.post.delete.useMutation({
    onSuccess: () => {
      toast.success("Post deleted");
      utils.post.list.invalidate();
    },
    onError: (error) => {
      switch (error.data?.code) {
        case "FORBIDDEN":
          toast.error("Bạn không có quyền xóa post này");
          break;
        case "NOT_FOUND":
          toast.error("Post này không còn tồn tại");
          utils.post.list.invalidate();
          break;
        default:
          toast.error("Có lỗi xảy ra. Vui lòng thử lại.");
      }
    },
  });
 
  return (
    <button
      onClick={() => deletePost.mutate({ id: postId })}
      disabled={deletePost.isPending}
    >
      {deletePost.isPending ? "Deleting..." : "Delete"}
    </button>
  );
}

Xử Lý Lỗi Toàn Cục#

Bạn có thể thiết lập error handler toàn cục bắt tất cả lỗi tRPC chưa xử lý:

typescript
// Trong TRPCProvider
const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        mutations: {
          onError: (error) => {
            // Fallback toàn cục cho mutation error chưa xử lý
            if (error instanceof TRPCClientError) {
              if (error.data?.code === "UNAUTHORIZED") {
                // Redirect về login
                window.location.href = "/login";
                return;
              }
 
              toast.error(error.message);
            }
          },
        },
      },
    })
);

Mutation và Optimistic Update#

Mutation là nơi tRPC thực sự tích hợp tốt với TanStack Query. Hãy xem pattern thực tế: nút like với optimistic update.

Mutation Cơ Bản#

typescript
// Server
const postRouter = router({
  toggleLike: protectedProcedure
    .input(z.object({ postId: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const existing = await ctx.db.like.findUnique({
        where: {
          userId_postId: {
            userId: ctx.user.id,
            postId: input.postId,
          },
        },
      });
 
      if (existing) {
        await ctx.db.like.delete({
          where: { id: existing.id },
        });
        return { liked: false };
      }
 
      await ctx.db.like.create({
        data: {
          userId: ctx.user.id,
          postId: input.postId,
        },
      });
 
      return { liked: true };
    }),
});

Optimistic Update#

User click "Like." Bạn không muốn chờ 200ms cho server response để cập nhật UI. Optimistic update giải quyết: cập nhật UI ngay lập tức, rồi rollback nếu server từ chối.

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
 
export function LikeButton({ postId, initialLiked, initialCount }: {
  postId: string;
  initialLiked: boolean;
  initialCount: number;
}) {
  const utils = trpc.useUtils();
 
  const toggleLike = trpc.post.toggleLike.useMutation({
    onMutate: async ({ postId }) => {
      // Hủy refetch đang chạy để không ghi đè optimistic update
      await utils.post.byId.cancel({ id: postId });
 
      // Snapshot giá trị trước đó
      const previousPost = utils.post.byId.getData({ id: postId });
 
      // Cập nhật cache optimistic
      utils.post.byId.setData({ id: postId }, (old) => {
        if (!old) return old;
        return {
          ...old,
          liked: !old.liked,
          likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
        };
      });
 
      // Trả snapshot cho rollback
      return { previousPost };
    },
 
    onError: (_error, { postId }, context) => {
      // Rollback về giá trị trước khi lỗi
      if (context?.previousPost) {
        utils.post.byId.setData({ id: postId }, context.previousPost);
      }
    },
 
    onSettled: (_data, _error, { postId }) => {
      // Luôn refetch sau lỗi hoặc thành công để đảm bảo server state
      utils.post.byId.invalidate({ id: postId });
    },
  });
 
  return (
    <button
      onClick={() => toggleLike.mutate({ postId })}
      className={initialLiked ? "text-red-500" : "text-gray-400"}
    >
      ♥ {initialCount}
    </button>
  );
}

Pattern luôn giống nhau:

  1. onMutate: Hủy query, snapshot dữ liệu hiện tại, áp dụng optimistic update, trả snapshot.
  2. onError: Rollback dùng snapshot.
  3. onSettled: Invalidate query để refetch từ server, bất kể thành công hay lỗi.

Ba bước này đảm bảo UI luôn responsive và cuối cùng nhất quán với server.

Invalidate Query Liên Quan#

Sau mutation, bạn thường cần refresh dữ liệu liên quan. useUtils() của tRPC làm điều này dễ dàng:

typescript
const createComment = trpc.comment.create.useMutation({
  onSuccess: (_data, variables) => {
    // Invalidate danh sách comment của post
    utils.comment.listByPost.invalidate({ postId: variables.postId });
 
    // Invalidate chính post (số comment thay đổi)
    utils.post.byId.invalidate({ id: variables.postId });
 
    // Invalidate TẤT CẢ danh sách post (số comment trong list view)
    utils.post.list.invalidate();
  },
});

Batching và Subscription#

HTTP Batching#

Mặc định, tRPC với httpBatchLink gộp nhiều request đồng thời thành một HTTP call. Nếu component render và fire ba query:

typescript
function Dashboard() {
  const user = trpc.user.me.useQuery();
  const posts = trpc.post.list.useQuery({ limit: 10 });
  const stats = trpc.stats.overview.useQuery();
 
  // ...
}

Ba query này tự động được batch thành một HTTP request: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...

Server xử lý cả ba, trả cả ba kết quả trong một response, và TanStack Query phân phối kết quả cho mỗi hook. Không cần cấu hình.

Bạn có thể tắt batching cho call cụ thể nếu cần:

typescript
// Trong provider, dùng splitLink để định tuyến procedure cụ thể khác đi
import { splitLink, httpBatchLink, httpLink } from "@trpc/client";
 
const [trpcClient] = useState(() =>
  trpc.createClient({
    links: [
      splitLink({
        condition: (op) => op.path === "post.infiniteList",
        true: httpLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
        false: httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
          maxURLLength: 2048,
        }),
      }),
    ],
  })
);

WebSocket Subscription#

Cho tính năng real-time, tRPC hỗ trợ subscription qua WebSocket. Điều này yêu cầu server WebSocket riêng (Next.js không native hỗ trợ WebSocket trong Route Handler).

typescript
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
 
// Event emitter in-memory (dùng Redis pub/sub trong production)
import { EventEmitter } from "events";
 
const eventEmitter = new EventEmitter();
 
export const notificationRouter = router({
  onNew: protectedProcedure.subscription(({ ctx }) => {
    return observable<Notification>((emit) => {
      const handler = (notification: Notification) => {
        if (notification.userId === ctx.user.id) {
          emit.next(notification);
        }
      };
 
      eventEmitter.on("notification", handler);
 
      return () => {
        eventEmitter.off("notification", handler);
      };
    });
  }),
 
  // Mutation trigger notification
  markAsRead: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const notification = await ctx.db.notification.update({
        where: { id: input.id, userId: ctx.user.id },
        data: { readAt: new Date() },
      });
 
      return notification;
    }),
});

Phía client:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState } from "react";
 
export function NotificationBell() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
 
  trpc.notification.onNew.useSubscription(undefined, {
    onData: (notification) => {
      setNotifications((prev) => [notification, ...prev]);
      toast.info(notification.message);
    },
    onError: (error) => {
      console.error("Subscription error:", error);
    },
  });
 
  return (
    <div>
      <span className="badge">{notifications.length}</span>
      {/* notification list UI */}
    </div>
  );
}

Cho WebSocket transport, bạn cần process server riêng. Đây là setup tối thiểu với thư viện ws:

typescript
// ws-server.ts (process riêng)
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { appRouter } from "./server/routers/_app";
import { createTRPCContext } from "./server/trpc";
 
const wss = new WebSocketServer({ port: 3001 });
 
const handler = applyWSSHandler({
  wss,
  router: appRouter,
  createContext: createTRPCContext,
});
 
wss.on("connection", (ws) => {
  console.log(`Connection opened (${wss.clients.size} total)`);
  ws.once("close", () => {
    console.log(`Connection closed (${wss.clients.size} total)`);
  });
});
 
process.on("SIGTERM", () => {
  handler.broadcastReconnectNotification();
  wss.close();
});
 
console.log("WebSocket server listening on ws://localhost:3001");

Và client cần wsLink cho subscription:

typescript
import { wsLink, createWSClient } from "@trpc/client";
 
const wsClient = createWSClient({
  url: "ws://localhost:3001",
});
 
// Dùng splitLink để định tuyến subscription qua WebSocket
const [trpcClient] = useState(() =>
  trpc.createClient({
    links: [
      splitLink({
        condition: (op) => op.type === "subscription",
        true: wsLink({ client: wsClient, transformer: superjson }),
        false: httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
      }),
    ],
  })
);

Upload File Type-Safe#

tRPC không xử lý file upload native. Nó là giao thức JSON-RPC — dữ liệu nhị phân không phải sở trường. Nhưng bạn có thể xây flow upload type-safe bằng cách kết hợp tRPC với presigned URL.

Pattern:

  1. Client hỏi tRPC lấy presigned upload URL.
  2. tRPC validate request, kiểm tra quyền, sinh URL.
  3. Client upload trực tiếp lên S3 bằng presigned URL.
  4. Client thông báo tRPC upload hoàn tất.
typescript
// src/server/routers/upload.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
 
const s3 = new S3Client({ region: process.env.AWS_REGION });
 
export const uploadRouter = router({
  getPresignedUrl: protectedProcedure
    .input(
      z.object({
        filename: z.string().min(1).max(255),
        contentType: z.string().regex(/^(image|application)\//),
        size: z.number().max(10 * 1024 * 1024, "File must be under 10MB"),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const key = `uploads/${ctx.user.id}/${crypto.randomUUID()}-${input.filename}`;
 
      const command = new PutObjectCommand({
        Bucket: process.env.S3_BUCKET!,
        Key: key,
        ContentType: input.contentType,
        ContentLength: input.size,
      });
 
      const presignedUrl = await getSignedUrl(s3, command, {
        expiresIn: 300, // 5 phut
      });
 
      // Luu upload dang cho trong database
      const upload = await ctx.db.upload.create({
        data: {
          key,
          userId: ctx.user.id,
          filename: input.filename,
          contentType: input.contentType,
          size: input.size,
          status: "PENDING",
        },
      });
 
      return {
        uploadId: upload.id,
        presignedUrl,
        key,
      };
    }),
 
  confirmUpload: protectedProcedure
    .input(z.object({ uploadId: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const upload = await ctx.db.upload.findUnique({
        where: { id: input.uploadId, userId: ctx.user.id },
      });
 
      if (!upload) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }
 
      // Xac nhan file thuc su ton tai trong S3
      // (tuy chon nhung khuyen nghi)
 
      const confirmed = await ctx.db.upload.update({
        where: { id: upload.id },
        data: { status: "CONFIRMED" },
      });
 
      return {
        url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${confirmed.key}`,
      };
    }),
});

Phia client:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState, useCallback } from "react";
 
export function FileUpload() {
  const [uploading, setUploading] = useState(false);
 
  const getPresignedUrl = trpc.upload.getPresignedUrl.useMutation();
  const confirmUpload = trpc.upload.confirmUpload.useMutation();
 
  const handleFileChange = useCallback(
    async (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      if (!file) return;
 
      setUploading(true);
 
      try {
        // Buoc 1: Lay presigned URL tu tRPC
        const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
          filename: file.name,
          contentType: file.type,
          size: file.size,
        });
 
        // Buoc 2: Upload truc tiep len S3
        const uploadResponse = await fetch(presignedUrl, {
          method: "PUT",
          body: file,
          headers: {
            "Content-Type": file.type,
          },
        });
 
        if (!uploadResponse.ok) {
          throw new Error("Upload failed");
        }
 
        // Buoc 3: Xac nhan upload qua tRPC
        const { url } = await confirmUpload.mutateAsync({ uploadId });
 
        toast.success(`File uploaded: ${url}`);
      } catch (error) {
        toast.error("Upload that bai. Vui long thu lai.");
      } finally {
        setUploading(false);
      }
    },
    [getPresignedUrl, confirmUpload]
  );
 
  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={uploading}
        accept="image/*"
      />
      {uploading && <p>Dang upload...</p>}
    </div>
  );
}

Toan bo flow la type-safe. Response type cua presigned URL, upload ID type, response xac nhan — tat ca duoc infer tu dinh nghia server. Neu ban them field moi vao response presigned URL, client biet ngay lap tuc.

Server-Side Call va React Server Component#

Voi Next.js App Router, ban thuong muon fetch du lieu trong Server Component. tRPC ho tro thong qua server-side caller:

typescript
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
 
// Su dung trong Server Component
// src/app/posts/[id]/page.tsx
import { createTRPCContext } from "@/server/trpc";
import { createCaller } from "@/server/trpc";
 
export default async function PostPage({
  params,
}: {
  params: { id: string };
}) {
  const ctx = await createTRPCContext({
    req: new Request("http://localhost"),
    resHeaders: new Headers(),
  });
  const caller = createCaller(ctx);
 
  const post = await caller.post.byId({ id: params.id });
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
      {/* Client component cho phan tuong tac */}
      <LikeButton postId={post.id} initialLiked={post.liked} />
      <CommentSection postId={post.id} />
    </article>
  );
}

Dieu nay cho ban nhung gi tot nhat tu ca hai phia: du lieu render server voi day du type safety, va tuong tac client-side cho mutation va tinh nang real-time.

Testing Procedure tRPC#

Testing don gian vi procedure chi la ham. Ban khong can khoi dong HTTP server.

typescript
// src/server/routers/user.test.ts
import { describe, it, expect, vi } from "vitest";
import { appRouter } from "./routers/_app";
import { createCaller } from "./trpc";
 
describe("user router", () => {
  it("tra ve profile user hien tai", async () => {
    const caller = createCaller({
      user: { id: "user-1", email: "test@example.com", role: "USER" },
      db: prismaMock,
      session: mockSession,
      headers: {},
    });
 
    prismaMock.user.findUnique.mockResolvedValue({
      id: "user-1",
      name: "Test User",
      email: "test@example.com",
      image: null,
      createdAt: new Date("2026-01-01"),
    });
 
    const result = await caller.user.me();
 
    expect(result).toEqual({
      id: "user-1",
      name: "Test User",
      email: "test@example.com",
      image: null,
      createdAt: new Date("2026-01-01"),
    });
  });
 
  it("throw UNAUTHORIZED cho request chua xac thuc", async () => {
    const caller = createCaller({
      user: null,
      db: prismaMock,
      session: null,
      headers: {},
    });
 
    await expect(caller.user.me()).rejects.toThrow("UNAUTHORIZED");
  });
 
  it("validate input voi Zod", async () => {
    const caller = createCaller({
      user: { id: "user-1", email: "test@example.com", role: "USER" },
      db: prismaMock,
      session: mockSession,
      headers: {},
    });
 
    await expect(
      caller.user.updateProfile({
        name: "", // min length 1
      })
    ).rejects.toThrow();
  });
});

Khong mock HTTP layer, khong supertest, khong route matching. Chi goi ham va assert ket qua. Day la mot trong nhung uu diem it duoc danh gia cua tRPC: testing don gian tam thuong vi transport layer chi la chi tiet trrien khai.

Khi NAO KHONG Nen Dung tRPC#

tRPC khong phai giai phap pho quat. Day la noi no that bai:

API Cong Khai#

Neu ban xay API cho developer ben ngoai su dung, tRPC la lua chon sai. Consumer ben ngoai khong co quyen truy cap TypeScript type cua ban. Ho can contract co tai lieu, on dinh — OpenAPI/Swagger cho REST, hoac GraphQL schema. Type safety cua tRPC chi hoat dong khi ca client lan server chia se cung TypeScript codebase.

Ung Dung Mobile (Tru Khi Ban Dung TypeScript)#

Neu app mobile cua ban viet bang Swift, Kotlin, hoac Dart, tRPC khong dem lai gi. Type khong vuot qua ranh gioi ngon ngu. Ban co the sinh OpenAPI spec tu route tRPC dung trpc-openapi, nhung luc do ban dang them nghi le tro lai. Cu dung REST tu dau.

Microservice#

tRPC gia dinh mot TypeScript codebase duy nhat. Neu backend chia thanh nhieu service bang cac ngon ngu khac nhau, tRPC khong giup giao tiep giua cac service. Dung gRPC, REST, hoac message queue cho dieu do.

Team Lon Voi Repo Frontend/Backend Rieng#

Neu frontend va backend song trong repo rieng biet voi deploy pipeline rieng, ban mat loi the cot loi cua tRPC. Chia se type yeu cau monorepo hoac shared package. Ban co the publish type AppRouter nhu npm package, nhung gio ban co van de versioning ma REST + OpenAPI xu ly tu nhien hon.

Khi Ban Can REST Semantics#

Neu ban can HTTP caching header, content negotiation, ETag, hoac tinh nang dac thu REST khac, abstraction cua tRPC tren HTTP se chong lai ban. tRPC coi HTTP la chi tiet transport, khong phai tinh nang.

Framework Quyet Dinh#

Day la cach toi quyet dinh:

Kich BanKhuyen Nghi
Ung dung fullstack TypeScript cung repotRPC — loi ich toi da, overhead toi thieu
Tool noi bo / admin dashboardtRPC — toc do phat trien la uu tien
API cong khai cho dev ben thu baREST + OpenAPI — consumer can doc, khong phai type
Client mobile + web (mobile khong TS)REST hoac GraphQL — can contract bat chap ngon ngu
Real-time nang (chat, gaming)tRPC subscription hoac raw WebSocket tuy do phuc tap
Team frontend/backend riengGraphQL — schema la contract giua team

Meo Thuc Te Tu Production#

Vai dieu toi hoc duoc khi chay tRPC trong production ma khong co trong doc:

Giu router nho. Mot file router khong nen vuot 200 dong. Tach theo domain: userRouter, postRouter, billingRouter. Moi cai trong file rieng.

Dung createCallerFactory cho server-side call. Dung dung toi fetch khi goi API cua chinh minh tu Server Component. Caller factory cho ban cung type safety voi khong HTTP overhead.

Dung toi uu hoa batching qua muc. httpBatchLink mac dinh gan nhu luon du. Toi thay team mat nhieu ngay cau hinh splitLink cho nhung cai thien nho. Profile truoc.

Dat staleTime trong QueryClient. staleTime mac dinh la 0 nghia la moi su kien focus trigger refetch. Dat thanh gia tri hop ly (30 giay den 5 phut) dua tren yeu cau do tuoi du lieu.

Dung superjson tu ngay dau. Them sau nghia la migrate moi client va server dong thoi. Day la cau hinh mot dong tiet kiem ban khoi bug serialize Date.

Error boundary la ban cua ban. Bao cac phan trang nang tRPC trong React error boundary. Mot query that bai khong nen lam sap toan trang.

typescript
"use client";
 
import { ErrorBoundary } from "react-error-boundary";
 
function ErrorFallback({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div role="alert" className="p-4 bg-red-50 rounded-lg">
      <p className="font-medium text-red-800">Co loi xay ra</p>
      <pre className="text-sm text-red-600 mt-2">{error.message}</pre>
      <button
        onClick={resetErrorBoundary}
        className="mt-3 px-4 py-2 bg-red-600 text-white rounded"
      >
        Thu lai
      </button>
    </div>
  );
}
 
export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <UserStats />
      </ErrorBoundary>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <RecentPosts />
      </ErrorBoundary>
    </div>
  );
}

Ket Luan#

tRPC khong phai thay the cho REST hay GraphQL. No la cong cu khac cho tinh huong cu the: khi ban kiem soat ca client lan server, ca hai deu TypeScript, va ban muon con duong ngan nhat tu "toi doi backend" den "frontend biet ve no."

Trong tinh huong do, khong gi den gan. Khong code generation, khong file schema, khong lech. Chi TypeScript lam nhung gi TypeScript lam tot nhat: bat loi truoc khi chung den production.

Danh doi ro rang: ban tu bo kha nang tuong thich cap giao thuc (khong client non-TypeScript) de doi lay toc do phat trien va safety tai compile-time kho dat duoc bang cach nao khac.

Cho hau het ung dung fullstack TypeScript, do la danh doi dang lam.

Bài viết liên quan