Xây dựng API End-to-End Type-Safe với tRPC và Next.js

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Vấn đề thực tế: Cái giá của sự đứt gãy API Contract

Trong một đợt sprint triển khai giao diện thanh toán đa tiền tệ, đội ngũ của chúng tôi đã gặp phải một sự cố kinh điển trên production. Một kỹ sư backend đã đổi tên trường dữ liệu trả về từ userId thành accountId. Unit test vẫn vượt qua. Toàn bộ integration test đều xanh. Pipeline CI/CD deploy trơn tru không một gợn sóng.

Thế rồi Sentry bắt đầu cảnh báo dồn dập. Chỉ trong chưa đầy 20 phút, 140 lượt thanh toán thất bại với lỗi TypeError: Cannot read properties of undefined. Các component phía client vẫn đang chờ đợi trường response.userId.

Cả frontend Next.js lẫn backend Node.js của chúng tôi đều dùng TypeScript. Vậy mà lỗi runtime vẫn lọt qua. Viết TypeScript ở cả hai đầu không đồng nghĩa với việc ranh giới mạng (network boundary) của bạn an toàn về kiểu dữ liệu nếu thiếu đi các giải pháp như kiểm thử hợp đồng (contract testing).

Phân tích nguyên nhân gốc rễ: Vì sao Full-Stack TypeScript thất bại tại ranh giới kết nối

Phần lớn các nhóm lập trình thu hẹp khoảng cách giữa client và server bằng cách sao chép schema thủ công hoặc dùng các công cụ generator dễ bị lệch pha. Quy trình làm việc thường ngày diễn ra như sau:

  • Kỹ sư backend xây dựng các endpoint và export các DTO phản hồi.
  • Kỹ sư frontend sao chép thủ công các interface đó qua, hoặc lấy về từ file đặc tả OpenAPI mà ai đó đã quên tạo lại trước khi merge PR.
  • Cả fetch() nguyên bản lẫn axios đều không thể xác thực payload nhận về tại thời điểm compile. TypeScript đơn giản là tin tưởng tuyệt đối vào thao tác ép kiểu as UserProfile của bạn.

Cảm giác an toàn giả tạo đó khiến nhiều team phải trả giá đắt. Khi ép kiểu dữ liệu trả về, bạn đã gỡ bỏ hoàn toàn tấm lưới an toàn của compiler. Ngay cả khi code nằm chung trong một repository Next.js, client và server vẫn vận hành như hai kẻ xa lạ nói chuyện không cùng ngôn ngữ.

So sánh các giải pháp: REST, GraphQL và tRPC

Các kiến trúc hiện đại xử lý giao tiếp full-stack như thế nào? Dưới đây là bức tranh so sánh giữa ba lựa chọn phổ biến nhất.

1. REST kết hợp OpenAPI Codegen

Khi xây dựng REST API, bạn viết endpoint, ghi chép tài liệu trong một file YAML dài 600 dòng, rồi chạy các CLI script để tự động sinh SDK cho frontend.

  • Ưu điểm: Tiêu chuẩn phổ quát; không phụ thuộc ngôn ngữ; kiến trúc tách biệt (decoupled).
  • Nhược điểm: Bảo trì mệt mỏi. Cập nhật một trường trên server, và type ở frontend sẽ bị lỗi thời cho đến khi bạn chạy lại lệnh generate. Tốc độ phản hồi rất chậm chạp.

2. GraphQL kết hợp Apollo hoặc Relay

GraphQL giải quyết vấn đề over-fetching thông qua ngôn ngữ định nghĩa schema (SDL) chặt chẽ và các truy vấn do client điều khiển.

  • Ưu điểm: Truy vấn linh hoạt; tích hợp sẵn khả năng introspection; đã được kiểm chứng thực tế trên các biểu đồ dữ liệu khổng lồ qua nhiều nền tảng client khác nhau.
  • Nhược điểm: Phải viết quá nhiều mã chuẩn bị (boilerplate). Bạn cần schema, resolver, client query và các công cụ lúc build như graphql-codegen. Nếu hệ thống của bạn hoàn toàn dùng TypeScript từ đầu đến cuối, đây là một gánh nặng không cần thiết.

3. tRPC (TypeScript Remote Procedure Call)

