Xây dựng Full-Stack API Type-Safe với tRPC và TypeScript trong Next.js — Không Cần Schema Riêng

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

Vấn Đề: API Đang Nói Dối Bạn

Bạn đổi tên một field ở backend. Migration database chạy ngon. Server khởi động không lỗi. Rồi hai ngày sau, một bug report rơi vào hộp thư — frontend bị crash vì vẫn đang dùng tên field cũ.

Đây không phải trường hợp hiếm gặp. Nó xảy ra thường xuyên trong các team duy trì REST hoặc GraphQL API riêng biệt song song với frontend TypeScript. Types tồn tại ở server. Types tồn tại ở client. Không có gì đảm bảo chúng luôn đồng bộ. Compiler tạo cho bạn cảm giác an toàn giả tạo.

Tôi đã đụng phải bức tường này trên một dự án Next.js production. Chúng tôi có endpoint /api/users trả về một user object, và tôi đang dùng nó ở frontend với các TypeScript interface viết tay. Một thay đổi backend lọt qua code review, chúng tôi deploy, và chỉ 15 phút sau người dùng thấy trang profile trắng xóa. Đó là lúc tôi bắt đầu tìm kiếm giải pháp tốt hơn.

Nguyên Nhân Gốc Rễ: API Contract Tồn Tại Ở Hai Nơi

Đây không phải là vấn đề về tooling — mà là vấn đề kiến trúc. Khi backend và frontend là hai hệ thống type riêng biệt với một network boundary ở giữa, bạn có hai nguồn sự thật cho cùng một contract. REST API mô tả dữ liệu qua tài liệu hoặc OpenAPI spec. GraphQL giải quyết một phần với schema, nhưng bạn vẫn cần code generation và một bước build trước khi client types có thể dùng được.

Mỗi cách tiếp cận đều có ma sát riêng:

  • OpenAPI yêu cầu duy trì file spec và chạy generator sau mỗi lần thay đổi route
  • GraphQL cần schema, pipeline codegen, và một client library riêng
  • Interface viết tay đòi hỏi kỷ luật và sự tin tưởng — cả hai đều không scale quá một team ba người

Thứ bạn thực sự muốn là một nguồn sự thật duy nhất mà cả hai phía của network đều chia sẻ — hoàn toàn không cần bước code generation nào.

So Sánh Các Giải Pháp

Lựa chọn 1: OpenAPI + Code Generation

Viết một OpenAPI YAML spec, chạy generator, nhận TypeScript types ở client. Cách này hoạt động được. Nhưng vấn đề là: spec đó là một artifact thứ ba có thể bị lệch so với implementation thực tế. Một developer cập nhật route handler, quên cập nhật spec, và bây giờ các type được generate của bạn sai trong khi compiler vẫn im lặng. Nếu bạn muốn tự động hóa bước này, tích hợp Swagger với Express là một hướng đi đáng tham khảo.

Lựa chọn 2: GraphQL với Apollo hoặc urql

GraphQL enforce schema tại runtime và codegen tạo ra các typed query. Với public API có nhiều external consumer, đây thực sự là lựa chọn đúng đắn. Nhưng bên trong một ứng dụng Next.js private mà bạn sở hữu cả hai đầu, chi phí tích lũy rất nhanh: schema definition language, resolver, cấu hình codegen, quản lý cache. Quá nhiều infrastructure cho một vấn đề không cần đến nó.

Lựa chọn 3: tRPC — Một Router, Hai Đầu

tRPC theo một hướng tiếp cận hoàn toàn khác. Định nghĩa API của bạn dưới dạng TypeScript router trên server. Client import type của router đó — không phải implementation — và nhận đầy đủ autocompletion cùng compile-time safety mà không cần code generation. Đổi tên một procedure hoặc thay đổi output shape, và lỗi TypeScript xuất hiện ngay lập tức trong mọi file gọi đến nó.

Điều này hoạt động được vì Next.js đã chạy cả server và client code trong cùng một monorepo. tRPC được xây dựng xoay quanh thực tế đó.

Thiết Lập tRPC trong Dự Án Next.js

Dưới đây là toàn bộ quá trình setup từ đầu. Tôi đã đưa cách này vào production ở hai ứng dụng — một với khoảng 30 procedure, một với gần 80. Trong cả hai trường hợp, không có bug API-contract nào lọt qua được môi trường local.

Bước 1: Cài Đặt Dependencies

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

