T3 Stack全栈实践:tRPC与Prisma打通端到端类型安全

📅 发布时间:2026/10/13 10:09:40
T3 Stack全栈实践:tRPC与Prisma打通端到端类型安全
最近在折腾全栈项目的时候我把研究重点放在了 t3code 这个代号下的整套技术组合上。如果你也关注过 Next.js 生态T3 Stack 这个名字多半不陌生它把 Next.js、TypeScript、tRPC、Prisma、Tailwind CSS、NextAuth 这六样东西打包成一套开箱即用的全栈方案。我最初以为它不过又是一个脚手架模板实际用下来发现这个技术栈最大的价值不是省掉配置时间而是从数据模型到页面组件类型安全能一路贯通到底彻底解决了我以前在前后端对接时反复对接口文档、手动维护类型的痛点。这篇文章不是官方文档的翻译而是我从初始化项目到上生产环境整个过程中踩过的坑、验证过的方案、以及拆开看过的每一条关键链路。适合那些准备用或正在用 T3 Stack 写真实项目的开发者也适合还没入坑、想搞清楚这一套东西到底值不值得学的朋友。1. T3 三原则与它真正想解决的全栈类型割裂问题1.1 为什么 T3 把类型安全放在不容商量的位置我在接触 T3 之前前后端分离的项目做了不少最头疼的永远是那个类型断层后端定义好接口返回结构前端为了拿到类型要么手写 interface要么靠 openapi 代码生成但一遇到接口改字段前端必然有一批类型过时然后运行时不报错、业务逻辑悄悄出错。T3 的三原则里第一条就是类型安全是底线不可妥协。这不是一句口号而是通过 tRPC 在协议层把类型直接变成了确定的约束让类型安全不依赖文档维护、不靠人肉同步。tRPC 的思路其实很朴实既然前端代码和后端代码最终都在同一个 monorepo 里为什么不能直接调用一个函数而是要去拼 URL、发请求、再手动解析响应它让前端像调用本地函数一样调用后端的 Procedure而 TypeScript 的类型推导在编译期就把两端的协议对齐了。你可以这样理解传统 REST API 像在不同城市之间寄包裹需要写清楚地址和物品清单tRPC 则像一个内部传送带包裹沿着管道直接送到对面物品清单就在代码里自动校验。1.2 T3 三原则中的保守优化与会扩展吗第二条原则保守优化的意思是默认不引入复杂抽象不追求所谓的高性能预热能用最简单的方式解决就不用重型方案。我把这套原则翻译成一句大白话先用最简单的方式把功能做出来等数据验证了瓶颈再上优化手段。第三条功能性优先其实问的是这个设计到底是为了面试和晒朋友圈还是为了上线后扛住真实流量这直接决定了技术选型时该不该引入消息队列、该不该拆微服务。我见过不少团队把 T3 误解为只要用了就高级结果在只有几千日活的时候引入了 kafka、redis 集群、k8s运维成本直接压垮交付速度。T3 的思路恰恰相反在 Next.js 的 serverless 环境里一个 Prisma 加 Postgres 就能跑得很稳等真到了需要异步处理的场景再渐进式引入队列系统也不迟。1.3 哪些项目适合以及不适合用 T3我试用三个月后对适用场景有了比较明确的判断。适合的典型场景包括需要快速交付的 Web 应用、内部后台系统、SaaS 产品的 MVP、以及小团队从零起步但希望保持代码质量的项目。T3 的高度集成能显著减少来回对类型的时间成本开发体验非常顺滑。不适合的场景也真实存在如果你的项目需要面向第三方开放 API或者有复杂的权限体系和多租户隔离或者团队里有后端同学坚持用 Java/Go 提供微服务那 T3 的端到端类型贯通优势就发挥不出来。毕竟类型安全这种东西是闭环内的红利一旦跳出闭环还是要走 OpenAPI 那套老路。提示选型时不要被技术栈酷炫迷惑先回答一个朴素问题你的前后端是不是真的由一个团队甚至一个人来写如果是T3 的收益是肉眼可见的。如果不是T3 的价值会大打折扣。2. 初始化脚手架create-t3-app 每个选项都不是白给的2.1 交互式选项逐项分析选与不选的标准创建一个 T3 项目非常简单一条命令就能跑pnpm create t3-applatest但脚手架里的每个交互式选择都值得停下来想清楚因为它们决定了项目之后的基本架构。我实际选过的组合和理由如下选项我选择的答案选择理由Next.js 版本App Router新项目默认走 App RouterServer Components 配合 tRPC 的 server caller 效果很好TypeScript是没有 TypeScript 的 T3 等于失去灵魂Tailwind CSS是用它做样式写页面速度确实快且不容易和组件库起冲突tRPC是不需要犹豫这正是 T3 的核心Prisma是数据库 ORM 里和 TypeScript 配合最顺的选项之一NextAuth.js是登录认证直接和 tRPC 中间件联动ESLint / Prettier是省得后面再补团队协作时能少很多无谓争吵Docker按需本地开发如果已经用 Docker Compose 跑 Postgres 就选是环境变量校验是防止 DATABASE_URL 写错导致线上启动失败如果你只是先跑通一下不想被这些选项搞乱直接全部选是也不会出问题。脚手架默认会生成一套可以立刻运行的最小代码。2.2 初始化后的目录结构每个关键文件的职责初始化完成后你会看到一套组织得非常清晰的目录。我认为理解下面这几个文件是后面所有调试工作的基础src/ ├── app/ │ ├── api/trpc/[trpc]/route.ts # tRPC 的 HTTP 入口 │ ├── layout.tsx │ └── page.tsx ├── server/ │ ├── api/ │ │ ├── routers/ │ │ │ ├── post.ts # 示例业务路由 │ │ │ └── root.ts # 路由聚合根 │ │ ├── trpc.ts # tRPC 实例、公共/受保护 Procedure │ │ └── root.ts # 创建上下文、所有 router 汇总 │ └── db.ts # Prisma Client 单例 ├── trpc/ │ ├── react.tsx # 前端 API Provider 封装 │ └── server.ts # 服务端 caller 封装 └── env.js # 环境变量校验我第一次看到[trpc]/route.ts时其实是懵的后来才搞明白在 App Router 下tRPC 需要一个 HTTP 端点将所有 Procedure 请求集中起来而这个文件就是那个统一的出口。浏览器端发起请求时请求会汇聚到这个路由上再由它分发到对应的 router 和 procedure。src/server/api/trpc.ts更重要因为所有的中间件、公共查询、受保护查询都在这里定义。项目刚生成时自带的示例代码已经给出了publicProcedure和protectedProcedure两个基本款理解它们的区别几乎等于理解了 T3 的鉴权模型。2.3 我建议先删掉再重写的文件脚本生成的项目里带着示例的postrouter 和页面代码。真实项目里这些示例基本用不上我的建议是动手写第一个真实功能之前先把routers/post.ts删掉把首页的示例逻辑清空然后自己在root.ts里注册第一个业务 router。这样做不是为了干净而是为了强迫自己走一遍完整的 router 注册和调用链路。很多人在脚手架示例上跑通了但换到自己的业务时却连路由都注册不上问题往往就出在没理解router 必须注册到 root 之后前端才有对应的 hooks 可用这一环。3. tRPC 调用链路拆解从 zod schema 到前端 useQuery 的全程类型贯通3.1 核心概念Procedure、Router 和 ContexttRPC 有三个核心概念理解了它们就能读懂几乎所有代码。Procedure 是最小的可调用单元相当于一个接口Router 是 Procedure 的集合相当于一个控制器Context 是所有 Procedure 都能访问的共享数据通常包含数据库实例和 session 信息。创建 tRPC 实例时最关键的代码是createTRPCContext它负责把每个请求的上下文组装好。默认实现里它会读取当前请求的 session然后连同数据库实例一起塞给所有 Procedure。换句话说Procedure 里拿到的ctx.db和ctx.session就是从这里来的。// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import { type CreateNextContextOptions } from trpc/server/adapters/next; import { getServerSession } from next-auth; import { db } from ~/server/db; export const createTRPCContext async (opts: CreateNextContextOptions) { const session await getServerSession(); return { db, session, ...opts }; }; const t initTRPC.contexttypeof createTRPCContext().create(); export const createTRPCRouter t.router; export const publicProcedure t.procedure;可以看到db和session都被注入到了 context 里。后续每一个 procedure 里不需要重新创建数据库连接也不需要重新获取 session这既是性能考虑也是统一鉴权的基础。3.2 一个完整的 CRUD 示例从服务端到客户端的调用下面我用一个帖子功能来演示完整链路。服务端定义一个 query 和一个 mutation输入参数全部由 zod 在运行时校验// src/server/api/routers/post.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; export const postRouter createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, take: 20, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1).max(5000), }), ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });前端调用非常直接use client; import { api } from ~/trpc/react; function PostList() { const { data: posts, isLoading } api.post.getAll.useQuery(); const createPost api.post.create.useMutation(); if (isLoading) return div加载中.../div; return ( div {posts?.map((post) ( div key{post.id}{post.title}/div ))} button onClick{() createPost.mutate({ title: 测试标题, content: 测试内容, }) } 新建 /button /div ); }注意api.post.create.useMutation()这里的api对象是自动生成的。它怎么知道有哪些路由因为 tRPC 的类型推导机制会从前端src/trpc/react.tsx里声明的AppRouter类型反推出所有的 hook。如果你新增了一个 router 却忘记在root.ts里注册前端立刻会报类型错误这在开发体验上是巨大的提升类型系统直接在编译期就告诉你你调的接口根本不存在。3.3 受保护路由的鉴权写法上面的create用的是protectedProcedure因为发帖子需要登录用户。它的实现核心是一个中间件在进入业务逻辑之前检查 sessionexport const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });我见过不少初学者在业务代码里手动判断登录态const session await getServerSession(); if (!session?.user) throw new Error(未登录);这当然能用但问题有三个每个路由都要重复写、错误信息和规范无法统一、context 里的类型没有收窄。而protectedProcedure的好处是中间件只写一次后面所有需要登录的接口直接复用并且通过了中间件之后ctx.session.user在 TypeScript 里就是非空类型业务代码里不需要再写防御性判断。3.4 App Router 下 Server Components 调用 tRPC 的注意事项T3 的示例代码里服务端组件可以直接调用api这个api来自src/trpc/server.ts里创建的 server caller。它不走 HTTP而是直接调用 router 里的函数性能更好也没有网络开销。但这里有一个非常容易踩的坑服务端组件里调用了 query 之后页面并不会自动更新。比如你在服务端组件里拉了一次帖子列表然后用户在客户端创建了一个帖子你希望服务端组件的列表自动刷新却发现页面纹丝不动。这是因为服务端组件的渲染发生在请求时客户端的 mutation 并不会触发服务端重新渲染。解决办法是调用router.refresh()const utils api.useUtils(); await createPost.mutateAsync({ title, content }); utils.post.getAll.refetch(); // 客户端数据刷新 router.refresh(); // 服务端组件重新渲染这两个调用各管一层丢了任何一个都会出现数据不同步的诡异现象。这是我实际开发中印象最深的一次排查前后端数据一个更新一个不更新最后才发现是忘了调router.refresh()。3.5 tRPC v10 到 v11升级后必须改的几个地方我项目初期用的还是 v10最近升级到 v11有几个变化必须搞清楚。v11 里trpc/client的httpBatchLink被移到了trpc/client目录createTRPCClient的类型参数从单个泛型变成了{ routes }对象结构。另外 v11 引入了一些新的缓存配置项比如responseCache可以直接给 query 配置服务端响应缓存这对高读接口来说收益非常明显。升级时最容易踩的坑是 import 路径变了旧代码里import { createTRPCReact } from trpc/react-query在 v11 依然有效但服务端 caller 的写法变了。我的建议是升级前先看一下官方 migration guide别直接pnpm up否则编译错误会多到让你怀疑人生。4. Prisma 数据建模与迁移实战我踩过的三个数据库坑4.1 Prisma Client 初始化的全局单例模式脚手架生成的src/server/db.ts长这样import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;这段代码看着简单实际非常关键。在 Next.js 开发模式下模块文件会被反复加载如果没有这个全局单例缓存每次热更新都会 new 一个新的 PrismaClient数据库连接数会一路飙升直到把你本地数据库拖垮。我第一次没意识到这个问题接上 Postgres 后跑了半天数据库连接数直接爆满最后才发现是热重载导致的连接泄漏。这个模式我不建议改也不需要理解得很深直接用就行。4.2 典型模型设计和关系选择以用户和帖子为例标准的schema.prisma模型如下model User { id String id default(cuid()) name String? email String? unique posts Post[] accounts Account[] sessions Session[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String author User relation(fields: [authorId], references: [id]) authorId String createdAt DateTime default(now()) updatedAt DateTime updatedAt }这里我犯过一个选型错误一开始偷懒把用户表的主键设成了自增整数Int后来做数据迁移和跨环境同步时发现整数主键在分布式的扩展性上不如cuid而且拼接 URL 时会暴露数据量。换成cuid之后主键不仅是全局唯一的还能避免很多 ID 遍历滥用的安全问题。所以新项目我建议直接用cuid()不要用自增整数。4.3 迁移命令与三个常见报错Prisma 的迁移流程是先改schema.prisma然后运行命令npx prisma migrate dev --name add_post_tablemigrate dev会生成迁移文件、执行 SQL并且自动重新生成 Prisma Client。如果数据库表结构已经和 schema 不一致了它还会提示你。我在实际过程中遇到三个高频报错报错代码现象解决方案P1001连接数据库超时检查 DATABASE_URL 的 host、端口、白名单P2021查询的表不存在通常是迁移没有执行跑一次prisma migrate dev或用prisma db push对齐结构P2002唯一约束冲突检查插入数据的唯一字段常见于重复邮箱、重复 slug尤其 P2021我遇到好几次。原因多是同事拉代码之后只跑了pnpm install没跑迁移。我的习惯是在项目启动脚本里顺手加上prisma migrate deploy这样部署的时候不会漏。4.4 查询性能include 大礼包的代价Prisma 里最容易写出慢查询的就是无脑include关联数据。比如查询帖子列表时把作者、点赞、评论全部 include 进来数据量一上来一条简单查询会变成多表 join性能直线下降。我处理这个问题的原则是列表页只查列表需要的字段详情页才查完整数据常用select精确控制字段而不是include大礼包。如果确实需要关联数据也尽量用include但配合take限制关联数量。还有一个细节count类的统计别在循环里逐条查询改用groupBy或原始 SQL不然 N1 问题会把你拖死。5. 登录态设计NextAuth 和 tRPC 中间件如何共享 session5.1 NextAuth 配置与 Prisma 如何对接NextAuth 在 T3 里的角色是认证底座它和 Prisma 的对接方式是通过PrismaAdapter// src/server/auth.ts import { PrismaAdapter } from auth/prisma-adapter; import NextAuth from next-auth; import { db } from ~/server/db; export const { handlers, auth, signIn, signOut } NextAuth({ adapter: PrismaAdapter(db), providers: [], session: { strategy: jwt }, });PrismaAdapter的价值在于它会自动把 User、Account、Session 等表同步到数据库中你不需要自己手写这几张表的 CRUD。常见的第三方登录 provider比如 GitHub、Google的文档都写得比较清楚配置好 client id 和 client secret 就能工作。5.2 session 的两种策略JWT 与 Database 的取舍NextAuth 的 session 策略有jwt和database两种这里需要做一个明确的选择。jwt策略是把用户信息编码进 JWT每次请求通过getServerSession解密获取优点是服务端无状态、适合 serverless缺点是用户信息更新后需要等 token 过期才能生效。database策略是把 session 存在数据库里可以实时踢人、撤销会话但每次请求都要查一次数据库在 serverless 环境下会增加一层延迟。我的经验是MVP 阶段用jwt足够能省掉不少数据库查询等真到了需要做强制下线查在线用户这种管理功能时再切到database策略也不迟。切过去也就是改一个配置项。5.3 tRPC 中间件中注入当前用户真正把 NextAuth 和 tRPC 打通的关键是在 context 里读取 session然后让protectedProcedure能用它。前面已经展示过这个流程我再补充一个细节如果你想在受保护接口里拿到更多用户信息比如用户角色、昵称可以通过session.user的 id 去数据库里查把完整的用户对象注入到 context 里。return next({ ctx: { user: await ctx.db.user.findUnique({ where: { id: ctx.session.user.id }, }), }, });好处很明显业务 procedure 里直接用ctx.user.role做权限判断不需要每个接口都查一遍用户。但要注意这个查询会发生在每个受保护接口调用时如果接口量大了建议做成按需加载而不是每次都全量读取。5.4 前端登录状态的几种判断方式前端判断登录态我常用的方式有三种。第一种是直接用useSession()const { data: session, status } useSession();第二种是调一个getMe类的 tRPC procedure把当前用户数据拉出来。第三种是某些页面在服务端通过auth()判断后直接重定向。三种方式的适用场景不同useSession适合全局判断要不要显示登录按钮getMe适合需要用户完整资料时服务端重定向适合保护页面路由。有个细节容易忽略useSession在第一次加载时可能会短暂返回unauthenticated状态导致页面闪一下未登录的 UI。如果不想闪屏可以在布局层用SessionProvider配合status loading做全局加载态。这个小细节很多项目上线后才发现体验影响还挺明显的。6. 上生产环境部署配置、缓存策略与性能体检6.1 serverless 环境下的数据库连接管理T3 项目通常部署到 Vercel 这类 serverless 平台而 serverless 环境最大的问题就是函数实例会随时销毁和重建数据库连接不能像传统服务器那样保持长连接。Prisma Client 在这种场景下需要依赖连接池。我的做法是Postgres 数据库开一个连接池地址DATABASE_URL 指向连接池而不是直连地址。这样每个 serverless 实例创建的连接都会被池化复用不会打爆数据库的最大连接数。不接连接池的话流量一上来数据库很容易报too many connections而且这个错误在本地开发完全复现不了只能在线上环境排查特别折腾。另外生产环境的 Prisma Client 应该只初始化一份千万不要在请求里反复new PrismaClient()。上一节提到的全局单例模式在生产环境同样适用。6.2 缓存策略superjson、响应缓存与 router.refresh默认脚手架里配置了superjson作为数据序列化工具它的主要作用是把Date、Map这类特殊类型在传输时完整保留。我建议不要关掉它因为 Prisma 查询结果里的时间字段是Date对象如果没有 superjson时间数据会在网络传输中变成字符串前端再用的时候非常别扭。针对读多写少的接口可以在 query 上配置缓存避免每次请求都打数据库。tRPC v11 提供了responseCache设置一个短时间的缓存能明显降低数据库压力。但缓存也会带来副作用用户更新数据后读接口可能拿到旧数据。我处理思路是对实时性要求高的接口不缓存对列表这类允许有几十秒延迟的数据开短缓存同时客户端 mutation 成功后调用refetch()强制刷新。6.3 部署后的性能检查与优化清单项目上线后我一般会按下面这个顺序做一轮体检打开浏览器开发者工具的 Network 面板看 tRPC 请求是否有批量合并httpBatchLink 默认会批量确认有没有出现海量单请求。查数据库慢查询日志重点看有没有全表扫描、有没有在循环里发查询。检查前端打包大小确认没有把整个数据库客户端打包进浏览器。用 Lighthouse 跑一下首屏重点看服务端渲染时间。我实际遇到过一次很典型的性能问题列表页的 query 被放在多个子组件里各自调用结果每次切换 Tab 都要重新拉一遍数据页面卡顿明显。后来把所有 query 提升到页面顶层组件用一次请求拿回全部数据再通过 props 分发下去首屏和交互都流畅了很多。这个问题的根子不是 T3而是我自己的组件设计但这种把接口调用和组件生命周期绑太死的毛病在全栈开发里特别常见值得警惕。还有一个容易被忽略的瓶颈是图片和静态资源。Tailwind 生成的 CSS 体量不小如果直接默认引入首屏样式体积会偏大。建议生产环境开启 Tailwind 的minify和 tree shaking配合 Next.js 的 Image 组件做图片优化首屏体积能再瘦一圈。整个 t3code 折腾下来我最深的体会是这套技术栈最大的魅力不在于任何单一框架而在于它们叠加之后产生的类型安全的正反馈循环——tRPC 让前后端类型一致Prisma 让数据库类型和 TypeScript 类型对齐zod 让运行时校验和编译期类型双保险。这种一致性带来的开发效率提升是要自己写过一段时间才能直观感受到的。如果你正打算开始一个全栈项目不妨先用 T3 跑一个完整的小功能试试别急着点评它重不重、花哨不花哨代码会给你答案。