A modern, type-safe fullstack starter and guide for building scalable apps with tRPC and TanStack Query in Next.js 15 App Router. Includes best practices, code samples, and step-by-step instructions for maintainable TypeScript projects.
Tài liệu tham khảo: tRPC Next.js Setup
pnpm add @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjsonTrong tsconfig.json:
src/
types/ # Định nghĩa các type/model dùng chung (Post, User, ...)
post.ts
user.ts
app/ # Next.js App Router
_trpc/ # Client: tRPC hooks, provider
client.ts
TRPCProvider.tsx
api/
trpc/[trpc]/route.ts # API handler cho tRPC
layout.tsx # Bọc app trong TRPCProvider
page.tsx # Sử dụng hooks tRPC
server/
api/
routers/
post.ts # Định nghĩa router cho resource (post, user...)
user.ts
root.ts # Tổng hợp các router nhỏ
trpc.ts # Khởi tạo tRPC, context, transformer
src/server/api/trpc.ts
- Định nghĩa context (có thể truyền db, session, headers...)
- Khởi tạo tRPC với transformer (superjson) và errorFormatter (Zod)
- Xuất các hàm tạo router, procedure
import { initTRPC } from '@trpc/server';
import superjson from 'superjson';
import { ZodError } from 'zod';
export const createContext = async (opts: { headers: Headers }) => ({ ...opts });
const t = initTRPC.context<typeof createContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;src/server/api/routers/post.ts
import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';
const fakePosts = [
{ id: 1, title: 'Chào mừng đến với tRPC', content: 'Đây là bài viết đầu tiên.' },
];
export const postRouter = createTRPCRouter({
getAll: publicProcedure.query(() => fakePosts),
create: publicProcedure
.input(z.object({ title: z.string().min(1), content: z.string().min(5) }))
.mutation(({ input }) => {
const newPost = { id: fakePosts.length + 1, ...input };
fakePosts.push(newPost);
return newPost;
}),
});src/server/api/root.ts
import { postRouter } from './routers/post';
import { createTRPCRouter } from './trpc';
export const appRouter = createTRPCRouter({
post: postRouter,
});
export type AppRouter = typeof appRouter;src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { type NextRequest } from 'next/server';
import { appRouter } from '@/server/api/root';
import { createContext } from '@/server/api/trpc';
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createContext({ headers: req.headers }),
});
export { handler as GET, handler as POST };src/app/_trpc/client.ts
import { createTRPCReact } from '@trpc/react-query';
import { type AppRouter } from '@/server/api/root';
export const api = createTRPCReact<AppRouter>({});src/app/_trpc/TRPCProvider.tsx
"use client";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import React, { useState } from 'react';
import superjson from 'superjson';
import { api } from './client';
export default function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({}));
const [trpcClient] = useState(() =>
api.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson,
}),
],
})
);
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</api.Provider>
);
}src/app/layout.tsx
import TRPCProvider from '@/app/_trpc/TRPCProvider';
// ...
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<TRPCProvider>
{children}
</TRPCProvider>
</body>
</html>
);
}src/app/page.tsx
"use client";
import { useState } from 'react';
import { api } from './_trpc/client';
export default function Home() {
const { data: posts, isLoading, isError, refetch } = api.post.getAll.useQuery();
const createPost = api.post.create.useMutation({ onSuccess: refetch });
// ...
}- Tham khảo thêm: tRPC docs
- Có thể thêm middleware, xác thực, custom context, ...
- Luôn bọc app trong
TRPCProviderở layout.tsx. - Đảm bảo các import sử dụng alias
@nếu đã cấu hình trongtsconfig.json. - Sử dụng Zod để validate input cho mutation.
- Có thể mở rộng router, context, middleware tuỳ ý.
Giả sử bạn muốn thêm API cho user (bên cạnh post), hãy làm theo các bước sau:
src/server/api/routers/user.ts
import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';
const fakeUsers = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
export const userRouter = createTRPCRouter({
getAll: publicProcedure.query(() => fakeUsers),
create: publicProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(({ input }) => {
const newUser = { id: fakeUsers.length + 1, ...input };
fakeUsers.push(newUser);
return newUser;
}),
});src/server/api/root.ts
import { postRouter } from './routers/post';
import { userRouter } from './routers/user'; // <-- thêm dòng này
import { createTRPCRouter } from './trpc';
export const appRouter = createTRPCRouter({
post: postRouter,
user: userRouter, // <-- thêm dòng này
});
export type AppRouter = typeof appRouter;src/app/page.tsx (hoặc component khác)
import { api } from './_trpc/client';
// Lấy danh sách user
const { data: users } = api.user.getAll.useQuery();
// Tạo user mới
const createUser = api.user.create.useMutation();Bạn có thể lặp lại các bước này để mở rộng cho bất kỳ resource nào (comment, product, ...).
Nếu bạn muốn dùng database thực tế như Firebase Firestore (hoặc bất kỳ DB nào), bạn sẽ thực hiện các thao tác truy vấn/thêm/sửa/xoá dữ liệu bên trong các procedure của router (ví dụ: trong file post.ts, user.ts, ...).
Cài đặt Firebase SDK:
pnpm add firebaseTạo file cấu hình Firestore (ví dụ: src/server/db/firestore.ts)
import { initializeApp, getApps } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
const firebaseConfig = {
apiKey: 'YOUR_API_KEY',
authDomain: 'YOUR_AUTH_DOMAIN',
projectId: 'YOUR_PROJECT_ID',
// ...
};
const app = getApps().length ? getApps()[0] : initializeApp(firebaseConfig);
export const db = getFirestore(app);Sử dụng Firestore trong router (ví dụ: src/server/api/routers/post.ts)
import { collection, getDocs, addDoc } from 'firebase/firestore';
import { db } from '../../db/firestore';
// ...
export const postRouter = createTRPCRouter({
getAll: publicProcedure.query(async () => {
const snapshot = await getDocs(collection(db, 'posts'));
return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
}),
create: publicProcedure
.input(z.object({ title: z.string().min(1), content: z.string().min(5) }))
.mutation(async ({ input }) => {
const docRef = await addDoc(collection(db, 'posts'), input);
return { id: docRef.id, ...input };
}),
});Lưu ý:
- Bạn có thể truyền các instance kết nối DB qua context nếu muốn dùng cho nhiều router.
- Với các DB khác (Prisma, MongoDB, ...), bạn cũng sẽ viết code thao tác DB ngay trong các procedure của router tương tự như trên.
Hầu hết dự án thực tế đều cần quản lý dữ liệu có cấu trúc (Post, User, ...). Để type-safe toàn dự án, bạn nên định nghĩa các type/model dùng chung ngay từ đầu.
Tạo thư mục src/types/ và định nghĩa các model:
src/types/post.ts
export type Post = {
id: number;
title: string;
content: string;
};src/types/user.ts
export type User = {
id: number;
name: string;
};Sau đó, bạn import các type này vào router, client, hoặc bất kỳ đâu cần type-safe.
MIT License. See LICENSE for details.