T3 Stack实战:基于Next.js与tRPC的全栈类型安全工程模板
最近我把一个新项目的仓库代号定成了 t3code名字看着像某个神秘的 CI 编码其实就是一套基于 T3 Stack 的 Next.js 全栈工程模板。所谓 T3 Stack核心是 TypeScript Tailwind CSS tRPC再按需接入 Prisma 和 NextAuth。t3code 想解决的问题很直接前后端不再各维护一套接口模型代码从数据库到按钮全程类型拉通。这篇文章不是官方文档的复述而是我从初始化、写业务、部署到踩坑的真实记录。如果你正准备用 Next.js 做全栈应用或者想了解 tRPC 和 Prisma 在成熟工程里怎么配合这篇应该对你有用。1. 项目定位t3code 解决的是全栈协作中的三类痛点1.1 类型断裂前后端各写一套模型的成本以前做前后端分离最磨人的不是写功能而是维护接口的“双份真相”。前端要自己定义 User、Order 这些接口后端也要维护一套 DTO中间靠 Swagger 文档对缝。改一个字段名后端改了、前端没跟上联调时才爆出来然后开始一轮“到底谁先改”的拉锯。t3code 的思路是把类型变成单一事实来源。它的数据层用 Prisma 定义模型服务层用 tRPC 暴露路由前端的 React Query hooks 直接从服务端路由推断类型。举个例子用户列表这个功能传统做法是写api/users.ts、手动声明返回类型、再封装 axios 请求t3code 里只需要在 tRPC router 里写一个getAll的 query前端调用api.user.getAll.useQuery()返回的data就已经是完整的User[]类型。这种“类型自动延伸”带来的实际好处是很多低级错误在编辑器里就被拦住了。后端把completed改成isDone前端用到completed的地方立刻标红不用等运行时报错。我个人的体会是类型安全不是银弹但在 CRUD 占大头的业务系统里它能省掉大量联调和回归成本。1.2 从样板代码堆里抽身REST 与 GraphQL 的对比既然要做全栈为什么不继续用 REST 或者 GraphQL这是一个很自然的疑问。REST 的样板代码其实不少URL 定义、Controller、DTO、请求参数校验、Response 包装前后端还要约定错误码格式。给内部项目用这层协议成本有点偏重。GraphQL 解决了一部分问题它引入 SDL、resolver、codegen还有缓存策略团队需要额外学习一整套生态。如果需求简单这些反而是负担。t3code 里的 tRPC 走的是另一条路不定义 URL不写 controller直接让客户端调用服务端“函数”。它没有独立的 schema 文件类型全部从 TypeScript 推导。维度RESTGraphQLtRPC类型安全靠手写或 OpenAPI 生成SDL 和 resolver 分离需要 codegen服务端推导自动同步服务端代码Controller Service DTOResolver SchemaRouter Procedure客户端调用axios/fetch 手动封装Apollo/urql query 字符串React Query 风格的 hook学习成本低中高低接近调用本地函数适用场景开放 API、多端接入多端聚合、字段选择复杂同仓库全栈、内部系统注意t3code 选 tRPC 不等于否定 REST。如果未来要开放 API 给第三方我建议在 tRPC 外面再包一层网关或者直接提供 OpenAPI 导出。它定位的是“前后端同仓库、同团队维护”的 Web 应用而不是对外公共 API 平台。1.3 t3code 的边界适合什么不适合什么任何一个技术方案都有适用边界。用 t3code 的思路做项目我觉得最适合这几类前后端同仓库甚至同一个人负责全部功能的小项目。以数据增删改查为主需要快速交付的管理后台、内部工具。团队已经使用 React/Next.js不想额外部署一个 BFF 服务。对类型安全有追求希望把一部分沟通成本交给编译器。相反如果遇到这些情况我反而不建议硬套需要面向第三方开放公共 API调用方可能是 Java、Python、小程序等非 TypeScript 环境。团队已经有成熟的 Java/Go 后端前端只是做展示层强行上 tRPC 等于把后端逻辑搬到 Node成本很高。需要大量异步消息、事件驱动、复杂微服务编排的场景tRPC 更适合同步请求-响应模式。团队对 TypeScript 本身不熟加了类型反而拖慢开发速度那先用简单的 REST 也没问题。看清边界之后t3code 就不再是“万能脚手架”而是一个在特定场景下效率极高的工程起点。2. T3 技术栈选型拆解为什么每一层都押注类型安全2.1 Next.js App Router 与 React Server Componentscreate-t3-app 现在已经默认走 Next.js App Router这个选择对项目结构影响很大。App Router 把页面拆成服务端组件和客户端组件服务端组件可以直接读数据库、拿 session不需要暴露接口客户端组件处理用户交互通过 tRPC hooks 获取数据。这里有一个新手最容易卡住的点tRPC 的useQuery、useMutation是客户端 hooks不能在服务端组件里直接用。如果在一个没有加use client的组件里调用Next.js 会直接报错。正确做法是页面默认服务端把需要交互的部分拆成独立的客户端组件比如TaskList、TaskForm页面只负责布局和传入初始数据。App Router 的另一个好处是数据预取。首次进入页面时你可以在服务端用createCaller直接调用 tRPC router把结果传给客户端组件或者直接用客户端组件里的useQuery配合 React Query 做一次初始请求。两种方式各有利弊前者首屏更快、能配合 SEO后者实现简单适合管理后台这类不需要 SEO 的应用。t3code 两种都支持这也是我推荐它在内部工具里用的原因。2.2 tRPC v11 的运作原理router、procedure 与 callertRPC 的核心概念不多router 是路由集合procedure 是一段可执行的函数caller 是服务端内部调用入口。初始化代码在src/server/api/trpc.ts里核心是拿到initTRPC实例import { initTRPC, TRPCError } from trpc/server; import superjson from superjson; import { ZodError } from zod; import type { createTRPCContext } from ./trpc; type Context AwaitedReturnTypetypeof createTRPCContext; const t initTRPC.contextContext().create({ transformer: superjson, errorFormatter({ shape, error }) { if (error.cause instanceof ZodError) { return { ...shape, data: { ...shape.data, zodError: error.cause.flatten() } }; } return shape; }, }); export const createTRPCRouter t.router; export const publicProcedure t.procedure; const enforceUserIsAuthed t.middleware(({ ctx, next }) { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure t.procedure.use(enforceUserIsAuthed);这里有两个容易忽略的细节。第一initTRPC.contextT()的泛型必须和createTRPCContext返回的类型完全一致否则后面所有 procedure 的ctx类型都会对不上。第二superjson负责序列化Date、Map这些复杂类型没有它Prisma 返回的Date字段会变成字符串前端new Date()还得自己处理。router 注册一般在src/server/api/root.ts里做import { taskRouter } from ./routers/task; import { createTRPCRouter } from ./trpc; export const appRouter createTRPCRouter({ task: taskRouter, }); export type AppRouter typeof appRouter;再通过 Next.js 的 route handler 暴露成 HTTP 接口import { fetchRequestHandler } from trpc/server/adapters/fetch; import { appRouter } from ~/server/api/root; import { createTRPCContext } from ~/server/api/trpc; const handler (req: Request) fetchRequestHandler({ endpoint: /api/trpc, req, router: appRouter, createContext: createTRPCContext, }); export { handler as GET, handler as POST };只要记住这份结构tRPC 的运作方式就清楚了浏览器请求/api/trpc/task.getAll服务端命中 router执行 procedure返回序列化数据。前端则通过api.task.getAll.useQuery()拿到带类型的响应。2.3 Prisma Schema 作为数据层类型源头t3code 里数据层的起点不是表结构 SQL而是prisma/schema.prisma。我建议先设计好 model再跑 migration。这样表结构、TypeScript 类型、关系约束都从同一个文件生成。以“任务管理”为例model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] tasks Task[] } model Task { id String id default(cuid()) title String completed Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) }选用cuid()而不是自增 int主要是避免暴露业务量同时方便分布式生成。生产环境如果用 PostgreSQL你也可以改成uuid()。关键是pnpm prisma migrate dev之后prisma/client会自动生成完整类型ctx.db.task.findMany()的返回结果就直接带上了Task[]类型。配合 zod 做输入校验是 t3code 的一个惯例。tRPC 的.input()接收 zod schema前端调用时入参类型就从 zod schema 推导出来运行时会做校验编译期也能看到字段提示。比如import { z } from zod; const createTaskSchema z.object({ title: z.string().min(1).max(100), dueAt: z.date().optional(), });这样“运行时校验”和“静态类型”来自同一份定义不会出现改了类型忘了改校验、或者改了校验忘了改类型的问题。2.4 样式方案Tailwind CSS 与组件库的配合Tailwind 不是 T3 Stack 的必选项但 t3code 默认带上它是因为它和组件化的 React 应用配合起来摩擦很小。给元素写类名不纠结语义化命名需求变更时直接改类不需要维护一堆 CSS 文件。更重要的是Tailwind 的apply和组件封装能很好地处理复用配合 shadcn/ui 这类复制型组件库你可以把组件源码直接放进项目里按需改而不是依赖一个厚重的 UI 包。用 CSS-in-JS 的方案也能做但 tRPC 全栈场景下模板希望能尽量少引入运行时开销Tailwind 在 SSR 下几乎是零成本。实际体验下来做管理后台、数据看板这类页面Tailwind 的效率优势还是很明显尤其是改间距、颜色、响应式断点的时候。3. 手把手初始化 t3code从命令到首个功能跑通3.1 环境准备与初始化命令环境准备没有太多意外Node.js 版本建议 18.18 以上最好直接用 20 LTS包管理器推荐 pnpm磁盘占用和安装速度都更友好。初始化命令很简单pnpm create t3-applatest t3code交互式界面会让你选择启用哪些模块。第一次建议全选Next.js App Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth.js、ESLint、Prettier。后面不用的可以再删但一开始少选了后面想加就得手动接比较麻烦。初始化完的目录结构大致是t3code/ ├─ app/ ├─ components/ ├─ server/ │ ├─ api/ │ ├─ auth.ts │ └─ db.ts ├─ prisma/ ├─ styles/ ├─ env.mjs ├─ trpc/ └─ package.json注意env.mjs它会在启动时校验环境变量。DATABASE_URL、NEXTAUTH_SECRET这些缺失时应用会直接拒绝启动避免带着错误配置跑上生产。3.2 数据表设计示例以“任务管理”为例写业务之前我习惯先把 Prisma model 定好。用任务管理做例子除了前面提到的 User 和 Task还要考虑索引和默认值。给Task的userId加索引避免按人查任务时全表扫描model Task { id String id default(cuid()) title String completed Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) index([userId]) }然后执行迁移pnpm prisma migrate dev --name init_task迁移成功后会生成 Prisma Client。本地开发可以用 SQLite也就是DATABASE_URLfile:./dev.db。但我个人建议如果你确定生产用 PostgreSQL最好本地也用 PostgreSQL用 Docker 起一个实例。原因后面会在坑点部分细说。3.3 用 tRPC 写第一个 CRUD 路由现在开始写业务路由放在src/server/api/routers/task.tsimport { z } from zod; import { TRPCError } from trpc/server; import { createTRPCRouter, protectedProcedure } from ../trpc; export const taskRouter createTRPCRouter({ getAll: protectedProcedure.query(async ({ ctx }) { return ctx.db.task.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: { title: input.title, userId: ctx.session.user.id }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const task await ctx.db.task.findUniqueOrThrow({ where: { id: input.id }, }); if (task.userId ! ctx.session.user.id) { throw new TRPCError({ code: FORBIDDEN }); } return ctx.db.task.update({ where: { id: input.id }, data: { completed: !task.completed }, }); }), });这段代码里有三个值得注意的实践。第一所有需要登录的接口都用protectedProceduresession 里没有用户直接抛UNAUTHORIZED。第二toggle里做了行级权限判断用户不能通过伪造 id 去操作别人的任务。第三输入统一走 zod前端少传一个字段在编辑器里就会被发现。写完再去root.ts注册一下export const appRouter createTRPCRouter({ task: taskRouter, });3.4 页面接入客户端组件与服务端预取前端接入在客户端组件里进行核心就是api对象。它由src/trpc/react.tsx里的 Provider 包裹类型从AppRouter自动推断。use client; import { api } from ~/trpc/react; export function TaskList() { const { data: tasks, isLoading, refetch } api.task.getAll.useQuery(); const utils api.useUtils(); const createTask api.task.create.useMutation({ onSuccess: async () { await utils.task.getAll.invalidate(); }, }); if (isLoading) return div加载中.../div; return ( div button onClick{() createTask.mutate({ title: 新任务 })} 新建 /button ul {tasks?.map((task) ( li key{task.id}{task.title}/li ))} /ul /div ); }mutation 成功后我推荐用invalidate而不是手动refetch。invalidate会让 React Query 标记相关查询为过期再自动重新请求这样更符合缓存意图。你可能会问为什么不能用onSuccess: refetch也能但invalidate更适合多个组件共享同一份数据的情况避免每个 mutation 都手动通知。服务端组件里要拿数据则需要用createCallerimport { createCaller } from ~/server/api/root; const caller createCaller({ session: null, db: prisma }); const tasks await caller.task.getAll();但要注意task.getAll是protectedProcedure如果 session 为 null它会在服务端抛错。所以公共服务端的页面通常只调用publicProcedure或者在校验通过后再走保护路由。3.5 本地联调与构建验证开发时我一般开两个终端一个跑pnpm dev一个跑pnpm prisma studio看数据。提交代码前跑一遍pnpm type-check确认没有类型错误最后再pnpm build做完整构建。Next.js 在构建阶段会执行类型检查所以很多 tRPC 类型签名错误在 build 时也会暴露出来不用等到线上。这里有个小建议tRPC 的类型问题不像运行时错误那样有堆栈它更多表现为“编辑器里标红”。如果你刚改了某个 procedure 的返回结构结果前端组件里到处飘红先别急着改代码回到pnpm type-check的输出里看最顶层的错误通常那才是根因。逐个文件修是最慢的方式。4. t3code 实战中的高频故障与排查速查4.1 “Property ‘session’ does not exist”与 ctx 类型串联项目初始化后最常见的报错出现在trpc.ts里某个 procedure 里用ctx.session.user.idTypeScript 却提示session不存在。原因基本是initTRPC.contextContext()的泛型类型和createTRPCContext返回值不对称。排查方法是先看createTRPCContext的定义export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerSession(authOptions); const db prisma; return { db, session, headers: opts.headers }; };然后确认trpc.ts里的 Context 类型是它的 ReturnTypetype Context AwaitedReturnTypetypeof createTRPCContext; const t initTRPC.contextContext().create({ ... });还要检查 NextAuth 的类型扩展。默认session.user只有name、email、image没有id。如果你在protectedProcedure里想用ctx.session.user.id需要在types/next-auth.d.ts里扩展import { DefaultSession } from next-auth; declare module next-auth { interface Session { user: { id: string; } DefaultSession[user]; } }这一坑的根源不是 tRPC 本身而是 NextAuth 默认类型太保守。只要记住“session 类型必须手动扩展”这一点能省很多时间。4.2 Prisma 在 Serverless 环境的连接数问题本地开发时SQLite 或单机 PostgreSQL 都很顺。一旦部署到 Vercel 这类 Serverless 平台每个函数实例都可能持有数据库连接连接数会快速增长最终报Cant reach database server或Too many connections。解决思路有两条。第一条是接外部连接池比如 Prisma Accelerate 或者 Neon、PgBouncer。第二条是限制连接数在创建 PrismaClient 时设置连接池参数import { PrismaClient } from prisma/client; const prisma new PrismaClient({ log: process.env.NODE_ENV development ? [query, error] : [error], });更直接的建议本地和生产都用 PostgreSQL同时开启事务模式连接池。SQLite 只适合纯单机工具如果项目的生产环境是 PostgreSQL就不要在本地用 SQLite 省事。因为两者在 schema 方言、事务行为上有差异迁移时很容易出现“本地迁移成功、生产迁移失败”的尴尬问题。4.3 类型不更新dev server 缓存与 Prisma Client 陈旧刚改完schema.prisma发现ctx.db.user还是没有新字段大概率是 Prisma Client 没有重新生成。执行pnpm prisma generate如果改的是某个 tRPC procedure 的输入或返回类型前端 hooks 的类型却迟迟不变最简单的办法是重启pnpm dev。Next.js 开发服务器对类型缓存有时不够敏感删掉.next目录再启动基本能解决。也就是rm -rf .next pnpm dev这个“重启大法”看起来土但在 t3code 这类类型驱动项目里非常管用。类型系统跨了 Prisma、tRPC、React Query 三层只要某一层没刷新反馈就会滞后。4.4 NextAuth 回调 URL 与部署环境变量登录功能本地正常部署到服务器后一登录就 500多半是环境变量问题。NextAuth v4 要关注NEXTAUTH_URL和NEXTAUTH_SECRETv5 有的版本改成AUTH_URL和AUTH_SECRET。这组变量必须和环境对应变量本地开发生产部署URLhttp://localhost:3000https://你的真实域名SECRET任意随机字符串保持与本地一致的随机字符串DATABASE_URLlocalhost 连接串生产数据库连接串很多人本地用默认 secret部署时没设置或者设置了不同的 secret结果 JWT 验签失败回跳地址也对不上。我的经验是一开始就把 secret 放到.env.local生成一个长随机值然后同步到部署平台的环境变量里。不要在代码里写死也不要多个环境共用同一个生产 secret除非你清楚自己在做什么。5. 从项目模板走向工程体系t3code 还能怎么延展5.1 拆分共享包Monorepo 还是单仓库t3code 默认是单仓库这对小团队和快速验证非常省心。但当业务规模变大后台管理、用户端、定时任务都要复用同一套类型和数据库客户端时就可以考虑演进到 pnpm workspace 的 Monorepo 结构apps/ ├─ web/ packages/ ├─ db/ ├─ api/ ├─ ui/tRPC 在 Monorepo 里需要注意两点。第一appRouter类型要用import type引入到前端避免把服务端代码打进客户端 bundle。第二Next.js 需要配置transpilePackages来编译 workspace 内的本地包。Prisma Client 最好统一放在packages/db里生成其他包都引用它避免每个应用各自生成一份。5.2 缓存、性能与可观测性t3code 默认用 React Query 作为请求缓存层但staleTime默认是 0意味着每次组件挂载都可能重新请求。对变化不频繁的数据可以按需调大api.task.getAll.useQuery(undefined, { staleTime: 60_000, });另一种做法是加日志中间件统计每个 procedure 的耗时方便定位慢查询const loggingMiddleware t.middleware(async ({ path, type, next }) { const start Date.now(); const result await next(); console.log(${type} ${path} ${Date.now() - start}ms); return result; });要注意日志里别打 input 的完整内容尤其是包含用户信息、密码字段的输入。打 path 和耗时已经足够。如果要系统性的监控可以接入 Sentry 或 OpenTelemetry把 tRPC 的调用链串起来。实践下来这条中间件对排查“哪个接口慢”非常有效。5.3 我留下的三条实践原则与个人体会t3code 用了几个月我最大的感受是它不是帮你“少写代码”而是帮你“少写容易烂的代码”。我给自己定了三条原则分享出来供你参考。第一条任何客户端输入都必须过 zod。前端组件里的校验只是体验问题服务端 procedure 里的 zod 校验才是安全边界。不要信任任何前端条件判断。第二条行级数据权限必须在服务端 procedure 里判断。前端可以隐藏删除按钮但真正防住越权的是服务端那句if (task.userId ! ctx.session.user.id) throw ...。第三条先别急着引入一堆 UI 组件库。先把数据链路走通、类型拉通再考虑按钮好不好看因为模板给你的灵活性越强过早优化反而容易把项目复杂度抬起来。如果你也准备拿 t3code 这套思路起步我建议从最简链路开始一个 Next.js 页面、一个 Prisma model、一个 tRPC router让类型先把路守住再一层层把功能加厚。等你跑顺了再回头调整目录结构、接监控、拆 Monorepo那都是水到渠成的事。