T3 Stack实战:tRPC让前后端类型共享,告别接口文档割裂
1. 为什么我给项目起名t3code——我对前后端类型割裂的耐心已经用完先说说这次实践的起因。我手上有个内部协作工具要从零重写功能谈不上复杂无非是任务清单、成员备注、状态流转这些接口但最大的麻烦在于这类工具通常会有二三十个琐碎接口每个接口的字段、枚举、嵌套结构都不同前端调起来简直是在开盲盒。以往我总是陷入同一个循环——后端写一个接口把返回结构口述或者写到注释里前端照着调等到字段一改、枚举一变、某次重构把字段从一个对象挪到另一个对象前端代码静默接受一切直到运行期间某个属性出现undefined才有人意识到这里好像对不上了。后来我在技术社区反复看到T3 Stack这个词当时第一反应是又一个组合拳框架因为前端圈每隔一阵就会冒出一个新组合。但深入了解后发现它真正戳中我的点在于类型不再是前后端各自维护的一套相似但不同的定义而是从数据库到后端路由再到前端hooks全程共用的同一份类型。T3 Stack说白了就是一条技术线的组合Next.js作为全栈框架载体TypeScript贯穿始终Tailwind CSS负责样式tRPC负责让前后端接口像本地函数调用一样类型安全。很多人说T3就是TypeScriptTailwindtRPC三个首字母再多一个Next.js当底座。本着实践出真知的思路我把重写项目当作试验田代号就叫t3code。整个过程做下来最大的感受不是新工具真酷而是原来前后端联调可以这么安静——没有打开Network面板核对字段的步骤没有这个字段到底是后端拼的还是前端拼的的争论大多数类型错误在编辑器里就直接标红了。这篇不是官方案例复刻我会把从脚手架搭建、数据模型设计、tRPC路由编写到生产部署踩坑的完整链路走一遍重点写那些动手必踩、文档不写的点。如果你正在用Next.js写全栈应用或者早就烦透了维护接口文档这篇应该能帮你少走弯路如果你还没接触过T3 Stack我尽量把每个概念背后的为什么也讲透。2. 脚手架选择背后的逻辑为什么我用create-t3-app而不是自由拼装2.1 create-t3-app到底帮你选了哪些东西搭建项目的时候我直接用官方脚手架一条命令就够了npm create t3-applatest交互式界面会让你选择包管理器、是否开启TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth等模块。为什么不自力更生自己拼因为T3 Stack这几个工具之间的接线部分才是最麻烦的——tRPC要和Next.js的App Router集成Prisma要挂到tRPC的context上NextAuth的session要同步进tRPC的中间件这些装配代码散落在各种文档里自己拼很容易缺胳膊少腿。脚手架真正的价值不是省几个命令而是它把这些工具之间的glue code全部生成好了。我第一次跑完脚手架后直接打开生成的项目目录看到src/server/api/trpc.ts、src/server/db.ts、src/trpc/server.ts这些文件里已经写好了初始化逻辑立刻明白了为什么社区推荐用它起步——这些文件就是整套架构的骨架后面所有业务代码都长在这些骨架上。2.2 项目结构里藏着的关键文件填完交互式选项后生成的目录大致长这样src/ app/ api/trpc/[trpc]/route.ts layout.tsx page.tsx server/ api/ root.ts routers/ trpc.ts db.ts trpc/ server.ts react.ts prisma/ schema.prisma .env这里每个文件都有明确分工。src/server/db.ts负责初始化Prisma客户端src/server/api/trpc.ts创建tRPC的router实例和公共proceduresrc/server/api/root.ts把各个业务router汇总成appRoutersrc/app/api/trpc/[trpc]/route.ts把tRPC挂到Next.js的API路由上src/trpc/react.ts封装给前端用的hookssrc/trpc/server.ts提供服务端调用tRPC的方法。我在实际操作前一度困惑tRPC不是RPC吗它到底走不走HTTP答案是走但它只需要一个通配路由来接收所有tRPC请求——Next.js这边只用那个[trpc]/route.ts文件就够了。这其实是不少人第一次搞懂tRPC时容易卡住的点你不需要给每个业务接口单独写一个API routetRPC会把所有procedure的调用统一收敛到一个入口按路径分发到对应的router和procedure上。这个设计让它比起每个接口一个handler的传统REST写法少了一大堆样板代码。2.3 我对App Router和Pages Router的取舍create-t3-app在生成时会让选择使用哪种路由模式我选的是App Router。如果你现在刚开始新项目我也建议直接选App Router。原因主要有三个。第一App Router是Next.js当前主推的方向新功能例如Server Component、Server Actions等都是围绕它设计的选它意味着很长一段时间内不需要考虑迁移。第二tRPC在App Router下的集成方式很干净服务端调用可以在Server Component里直接await api.task.list()不必像Pages Router那样刻意区分ssr: false。第三App Router的layout机制对全站布局、权限控制、主题样式管理都更自然尤其是嵌套布局在对接NextAuth的session传递时心智负担小很多。当然Pages Router也不是不能用如果你维护的是老项目就没必要为了tRPC强行改造路由模式。我的建议是新项目上App Router老项目继续用Pages RoutertRPC对两种模式都有官方适配。2.4 脚手架生成后我有几件事是立刻动手改的第一件事是填.env里的数据库连接串。脚手架默认给的是postgresql://postgres:passwordlocalhost:5432/t3code这种占位符不改成真实连接串的话后面所有涉及数据库的命令都会报错。第二件事是确认Prisma的provider和数据库类型一致。我在schema.prisma里用的PostgreSQL然后在.env里把DATABASE_URL填成对应的Postgres连接串。这一步看着简单但很容易出现开发环境用SQLite、生产环境用Postgres导致的迁移文件混乱。第三件事是跑一次数据库同步再跑一次开发服务器验证整条链路是通的npx prisma db push npm run devdb push会把当前schema直接推给数据库适合开发早期等schema稳定后建议换成正式的migrate dev生成迁移历史。跑到这里如果能打开页面看到默认的Next.js首页且没有报tRPC相关的connection错误说明整套脚手架已经盘活了。3. tRPC的核心机制为什么一次定义、两端共享能做到3.1 从REST到RPC的思路转变说到tRPC绕不开那个灵魂拷问它和传统REST到底有什么本质区别REST的思维模式是围绕资源设计URL一个资源配一组GET/POST/PUT/DELETE操作前端通过URL语义去猜测后端能力。tRPC的思维模式则更接近远程函数调用——你把后端要暴露的能力写成一个个函数前端像调用本地函数一样直接调用。这个转变带来的直接好处是不需要再发明一套URL规范。获取任务列表就是task.list创建任务就是task.create路径就是函数名的集合参数就是函数的入参返回就是函数返回值的类型。这套RPC模型在内部工具、管理后台这类前后端完全由同一团队维护的项目里极其高效因为根本不需要对外暴露稳定的API契约内部接口可以随时改函数名、调参数结构类型检查器会立刻告诉你哪些调用点要跟着改。3.2 router和procedure的写法人话版tRPC里最核心的两个概念是router和procedure。router就是一组相关procedure的集合可以类比成一个控制器procedure就是一个个具体的接口实现分两类query用于读数据mutation用于写数据。我写的任务router长这样// src/server/api/routers/task.ts import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const taskRouter createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) { return ctx.db.task.findMany({ orderBy: { createdAt: desc }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1).max(200), priority: z.enum([low, medium, high]).default(medium), }) ) .mutation(async ({ ctx, input }) { return ctx.db.task.create({ data: input }); }), toggleDone: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const task await ctx.db.task.findUnique({ where: { id: input.id } }); if (!task) { throw new Error(task not found); } return ctx.db.task.update({ where: { id: input.id }, data: { done: !task.done }, }); }), });这段代码里ctx.db是挂在tRPC context上的Prisma客户端实例。这个context在src/server/api/trpc.ts里初始化创建tRPC实例时会传入一个包含数据库连接和session信息的上下文对象于是每个procedure都能通过ctx安全地拿到数据库操作能力。3.3 zod输入校验为什么不是可选加分项注意create这个procedure里的.input(...)括号里不是普通ts类型而是zod schema。这是tRPC最大的一个细节客户端请求进来时tRPC会先用zod对输入参数做运行时校验校验通过才会进入procedure的业务逻辑。很多刚接触的人会问我已经有TypeScript了为什么还需要zod因为TypeScript的类型只在编译期存在编译完就没了运行时收到的参数到底是什么ts完全管不到。而zod是在运行时真正执行的校验器保证了进入函数体的一定是符合预期的数据。这个设计直接让信任边界变得清晰procedure的入参在函数体内不需要再写一遍if (!title || title.length 0)这种防御代码因为zod已经挡在门口了。我在后续写业务时发现这个习惯会反向塑造代码风格——越来越倾向于把入参的约束、默认值、枚举值全写进zod schema函数体反而变得特别干净。3.4 类型到底是怎么流动的tRPC的类型共享原理是全文最值得理解的部分。服务端定义好appRouter后客户端通过TypeScript的类型推断机制拿到它的结构不需要手动导出任何接口类型。拿我的项目举例根router定义在src/server/api/root.tsimport { taskRouter } from ~/server/api/routers/task; import { createTRPCRouter } from ~/server/api/trpc; export const appRouter createTRPCRouter({ task: taskRouter, }); export type AppRouter typeof appRouter;客户端那边建立hooks时把AppRouter当作泛型参数传入于是前端所有api.task.list、api.task.create这些调用的入参和返回值类型就全部自动推断了。整个过程没有导出interface发布npm包共享类型手写d.ts这些额外动作完全是靠TypeScript的结构类型系统把服务端router的结构照搬到前端。这种共享的粒度很有讲究我在实际操作中验证过如果后端把某个procedure的返回值从{ id: string; title: string }改成{ id: string; title: string; priority: string }前端对应的.useQuery()调用处会立刻在编辑器里报类型错误提示返回值结构变了。这意味着接口变更从运行期问题提前到了编译期问题联调效率自然就上来了。4. 从Prisma Schema到React页面一个任务管理模块的完整落地4.1 数据模型怎么定义第2章的router代码里ctx.db.task已经暗示了数据模型的存在。我用Prisma定义了一个非常典型的任务实体// prisma/schema.prisma generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model Task { id String id default(cuid()) title String priority Priority default(MEDIUM) done Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt } enum Priority { LOW MEDIUM HIGH }这个模型的设计逻辑很直接id用cuid而不是自增id是为了避免暴露业务量信息title必填priority用枚举而不是字符串是为了在数据层就杜绝脏值done带默认值减少业务代码的赋值遗漏createdAt和updatedAt自动维护。定义好后执行迁移npx prisma migrate dev --name initmigrate dev除了把表建到数据库还会自动执行prisma generate生成Prisma Client于是ctx.db.task就有了完整的类型提示。这一步是后面所有类型安全的基础因为数据库字段的拼写错误都会在这一层先暴露出来。4.2 路由挂载与根router联动写完taskRouter后要把挂回根router前端才能通过api.task访问到它。这一步我在根router文件里加上一行就完了export const appRouter createTRPCRouter({ task: taskRouter, });也就是说前端访问路径是api.task.list对应的是appRouter下的task子router里的listprocedure。命名空间的组织在这套架构里天然支持嵌套业务模块多了以后可以按域划分api.user.profile、api.task.comment等层次感很清晰。4.3 React Query的整合页面里的调用方式落地到页面时tRPC和React Query是深度绑定的。生成的src/trpc/react.ts本质上就是tanstack/react-query加tRPC的封装。页面里调用方式如下use client; import { api } from ~/trpc/react; export function TaskList() { const utils api.useUtils(); const { data: tasks, isLoading } api.task.list.useQuery(); const createTask api.task.create.useMutation({ onSuccess: () utils.task.list.invalidate(), }); const toggleDone api.task.toggleDone.useMutation({ onSuccess: () utils.task.list.invalidate(), }); if (isLoading) return div加载中.../div; return ( ul {tasks?.map((task) ( li key{task.id} span{task.title}/span button onClick{() toggleDone.mutate({ id: task.id })} {task.done ? 已完成 : 标记完成} /button /li ))} /ul ); }这里最关键的三个API值得仔细理解。api.task.list.useQuery()返回{ data, isLoading }数据就是服务端listprocedure的返回值类型完全一致。api.task.create.useMutation()返回一个mutate函数调用时传的对象必须符合zod校验的输入结构。useUtils()拿到的utils.task.list.invalidate()是缓存失效函数它的作用是当创建或更新操作成功后让React Query认为任务列表缓存过期自动重新拉取列表数据。这个模式覆盖了我90%的数据刷新需求省去了手动管理状态同步的麻烦。4.4 全链路类型追踪的一个完整实例为了直观看到类型是怎么一路贯穿的我把整条链路的代码串在一起看一遍。服务端create的输入类型来自zod.input(z.object({ title: z.string().min(1).max(200), priority: z.enum([low, medium, high]).default(medium), }))前端调用时createTask.mutate的参数类型会被推断成{ title: string; priority?: low | medium | high }注意三点字段名、可选性、枚举值完全对应priority自动被识别为可选因为zod给了默认值title被识别为必选。如果我试图传一个{ tiitle: xxx }过去错误在编辑器中立即出现根本没机会跑到运行期。返回值的类型也是同样的逻辑。api.task.list.useQuery()拿到Task[]数组数组元素的字段就是Prisma模型里定义的那些。如果某天在Prisma里把priority从Priority枚举改成纯字符串所有页面里枚举相关的判断逻辑都会在编译期标红提醒你处理兼容。这种连贯的类型体验就是t3code这个项目整套架构的核心回报。它意味着你改模型时编译器帮你划定了所有受影响的范围。5. 生产环境必踩的坑鉴权、部署、性能都是要自己做主的环节5.1 用NextAuth给tRPC路由加一道闸开发环境里把CRUD跑通只是开始生产环境第一件事就是鉴权。我的做法是用NextAuth配合tRPC的middleware机制实现未登录不能操作。脚手架自带的src/server/api/trpc.ts里已经有一个publicProcedure它没有任何权限校验。我在它旁边又定义了一个protectedProcedureimport { TRPCError } from trpc/server; import { createTRPCContext } from ~/server/api/trpc; const enforceUserIsLoggedIn tRPC.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session } }); }); export const protectedProcedure tRPC.procedure.use(enforceUserIsLoggedIn);然后在需要保护的router里把publicProcedure换成protectedProcedure即可。这里有个容易踩的坑需要专门说写中间件时必须返回next({ ctx })如果你在中间件里直接返回一个正常对象而不调用next下游procedure根本不会执行如果直接返回next()而不合并context下游就收不到session。搞清了这两个差异tRPC的鉴权链路就通了大半。5.2 部署到Vercel时我踩过的构建顺序问题本地一切正常推到Vercel上就翻车是T3 Stack新手最容易遇到的状况。我在第一次部署时遇到两个错误都值得记录。第一个错误是构建阶段找不到Prisma Client。根因是prisma generate在构建流程里没有执行。Vercel部署时会在构建阶段跑一遍依赖安装和项目构建但如果不显式执行prisma generatePrisma Client的代码就不会生成任何用到ctx.db的代码都会因模块缺失而编译失败。解决方案是在package.json里加一个postinstall脚本scripts: { postinstall: prisma generate }这样每次安装依赖后都会自动生成Prisma Client构建就有了保障。第二个错误是数据库结构没有同步。本地开发用migrate dev随时改schema但Vercel部署不会自动执行迁移。生产环境的schema变更需要手动跑prisma migrate deploy这个命令会只执行未应用的迁移文件比直接db push安全得多。正确的流程是本地开发用migrate dev写迁移代码合并前把迁移文件一起提交部署时在构建或发布阶段单独跑prisma migrate deploy把结构同步到生产数据库。还要提醒的是DATABASE_URL这类环境变量必须配置在Vercel的环境变量面板里而且要用生产数据库的地址别把本地Postgres的连接串粘上去了。5.3 React Query缓存与SSR的边界怎么把握在页面组件里我用的是纯客户端调用的useQuery这意味着数据会在浏览器端请求并缓存。对于管理后台、内部工具这类实时性要求不高但操作频发的场景这种模式没问题甚至在体验上更好——切换页面回来不用重新发请求。但如果你做的是公开的、对首屏渲染有要求的页面就需要考虑SSR或者Server Component方案。tRPC的服务端调用方式也很直接在Server Component里可以直接await api.task.list()数据在服务端获取并渲染成HTML返回。我实际写下来发现一个判断标准数据是否跟当前用户的session强相关。敏感数据走服务端渲染更安全普通列表数据用客户端请求缓存会更灵活。有几个常见的性能误配置我也在这里一并说了。不要在客户端useQuery里去改staleTime为超大值后又在同样页面用服务端渲染两套数据源会打架不要把所有列表请求都开缓存而不做invalidate改完数据列表不刷新会让人以为接口挂了不要在每次render时创建新的mutation对象React Query内部的稳定化处理会让它正常但如果你在useEffect里手动触发mutate要去读loading状态而不是手动管理isFetching。5.4 我踩过的三个典型错误汇总第一个是tRPC context里的session类型问题。NextAuth的session类型默认是普普通通的Session但你自定义了session.user.role这种字段时TypeScript不会直接认账。解决方式是在项目里声明一个类型增强文件把session的类型补进去。第二个是App Router tRPC Server Component的传参问题。在Server Component里调用tRPC后直接把这个数据作为props传给Client Component没有问题但如果你试图在Client Component里再次await api.task.list()是不行的——Client Component不支持async函数。这个限制不是tRPC的问题是Next.js对Client Component的约束。我一开始没搞懂写出来让还在这条路上摸索的人少花点时间。第三个是zod schema在input里用了date类型时的坑。zod的z.date()在客户端序列化到服务端时日期会被转成字符串还是保持Date对象不同配置下结果不同。我后来统一在输入层用z.string().datetime()收字符串在procedure内部再转成new Date()绕开序列化边界的不确定性。6. 什么时候我会主动放弃这套方案讲了这么多好处必须说点反话。t3code这套组合不是万能模板在我做项目决策时有三类场景我会果断放弃它。第一类是纯展示型网站或落地页。没有用户交互、没有数据持久化、没有内部权限只是几个静态页面的话上Next.jsT3 Stack纯粹是给简单任务增加复杂度。这种场景一个ViteTailwind就能解决连服务端都不需要。第二类是前后端团队完全分离的组织结构。tRPC最大的优势在于前后端共享类型但前提是前后端代码在同一个仓库、同一次部署中。如果前端团队和后端团队各自维护独立代码库后端已经有一套对外REST API或GraphQL服务那前端硬接tRPC反而会破坏边界这时候老老实实走OpenAPI规范生成客户端类型才是正解。tRPC适合一个人或一个小团队既写后端又写前端的场景它的边界是代码仓库边界不是团队边界。第三类是接口需要开放给第三方使用。tRPC的接口契约是TypeScript类型而外部系统不一定用TypeScript也不一定愿意接入你的router结构。公开API还是用REST或GraphQL这种语言无关的规范更合适。所以我现在的判断标准很简单项目是内部闭环、全栈同仓、类型敏感度高的用t3code这套流项目是开放生态、多语言、强契约的回到传统API方案流。最后分享一个我实际用下来的经验。如果真的想用T3 Stack跑一个正式项目不要从零开始理解所有概念再动手最好是直接拿create-t3-app生成一个项目照着默认结构把一个最小的CRUD跑通再逐步加鉴权、加业务、加部署。等你把第一个模块完整落地再回头看类型是怎么流动的这个问题会比读十篇文章都清楚。我在t3code项目里踩过的坑基本都写在上面了希望你走得比我顺。