tRPC loại bỏ hoàn toàn các file schema và các bước build trung gian. Thay vào đó, frontend của bạn suy luận trực tiếp kiểu dữ liệu từ router của backend.

  • Ưu điểm: Không cần sinh mã nguồn (zero code generation). Gợi ý code (auto-completion) tức thì. Báo lỗi compiler ngay khi signature của route backend thay đổi.
  • Nhược điểm: Phụ thuộc chặt chẽ vào TypeScript ở cả client và server. Không phù hợp cho các public API hoặc API bên thứ ba.

Thực hành: Triển khai tRPC với Next.js App Router

Kết hợp tRPC với Zod trong Next.js sẽ tạo nên một tấm lưới bảo vệ tức thì. Đổi tên một trường ở backend, dự án frontend của bạn sẽ báo lỗi compile ngay trên máy local trước khi bạn kịp đẩy lên môi trường staging.

Bước 1: Cài đặt các dependency

Thêm các package cần thiết vào dự án Next.js của bạn:

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

Bước 2: Khởi tạo Context và Router

Tạo một điểm khởi đầu (ví dụ: src/server/trpc.ts) để khởi tạo instance và export các procedure builder có thể tái sử dụng:

import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

export const router = t.router;
export const publicProcedure = t.procedure;

Bước 3: Định nghĩa Procedure và Input Schema

Xây dựng API router trong file src/server/routers/app.ts. Zod đảm nhiệm việc validate runtime cho dữ liệu đầu vào, trong khi TypeScript tự động suy luận kiểu dữ liệu trả về:

import { z } from 'zod';
import { router, publicProcedure } from '../trpc';

export const appRouter = router({
  getUser: publicProcedure
    .input(
      z.object({
        id: z.string().uuid(),
      })
    )
    .query(async ({ input }) => {
      // Giả lập truy vấn database
      return {
        id: input.id,
        name: 'Alex Rivera',
        role: 'lead-engineer',
        activeProjects: 4,
      };
    }),
});

// Chỉ export định nghĩa type, tuyệt đối không export mã nguồn triển khai
export type AppRouter = typeof appRouter;

Việc export type AppRouter = typeof appRouter chính là chìa khóa then chốt. Next.js sẽ loại bỏ hoàn toàn code server, logic database và các biến bí mật khỏi bundle gửi về client. Chỉ có các chữ ký type siêu nhẹ được truyền tải.

Bước 4: Thiết lập Route Handler

Trong Next.js App Router, gắn fetch adapter tại src/app/api/trpc/[trpc]/route.ts:

import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/app';

const handler = (req: Request) => 
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => ({}),
  });

export { handler as GET, handler as POST };

Bước 5: Truy vấn dữ liệu từ Client

Kết nối helper React client và thực hiện truy vấn bên trong một component:

'use client';

import { trpc } from '@/utils/trpc';

export function UserProfileCard({ userId }: { userId: string }) {
  const { data, isLoading, error } = trpc.getUser.useQuery({ id: userId });

  if (isLoading) return <p>Đang tải hồ sơ...</p>;
  if (error) return <p>Lỗi tải thông tin người dùng: {error.message}</p>;
  if (!data) return null;

  return (
    <div className="card">
      <h3>{data.name}</h3>
      <p>Vai trò: {data.role}</p>
      <span>Dự án đang tham gia: {data.activeProjects}</span>
    </div>
  );
}

Thử đổi tên activeProjects thành projectCount ở backend. Ngay lập tức, VS Code sẽ gạch chân đỏ ngay dưới data.activeProjects. Bạn phát hiện lỗi hồi quy chỉ trong vài mili-giây—không cần chạy test hay phải họp kiểm điểm sự cố sau khi deploy.

Kinh nghiệm thực chiến cho môi trường Production

  • Chia nhỏ router từ sớm: Tổ chức các route theo từng domain (ví dụ: authRouter, billingRouter) rồi gộp lại vào một root router duy nhất thông qua t.mergeRouters().
  • Tập trung hóa middleware: Xâu chuỗi các middleware tái sử dụng để xử lý xác thực (authentication) và giới hạn tần suất truy cập (rate limiting) thay vì rải rác các bước kiểm tra ở từng query riêng lẻ.
  • Làm sạch input với Zod: Sử dụng .transform() để ép kiểu query string trên URL và chuỗi ngày tháng chuẩn ISO một cách gọn gàng trước khi dữ liệu đi vào logic xử lý.
Share: