tRPC – Type-safe API không cần schema cho NextJS full-stack

 

tRPC cho phép gọi server function từ client với full type safety — không cần viết schema, không cần code generation. Nếu bạn làm full-stack TypeScript với NextJS, tRPC thay thế REST hoàn toàn trong internal API.

1. tRPC hoạt động như thế nào?

Server định nghĩa procedure:
  getUser(id: string) → Promise<User>

Client gọi như gọi function thật:
  const user = await trpc.getUser.query('123')
  // TypeScript biết user có type User!

Không cần: Swagger, axios types, API docs — IDE tự hiểu.

2. Cài đặt (NextJS App Router)

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

3. Server — Định nghĩa router

// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import superjson from 'superjson';
import { ZodError } from 'zod';

// Context — truyền vào mọi procedure
export async function createContext() {
  const session = await getServerSession(authOptions);
  return { session };
}
export type Context = Awaited<ReturnType<typeof createContext>>;

const t = initTRPC.context<Context>().create({
  transformer: superjson,  // Hỗ trợ Date, Map, Set trong JSON
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

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

// Middleware kiểm tra auth
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { ...ctx, user: ctx.session.user } });
});

export const protectedProcedure = t.procedure.use(isAuthed);
// src/server/routers/users.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';

export const usersRouter = router({
  // Query — đọc data
  getAll: publicProcedure
    .input(z.object({
      page: z.number().default(1),
      limit: z.number().default(10),
      search: z.string().optional(),
    }))
    .query(async ({ input }) => {
      const { page, limit, search } = input;
      const users = await db.user.findMany({
        where: search ? { name: { contains: search } } : undefined,
        skip: (page - 1) * limit,
        take: limit,
      });
      const total = await db.user.count();
      return { users, total, page, limit };
    }),

  getById: publicProcedure
    .input(z.string().uuid())
    .query(async ({ input: id }) => {
      const user = await db.user.findUnique({ where: { id } });
      if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: 'User không tồn tại' });
      return user;
    }),

  // Mutation — thay đổi data
  create: publicProcedure
    .input(z.object({
      name: z.string().min(2),
      email: z.string().email(),
      password: z.string().min(6),
    }))
    .mutation(async ({ input }) => {
      const hashedPw = await bcrypt.hash(input.password, 10);
      return db.user.create({
        data: { ...input, password: hashedPw },
      });
    }),

  update: protectedProcedure
    .input(z.object({
      id: z.string().uuid(),
      name: z.string().min(2).optional(),
      avatar: z.string().url().optional(),
    }))
    .mutation(async ({ input, ctx }) => {
      if (input.id !== ctx.user.id) {
        throw new TRPCError({ code: 'FORBIDDEN' });
      }
      const { id, ...data } = input;
      return db.user.update({ where: { id }, data });
    }),

  delete: protectedProcedure
    .input(z.string().uuid())
    .mutation(async ({ input: id, ctx }) => {
      await db.user.delete({ where: { id } });
      return { success: true };
    }),
});
// src/server/routers/posts.ts
export const postsRouter = router({
  getAll: publicProcedure
    .input(z.object({ authorId: z.string().optional() }))
    .query(async ({ input }) => {
      return db.post.findMany({
        where: input.authorId ? { authorId: input.authorId } : undefined,
        include: { author: { select: { id: true, name: true, avatar: true } } },
        orderBy: { createdAt: 'desc' },
      });
    }),

  create: protectedProcedure
    .input(z.object({
      title: z.string().min(1).max(200),
      content: z.string().min(10),
    }))
    .mutation(async ({ input, ctx }) => {
      return db.post.create({
        data: { ...input, authorId: ctx.user.id },
      });
    }),
});

// src/server/root.ts
import { router } from './trpc';
import { usersRouter } from './routers/users';
import { postsRouter } from './routers/posts';

export const appRouter = router({
  users: usersRouter,
  posts: postsRouter,
});

export type AppRouter = typeof appRouter;

4. NextJS API Handler

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/root';
import { createContext } from '@/server/trpc';

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

export { handler as GET, handler as POST };

5. Client Setup

// src/trpc/client.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/root';

export const trpc = createTRPCReact<AppRouter>();

// src/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import { useState } from 'react';
import { trpc } from '@/trpc/client';

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
          transformer: superjson,
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}

6. Dùng trong Client Components

'use client';
import { trpc } from '@/trpc/client';

export function UsersList() {
  // Giống React Query — có loading, error, data
  const { data, isLoading, error } = trpc.users.getAll.useQuery({
    page: 1,
    limit: 10,
    search: 'nguyen',
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {data?.users.map(user => (
        <li key={user.id}>{user.name}{user.email}</li>
      ))}
    </ul>
  );
}

export function CreateUserForm() {
  const createUser = trpc.users.create.useMutation({
    onSuccess: () => {
      // Invalidate cache
      trpc.useUtils().users.getAll.invalidate();
    },
  });

  async function handleSubmit(data: FormData) {
    await createUser.mutateAsync({
      name: data.get('name') as string,
      email: data.get('email') as string,
      password: data.get('password') as string,
    });
  }

  return (
    <form action={handleSubmit}>
      <input name="name" placeholder="Tên" />
      <input name="email" type="email" placeholder="Email" />
      <input name="password" type="password" placeholder="Mật khẩu" />
      <button type="submit" disabled={createUser.isPending}>
        {createUser.isPending ? 'Đang tạo...' : 'Tạo user'}
      </button>
    </form>
  );
}

7. Server-side Caller

// app/users/page.tsx — Server Component
import { createCaller } from '@/server/root';
import { createContext } from '@/server/trpc';

export default async function UsersPage() {
  // Gọi trực tiếp không qua HTTP — nhanh hơn
  const caller = createCaller(await createContext());
  const { users, total } = await caller.users.getAll({ page: 1, limit: 20 });

  return (
    <div>
      <h1>Users ({total})</h1>
      {users.map(u => <div key={u.id}>{u.name}</div>)}
    </div>
  );
}

8. Kết luận

  • End-to-end type safety: Đổi tên field ở server → IDE báo lỗi ngay ở client
  • Zod validation: Input validation tự động, error message rõ ràng
  • Batching: Nhiều queries gộp thành 1 HTTP request
  • Server Caller: Gọi procedure từ Server Component không qua network
  • Giới hạn: Chỉ dùng được trong TypeScript full-stack — không phù hợp public API cho bên ngoài

tRPC là lựa chọn tốt nhất cho NextJS full-stack — nhanh hơn REST, type-safe hơn GraphQL, ít boilerplate nhất.