T3 Stack全栈类型安全实战:tRPC+Prisma+Next.js开发指南
1. 项目缘起与整体设计思路1.1 t3code是什么一个围绕T3 Stack落地的全栈类型安全项目t3code这个名字第一眼容易让人疑惑——T3指的是什么code肯定离不开写代码但T3到底是什么这里我得先把背景交代清楚。T3 Stack是一套由TypeScript、Next.js、tRPC、Tailwind CSS、Prisma以及可选的NextAuth.js组合而成的全栈Web开发技术栈。它的核心理念就是让“类型安全”贯穿整个开发链路从数据库的数据模型到后端API的输入输出再到前端页面的数据消费所有类型都是同一个源头由TypeScript编译器帮你兜底。我最初接触T3 Stack是因为一个实际痛点传统的前后端分离项目里后端REST接口写好了前端要重新定义一遍TypeScript接口参数结构稍微变一下前端经常要翻半天才知道哪里坏了。更离谱的是接口文档和代码脱节后端改了字段忘记同步文档前端收到一个不符合预期的响应只能打开浏览器控制台一个一个对字段。t3code这个项目本质上是我把T3 Stack完整跑通一遍后沉淀下来的经验总结从数据库模型设计、API层搭建、前端页面渲染到部署时的常见坑全部走了一遍。它解决的核心问题就是“前后端类型割裂”以及由此带来的联调效率低下。这个内容适合谁来参考一种是刚接触全栈开发、想找一个现代实践方案的中级开发者另一种是已经用REST模式写了好几年想看看tRPC这条路到底靠不靠谱的人。我可以先给你一个结论如果你只做短平快的内部工具或者独立开发一个中小型产品T3 Stack这套组合能帮你把CRUD开发时间压缩一半以上而且类型错误在编译期就被拦住不再是运行时的黑色报错。1.2 为什么选T3这套组合而不是自己拼一套技术栈很多人第一个问题就是Next.js、Prisma、tRPC我都听过但为什么要绑在一起我自己拼一套不行吗当然可以但你自己拼面临的是版本兼容、上下文传递、类型定义手工维护这一大堆琐碎问题。T3 Stack默认把这套集成方案已经内置好了。它不是一个像Laravel那样的大一统框架而是一套约定告诉你每个工具负责什么环节、数据流怎么走。具体拆开看Next.js负责前端页面渲染、路由以及前后端代码放在同一个仓库里跑的便利性。你可以在app目录里写页面组件也可以在src/server/api里写后端逻辑。Prisma管数据库访问层它的schema.prisma文件是数据模型的唯一真相源。你改了这个文件运行迁移命令之后TypeScript类型会自动更新。tRPC是T3 Stack的灵魂。它让你在Next.js的API层定义一个个procedure前端直接像调用本地函数一样调用这些procedure不需要REST路径、不需要手写请求封装、不需要手写响应类型。Tailwind CSS负责样式它不参与数据流但它的原子类设计让UI层不需要额外维护组件样式文件开发速度提升很明显。NextAuth.js现在叫Auth.js负责认证它把登录会话和tRPC的context打通了你做“只能看到自己数据”这类功能时会省很多事。这套组合的核心逻辑是“少即是多”。T3 Stack的设计哲学决定了它只保留你真正需要的东西你能用TypeScript解决的事情绝不引入额外的运行时依赖。举个例子tRPC的输入校验在开发模式走zod验证在类型层面你不需要手动声明请求参数的类型zod schema本身就是类型这也是t3code这个项目名字里“code”部分的意义——代码即契约类型即文档。2. t3code的核心架构与关键技术拆解2.1 端到端类型共享的完整数据链路t3code项目里最值得琢磨的一条线是类型从数据库一路流到前端组件的完整链路。这不像传统的REST API那样前端拿到响应后还要定义一个interface去声明“这个响应长什么样”。在T3 Stack里类型源头只有一个Prisma Schema。我建议在动手前先记住这条链路schema.prisma数据库模型 ↓ 类型生成 Prisma ClientNode层访问数据库 ↓ 传给 tRPC procedure输入用zod校验输出依赖Prisma类型 ↓ 类型推断 react-query hooks前端调用tRPC的useQuery ↓ 类型传递 React组件里的props/state如果链路中间任何一环修改了数据类型TypeScript编译器会立刻告诉你哪个组件在传参时出了错。比如数据库里把用户名字段从name改成了username你在Prisma模型里改完运行npx prisma generate然后编译项目所有用到name的组件全部报错。这种“改一处全项目受检”的体验在传统模式下需要额外配合swagger-codegen之类的代码生成工具才能勉强做到而且生成的代码版本管理还是个大麻烦。T3 Stack把这一整套流程收敛到了编译器和IDE提示里省心非常多。在实际落地时我特别推荐一个习惯先把schema.prisma设计得足够贴合业务然后再去写页面。因为Prisma模型一旦确定后面所有的API逻辑都是围绕这套模型展开的。数据库设计考虑得越周全tRPC路由写起来就越顺。2.2 tRPC的请求生命周期函数调用为什么能替代REST很多人听到tRPC会疑惑前端直接“调用函数”网络请求到底怎么走其实底层的HTTP传输并没消失tRPC只是帮你把“生产请求、解析请求、封装响应、校验参数、推断类型”这些原本手工写的代码全部自动化了。在t3code项目里一个标准操作是这样的// src/server/api/routers/post.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; export const postRouter createTRPCRouter({ list: publicProcedure .input( z.object({ limit: z.number().min(1).max(100).default(20), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) { const items await ctx.db.post.findMany({ take: input.limit, skip: input.cursor ? 1 : 0, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: desc }, }); return items; }), });前端调用时是这样的const { data, isPending } api.post.list.useQuery({ limit: 10 });注意useQuery里面的参数类型和返回的data类型都是编译时确定的。如果你在调用时传了多余的字段或者少传了必填字段IDE会直接报错。这种体验平时用REST API时根本不可能有。再拆解一下tRPC背后的生命周期它可以分为五个阶段前端调用useQuery时tRPC会生成一个以procedure路径为标识的HTTP请求比如/api/trpc/post.list。Next.js的API路由收到请求tRPC根据路径找到对应router。请求先经过input中的zod schema校验参数不合格直接返回客户端错误。在query函数内部ctx携带数据库实例和session信息供你执行数据访问。响应返回后tRPC客户端根据路由类型推断出返回值类型前端拿到带有完整类型的data。有人会问为什么不用GraphQLGraphQL也能做到类型安全但它的schema定义、resolver拆分、codegen成本比较高。tRPC更适合“服务端和客户端同在一个代码仓库”的场景你不需要定义一套独立的schema语言TypeScript本身的类型就是schema。这也是t3code能快速落地的关键原因。2.3 Prisma Schema设计数据模型的真相源t3code项目的数据模型我建议按业务实体来划分。举一个实际例子一个带评论功能的博客系统包含用户User、文章Post、评论Comment三个核心实体它们之间的关联关系用Prisma模型表示如下// prisma/schema.prisma generator client { provider prisma-client-js } datasource db { provider sqlite // 开发环境先用SQLite生产切换PostgreSQL url env(DATABASE_URL) } model User { id String id default(cuid()) name String? email String unique posts Post[] comments Comment[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String published Boolean default(false) author User relation(fields: [authorId], references: [id]) authorId String comments Comment[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Comment { id String id default(cuid()) body String post Post relation(fields: [postId], references: [id]) postId String author User relation(fields: [authorId], references: [id]) authorId String createdAt DateTime default(now()) }这里我用了cuid()而不是自增整数主键原因有几个公开API中整数ID容易枚举遍历cuid更难猜测分布式或多人协作时不需要等待数据库自增tRPC把ID作为字符串传参时不会有类型不匹配问题。设计模型时我有两个建议其一尽早把唯一性约束写清楚比如email unique到了生产环境再补唯一索引迁移成本和风险都会高很多其二DateTime字段务必加上default(now())否则创建记录时漏传字段会导致运行时错误。模型改完后运行npx prisma migrate dev --name init npx prisma generate第一次迁移会创建数据库表并生成Prisma Client类型。之后每次修改模型重复这两个命令即可。有一个细节建议特别注意prisma generate生成的类型文件是tRPC返回值类型推断的依据所以改完schema后一定要重新生成否则编译期拿到的还是旧类型。3. 实操落地从零搭建一个可运行的t3code项目3.1 初始化脚手架与目录结构解读T3 Stack官方维护了一个脚手架create-t3-app建议从它开始因为内置了这些能力的初始配置TypeScript严格模式、Next.js App Router、tRPC router目录、Prisma数据库连接、Tailwind样式体系。自己手搓这些配置至少需要折腾大半天而脚手架只要一个命令npx create-t3-applatest t3code项目初始化时交互式命令行会问你开启哪些可选模块。我的建议是首次体验全开尤其是NextAuth.js和Drizzle这类的依赖先装上再逐个理解。如果你已经确定不用数据库可以不勾Prisma。但t3code既然是围绕类型安全的全栈项目数据库访问层是必要的别省。初始化完成后标准目录长这样prisma/ schema.prisma src/ app/ api/ trpc/ [trpc]/route.ts layout.tsx page.tsx server/ api/ routers/ post.ts root.ts db.ts auth.ts trpc/ server.ts client.ts react.tsx初次看到这些目录的人容易慌其实关键就两处src/server/api/routers/放你的tRPC业务逻辑src/trpc/放服务端和客户端共享的类型定义与tRPC实例。启动开发服务器npm run dev看到Next.js默认页后先别急着写代码进入src/server/db.ts确认数据库地址已经正确配置。脚手架默认读DATABASE_URL这个环境变量如果你用SQLite.env里应该长这样DATABASE_URLfile:./db.sqlite3.2 数据模型迁移的完整过程我以带评论的博客功能为例先创建几组tRPC路由。第一步定义文章相关路由// src/server/api/routers/post.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; export const postRouter createTRPCRouter({ listPublished: publicProcedure .input( z.object({ limit: z.number().min(1).max(50).default(10), }) ) .query(async ({ ctx, input }) { return ctx.db.post.findMany({ where: { published: true }, orderBy: { createdAt: desc }, take: input.limit, include: { author: { select: { name: true } } }, }); }), getById: publicProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ ctx, input }) { return ctx.db.post.findUnique({ where: { id: input.id }, include: { comments: { include: { author: true } } }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(120), content: z.string().min(1), published: z.boolean().default(false), }) ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, published: input.published, authorId: ctx.session.user.id, }, }); }), });第二步把路由挂到根router上// 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;注意export type AppRouter这一行它是tRPC前端类型推断的核心。前端trpc客户端实例就依赖这个类型来确定所有procedure的入参和出参类型。只要后端router变化前端代码的TypeScript提示立刻跟着变。第三步运行迁移npx prisma migrate dev --name add-post-comment此时数据库已经生成对应的表。你可以打开prisma/studio看一下数据模型npx prisma studioStudio会启动一个本地可视化管理界面用来检查数据是否正确写入排查时很好用。3.3 前端页面调用tRPC并渲染数据前端调用逻辑放在Next.js的Server Component或者Client Component里都有。以Client Component为例use client; import { api } from ~/trpc/react; export function PostList() { const { data, isLoading, error } api.post.listPublished.useQuery({ limit: 10, }); if (isLoading) return div加载中.../div; if (error) return div请求失败: {error.message}/div; return ( ul {data?.map((post) ( li key{post.id} h2{post.title}/h2 p{post.content}/p span作者: {post.author.name ?? 匿名}/span /li ))} /ul ); }我觉得这个模式最大的价值在于编辑代码时IDE的自动补全能显示出一个数据库查询返回的所有字段而且字段拼错会直接报ts-error。你不必记住后端到底返回了什么因为开发环境里整个类型链是透明的。从这个角度看t3code项目的“类型安全”不是一句空话而是实际开发中的默认状态。还有一个容易被忽略的地方在Server Component中如果你通过createCaller直接调用tRPC查询可以避免额外的HTTP往返import { createCaller } from ~/trpc/server; export default async function HomePage() { const caller createCaller(); const posts await caller.post.listPublished({ limit: 5 }); return ( main PostList initialData{posts} / /main ); }createCaller从服务端直接调用procedure函数绕过了HTTP层响应时间会更短并且仍然能得到完整的类型推断。理解这一点后你就知道T3 Stack并不强迫你所有的数据都走网络请求该直连的时候直连该走HTTP的时候走HTTP主动权在你自己手里。3.4 鉴权与受保护接口的落地前面post.create用了protectedProcedure它的含义是“必须登录才能调用”。T3 Stack默认集成NextAuth.js现在叫Auth.js它会把session塞进tRPC的context里。在src/server/api/trpc.ts里可以看到类似这样的代码export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: ctx.session } }); });所有挂在protectedProcedure下的接口只要用户未登录就会返回401错误前端useQuery会捕获到error.data.httpStatus为401。你可以据此在页面上跳转登录页。如果要做“用户只能修改自己的文章”这类权限控制在mutation内部判断即可update: protectedProcedure .input(z.object({ id: z.string().cuid(), title: z.string().optional() })) .mutation(async ({ ctx, input }) { const post await ctx.db.post.findUnique({ where: { id: input.id } }); if (!post || post.authorId ! ctx.session.user.id) { throw new TRPCError({ code: FORBIDDEN }); } return ctx.db.post.update({ where: { id: input.id }, data: { title: input.title }, }); }),这类授权逻辑写在tRPC服务端的最明显好处是前端无论如何绕道都不会绕过权限校验因为校验发生在靠近数据源的那一层。3.5 服务端渲染时如何避免类型与数据不同步在Server Component里直接调用createCaller有一个细节要注意如果procedure内部使用了ctx.headers比如做多语言判断Server Component环境里可能拿不到完全一致的header信息。脚手架给了一个headers注入的方式createCaller默认会从Next的headers()里读取。我在实际使用中遇到过开发模式一切正常、部署后偶发跨域读取异常的问题主要原因是不知道为什么在某个中间层请求头被改写。排查思路是先在src/server/api/trpc.ts里加一个临时的header打印然后分别对比浏览器直连和Server Component内部调用的结果。如果发现差异就改为显式传入关键header字段const caller createCaller({ headers: { x-user-locale: zh-CN } });这种显式传参方式能避免依赖隐式上下文排查起来也更清晰。4. 常见问题与排查技巧实录4.1 类型报错集中在哪几个坑t3code这类T3项目最常见的编译错误我总结下来有四个来源Prisma类型未重新生成。改了schema.prisma后没有运行npx prisma generate结果前端引用的字段在旧类型上不存在。解决办法就是先跑generate大多数情况下编译错误会消失。zod schema与Prisma类型不一致。比如input里声明了一个必填字符串但Prisma模型里字段允许为null。这种情况多发生在“手写schema时想得不够周全”。我的建议是输入校验严格一点内部处理灵活一点。你不想让前端传null就在zod里直接拒绝null。ctx.db类型为null或undefined。在初始化脚手架时createTRPCContext里面的db可能因为环境变量没配好而变成undefined导致后续所有使用ctx.db的地方报错。检查一下.env里的DATABASE_URL是否正确。在Server Component中错误引用客户端tRPC实例。比如在app/page.tsx这种Server Component里使用了~/trpc/react这是客户端专用模块应该改用~/trpc/server的createCaller。否则会报“组件无法在Server Component中使用客户端hook”之类的错误。4.2 tRPC请求404、无法访问服务器返回问题如果你看到浏览器请求/api/trpc/post.list返回404而不是正常的JSON响应通常是Next.js的API路由没有正确挂载。检查src/app/api/trpc/[trpc]/route.ts文件是否存在并且内容是否导出了GET和POST两个handler。还有一个容易被忽略的情况如果你在Next.js中间件middleware里做了路径拦截比如未登录时重定向到登录页可能会把/api/trpc的请求也拦截掉导致所有tRPC请求404或302。我给中间件加一个白名单export const config { matcher: [/((?!api/trpc|_next/static|_next/image|favicon.ico).*)], };这个方法能放行API路径又不影响页面级鉴权。4.3 Prisma迁移冲突与数据库状态不一致开发过程中经常要反复修改模型如果上一次迁移还没应用到某个环境再跑新迁移时可能报P3005之类的错误。不用慌先把本地的迁移回滚到一致状态。操作方式npx prisma migrate reset它会清空数据库重新应用所有迁移文件。这个命令只适合开发环境生产库千万不要用。如果你在团队协作中遇到迁移分支冲突建议约定每个人都基于最新的main分支拉代码跑完迁移再改模型减少迁移文件冲突的概率。4.4 tRPC在SSR模式下的性能与缓存问题在Server Component里使用createCaller每次渲染页面都可能执行数据库查询。如果某个查询非常重页面响应会变慢。我自己给t3code加了一层简单的缓存针对不常变更的列表在router内做数据缓存。其实Next.js本身提供了unstable_cache可以包住caller.xxx这个执行过程但要注意缓存键必须和入参保持一致性。不过要提醒一点不要把ctx整个对象缓存下来它包含session等请求级信息。像下面这种做法就比较容易踩坑。// 错误示范缓存了包含请求级信息的ctx const cached await unstable_cache( () caller.post.listPublished(input), // 缓存条件里没有考虑用户权限危险 )();更稳妥的做法是在procedure函数的实现内部做细粒度缓存或者用react-query的staleTime控制客户端缓存。对于刚上手T3 Stack的人来说先别过度设计缓存保持默认行为等确实有性能瓶颈再优化。4.5 排查思路速查表现象首要排查点推荐命令/操作前端调用后报404Next.js API路由是否挂载检查src/app/api/trpc/[trpc]/route.ts编译报类型缺失Prisma类型未更新npx prisma generate查询字段为undefined前端字段名与Prisma返回不一致在IDE中悬浮查看返回类型401错误使用了protectedProcedure但未登录在调用前确保session.user存在Server Component里用不了hook误用了客户端tRPC实例改用~/trpc/server的createCaller数据库迁移失败迁移历史冲突开发环境用migrate reset数据查询慢N1查询或缺少索引使用Prisma的include优化关联查询我建议把这些常见问题存在本地笔记里每次新项目遇到相同报错直接翻表格对照能节省很多时间。5. 后续扩展思路与我的个人体会t3code这个项目其实可以在很多方向继续扩展。最直接的一步是接入文件上传用tRPC定义一个mutation接收文件元数据然后把文件存到对象存储或者本地目录数据库里只保存访问路径。前端可以用react-dropzone之类的组件配合调用整个过程仍然能保持类型安全。另一个方向是加入订阅推送tRPC的subscription能力配合Server-Sent Events可以做实时评论、在线状态这类功能。需要说明的是tRPC的subscription在Next.js上的配置比query稍复杂你需要额外处理EventSource的连接生命周期。还有一个很实用的扩展是把t3code当作一个内部管理后台的基石。T3 Stack做后台系统非常顺手左边侧边栏导航、右边数据表格、顶部搜索筛选这些功能在Tailwind加tRPC的组合下开发速度比传统方案快一截。因为你不必花时间维护接口文档改完数据模型整个后台的字段提示都会同步更新。我个人的体会是T3 Stack特别适合这种“一个人或者小团队要快速把想法变成可用产品”的场景。它的学习曲线不是完全没有比如第一次接触tRPC时你会疑惑“这到底怎么通信的”但一旦跑通第一个query后面的开发会越写越顺。踩过几次坑之后你会越来越依赖IDE的类型提示而不是翻接口文档。最后再分享一个小技巧如果你在服务端直接用createCaller发现某个查询返回的数据格式和客户端调用时略有不同比如多了一层包装可以看看src/trpc/server.ts里的初始化代码确认是否传入了正确的headers。很多莫名其妙的SSR渲染差异追到最后都是请求上下文没传对。搞定了这点t3code这套流程基本就稳了。