Zod xử lý input validation và đóng vai trò là lớp schema cho các input của procedure. Viết validation một lần và nhận cả runtime safety lẫn TypeScript inference từ cùng một định nghĩa.

Bước 2: Khởi Tạo tRPC trên Server

Tạo file src/server/trpc.ts:

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

const t = initTRPC.create();

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

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

Tạo file src/server/routers/user.ts:

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

export const userRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      // Thay thế bằng câu truy vấn DB thực tế của bạn
      return {
        id: input.id,
        name: 'Alice',
        email: '[email protected]',
      };
    }),

  create: publicProcedure
    .input(
      z.object({
        name: z.string().min(1),
        email: z.string().email(),
      })
    )
    .mutation(async ({ input }) => {
      // Chèn vào DB
      return { id: 'new-id', ...input };
    }),
});

Sau đó lắp ráp root router trong src/server/routers/_app.ts:

import { router } from '../trpc';
import { userRouter } from './user';

export const appRouter = router({
  user: userRouter,
});

export type AppRouter = typeof appRouter;

Type AppRouter đó là thứ duy nhất vượt qua ranh giới server–client. Không có chi tiết implementation nào bị lộ ra.

Bước 4: Gắn API Handler vào Next.js

Tạo src/pages/api/trpc/[trpc].ts cho Pages Router, hoặc src/app/api/trpc/[trpc]/route.ts cho App Router:

// Phiên bản Pages Router
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';

export default createNextApiHandler({
  router: appRouter,
  createContext: () => ({}),
});

Bước 5: Cấu Hình Client

Tạo file src/utils/trpc.ts:

import { createTRPCNext } from '@trpc/next';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/routers/_app';

export const trpc = createTRPCNext<AppRouter>({
  config() {
    return {
      links: [
        httpBatchLink({
          url: '/api/trpc',
        }),
      ],
    };
  },
});

Bước 6: Bọc App của Bạn

Trong src/pages/_app.tsx:

import type { AppType } from 'next/app';
import { trpc } from '../utils/trpc';

const MyApp: AppType = ({ Component, pageProps }) => {
  return <Component {...pageProps} />;
};

export default trpc.withTRPC(MyApp);

Bước 7: Gọi Procedure từ Component

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

export default function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading } = trpc.user.getById.useQuery({ id: userId });

  if (isLoading) return <p>Đang tải...</p>;

  return (
    <div>
      <h1>{data?.name}</h1>
      <p>{data?.email}</p>
    </div>
  );
}

Bây giờ hãy đổi tên email thành emailAddress trong router. Mọi component đang tham chiếu data.email sẽ ngay lập tức hiển thị lỗi TypeScript — trước khi bạn chạy app, trước khi mở trình duyệt, trước khi bất kỳ người dùng nào thấy crash.

Những Gì Bạn Nhận Được Ngay Từ Đầu

  • Autocomplete khi gọi procedure — editor của bạn biết mọi route có sẵn và input mà nó cần
  • Lỗi compile-time khi sai cấu trúc dữ liệu — đổi tên một field ở server và tất cả nơi gọi đến sẽ báo lỗi lúc build, không phải lúc runtime
  • Tự động gộp request — nhiều lần gọi useQuery trong cùng một render cycle được tự động gộp thành một HTTP request duy nhất
  • Tích hợp React Query — caching, refetching và trạng thái loading được bao gồm sẵn qua @tanstack/react-query
  • Không cần code generation — không có file YAML, không có build script, không có folder được generate làm rối repo của bạn

Khi Nào tRPC Không Phải Công Cụ Phù Hợp

tRPC phát huy tác dụng tốt nhất khi backend và frontend chia sẻ cùng một TypeScript monorepo và một team sở hữu cả hai. Đây là lựa chọn không phù hợp cho public API được tiêu thụ bởi các client bên thứ ba, ứng dụng mobile viết bằng Swift hoặc Kotlin, hoặc khi backend và frontend repo được deploy theo lịch riêng biệt. Trong những trường hợp đó, OpenAPI hoặc GraphQL vẫn là lựa chọn thực tế hơn.

Nhưng với ứng dụng Next.js mà một team duy nhất sở hữu toàn bộ stack, tRPC loại bỏ hoàn toàn một loại bug mà không tăng thêm độ phức tạp đáng kể. Setup ban đầu mất khoảng 20 phút. Sau đó, compiler sẽ enforce contract — vĩnh viễn, tự động, không cần thêm bất kỳ quy trình nào.

Share: