T3 Stack实战:用tRPC构建类型安全的全栈应用 t3code 这个名字第一次出现在我仓库里的时候团队里同事都以为是什么加密协议。其实就是我给一个内部全栈项目起的代号核心含义是用 T3 Stack 这一套组合拳写出来的完整代码。T3 Stack 在最近的全栈社区里热度很高它由 Next.js、TypeScript、tRPC、Tailwind CSS 四件套组成Prisma 和 NextAuth.js 经常作为数据库和认证层一起加入。我当时的问题是到底用传统 REST 接口还是上个 GraphQL或者干脆试试 tRPC折腾了一段时间后我选择了 t3code 这条路线。如果你也正在纠结全栈项目的技术选型或者听说过 T3 Stack 但一直没看清它怎么落地这篇文章应该能帮你省不少时间。1. 为什么是 t3code从技术选型到项目定位1.1 T3 Stack 到底在解决什么问题先说清楚 T3 是什么。t3code 里的 T3对应的是社区里已经比较有名气的 T3 Stack。它并不是一个所有代码都要重新实现的框架而是一套最佳实践组合前端页面和路由用 Next.js语言层用 TypeScript接口层用 tRPC样式层用 Tailwind CSS。数据库通常接 Prisma认证通常接 NextAuth.js。T3 这个名字来自 Theo 的随口一提因为 TypeScript、tRPC、Tailwind 三个词的首字母都是 T所以叫 Three T。我最早接触到这个概念是在一个开源项目的 README 里当时第一反应是“又是花活”。但真正在小项目里试过一次后我的看法变了。过去写一个全栈功能流程通常是后端定接口、写文档、给前端一份类型定义前端再根据文档去调用。问题是文档和类型定义很容易过期字段一改前后端经常各改各的最后联调时才发现对不上。T3 Stack 的核心思路就是消灭这个“翻译层”用 tRPC 定义后端路由过程前端会自动推导出对应的 hooks 类型。改一个字段类型保存代码的瞬间另一端就编译报错。这种感觉就像两个人明明说同一种方言却偏偏请了个翻译最后把翻译辞退直接对话效率和安全感都上来了。为什么我要把项目代号叫 t3code而不是直接叫项目业务名因为我希望这个仓库本身就是一个可复用的全栈模板里面沉淀了我踩过的版本坑、配置细节、目录划分方式以后开新项目直接复制改改就能用。它本质上是一个“T3 Stack 实战代码库”既是业务项目也是一份长期维护的技术样板。1.2 这个项目定位适合谁解决什么业务t3code 要解决的业务场景并不复杂。我做的是一个内部效率工具核心功能包括用户登录、资源列表、筛选、新增编辑删除、以及一个简单的数据仪表盘。没有高并发的诉求没有对外开放 API 的需求但对开发速度要求比较高因为内部工具的生命周期通常很短需求变化却很快。这类项目用传统前后端分离方案也能做但会有很多重复劳动。分开两个仓库管理加上接口联调、类型维护、环境部署时间成本会翻倍。T3 这种全栈同仓库的方案天然适合内部工具、管理后台、MVP 验证产品。你可以把后端逻辑、数据库模型、前端页面放在一个工程里逻辑链路短改动反馈快。我之前也做过一个对比同样的 CRUD 功能用 Next.js tRPC 写比用 Express REST 前端对接的时间大概省了三分之一省下来的时间主要花在“没有类型对不上”的地方。当然它也不是万能的。如果你要做的是面向第三方的开放 API需要稳定的 OpenAPI 文档给外部团队使用那 tRPC 并不是最优选。t3code 的价值边界非常清晰适合应用内部链路不适合公共契约场景。后面我会专门展开这一点。2. 项目骨架分层与数据流2.1 目录结构怎么摆才不容易乱从 create-t3-app 脚手架出来的目录结构已经帮你划好了大部分边界但如果你没有理解它为什么要这么分很容易在写大之后变得混乱。以一个标准 t3code 项目的 src 目录为例t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc]/ │ │ │ └── route.ts │ │ ├── _components/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── post.ts │ │ │ │ └── root.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── react.tsx ├── .env ├── next.config.js ├── tailwind.config.ts └── tsconfig.json这个目录的关键点在于把所有“服务端相关”的代码收敛到 src/server 下把“tRPC 客户端相关”的初始化收敛到 src/trpc 下页面组件放在 src/app 里。这样你打开一个文件就能快速判断它运行在哪个环境是服务端逻辑还是客户端逻辑是数据访问还是纯 UI。很多项目越写越乱就是因为把 tRPC 路由、数据库操作、页面请求逻辑混在一起最后连一个 getServerSideProps 都塞满了业务代码。在 t3code 里我自定义的分层习惯是routers 里只做参数校验和与业务相关的编排复杂的数据库查询逻辑拆到独立函数里页面组件里尽量不直接写数据访问逻辑而是调用由 tRPC 生成的 hooks。这样做的直接好处是未来如果想换掉数据库或者调整查询逻辑页面层完全不需要动。2.2 一次查询请求是怎么走完的看到这么多目录新手容易懵一次查询到底从哪进、到哪出我根据实际运行流程整理了一下。当用户在浏览器里调用trpc.post.all.useQuery()时实际发生的事情是浏览器中的 tRPC client 把请求打包成一个 HTTP POST发送到 Next.js 提供的/api/trpc/post.all端点。服务端 route handler 解析请求取出 json 数据找到 postRouter 里的 all 过程执行里面的业务逻辑。业务逻辑里通过 context 拿到 Prisma client查询数据库然后把结果序列化成纯 JSON 返回给前端。前端拿到数据后因为类型是编译期推导出来的所以组件里能直接感知到 data、error、isLoading 的状态。这个链路的重点是 context。tRPC 的 context 是在每个请求进入 router 之前执行的一段代码它负责把数据库 client、当前登录用户信息、请求头等放进上下文对象里。后续所有 procedure 都可以从 ctx 中取出这些依赖。我在创建 t3code 时context 的代码大概是这样的import { getServerSession } from next-auth; import { authOptions } from /server/auth; import { db } from /server/db; import { createTRPCContext } from trpc/server; export const createContext async (opts: { headers: Headers }) { const session await getServerSession(authOptions); return { db, session, headers: opts.headers, }; };这里有个容易踩的坑createContext的返回类型会直接影响后面所有 router 里ctx的类型推导。如果你在这里返回了错误的字段名比如把 session 写成 user前端不会报错但在路由里写ctx.session.user.id时 TypeScript 会立即给出一堆红色波浪线。类型推导的好处就在这它不是运行时才暴露而是在你写代码的瞬间就逼你修正。2.3 为什么我不为 t3code 写一堆 REST API 路由以前我写 Next.js API routes 的时候会在pages/api或app/api下面建一堆route.ts然后每个文件里写 GET/POST 的 handler再手动做 Zod 校验、错误处理。这套模式最大的问题是每个接口都需要重复处理“取参、验参、查库、包装返回”的流程。如果做十个资源就要维护十个文件如果做几十个资源代码会立刻膨胀。tRPC 的思路是把接口定义和函数调用抽象在一起。你不用再面对“HTTP 方法 URL 路径”的概念而是思考“这个过程入参是什么、出参是什么”。每个 router 里的一组 procedure 就是一组接口但它们以 TypeScript 函数的形式被消费。t3code 项目的所有请求都汇聚在同一个/api/trpc端点下后端只负责按请求路径路由到对应 procedure。这样既不牺牲 URL 语义又彻底解决了重复代码的问题。我不是说 REST 不好。REST 的语义非常适合资源公开、跨语言、跨团队协作。但在 t3code 这种前后端同仓库、同语言的内部项目里REST 的“清晰接口边界”反而成了一种负担你维护接口文档和类型定义的成本已经超过了它带来的好处。tRPC 本质上是一种 RPC它的选择逻辑很简单——应用内部的通信链路越短越好跨应用边界的通信才需要更标准的协议。3. 核心功能实现跑通第一条完整业务链路3.1 从零初始化项目脚手架与依赖安装t3code 是从官方的 create-t3-app 脚手架开始搭的。初始化命令很简单pnpm create t3-applatest t3code执行过程中会问你选哪些模块。我的选择是Next.js App Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth.js。如果你不确定推荐只勾选自己真正用到的模块。很多人会一股脑全勾结果项目里堆了一堆没用的配置反而增加理解成本。初始化完成后脚手架会自动生成一套已经配置好的基础代码包括 tRPC 的初始化、Prisma client 的实例、NextAuth 的接入、Tailwind 的全局样式。这一步看起来简单其实是整个项目最值得感谢的地方。因为这些库之间的版本兼容问题非常多手动整合往往要在 “React 19 适配” “Next.js 15 中间件改动” 这些坑里泡几天而脚手架已经提前帮你处理掉了大部分。装完依赖后第一件事不是写业务代码而是先跑一遍pnpm dev确认页面能正常打开、tRPC 的 health 查询能连通。如果这一步都通不过先解决环境问题再动业务代码。基于我自己带人的经验这一步花 10 分钟做健康检查比后面排查半天强得多。3.2 数据建模与第一次迁移t3code 的数据库模型以 Prisma schema 为准。我定义的第一版模型很简单一个用户对多个资源记录model User { id String id default(cuid()) name String? email String? unique image String? posts Post[] accounts Account[] sessions Session[] } model Post { id String id default(cuid()) title String content String? published Boolean default(false) authorId String author User relation(fields: [authorId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt }写完 schema 后执行迁移命令npx prisma migrate dev --name init这里有一点非常关键Prisma 的 migrate 命令不只是创建表结构它还会生成 client 代码。你在写 router 时输入的ctx.db.post.findMany()能获得完整类型提示依赖的就是这个生成过程。如果你改了 schema 却没有重新生成 clientTypeScript 会提示你找不到对应属性这是新手最容易困惑的问题之一。我个人的习惯是每次改完 schema都顺手跑一遍npx prisma generate再回来写代码。迁移完成后可以用npx prisma studio打开一个可视化的数据库管理界面检查数据表和数据是否符合预期。这一步在开发期特别方便能直观看到数据库里到底存了什么比在终端里敲 SQL 高效得多。3.3 写一个带权限控制的 tRPC Router在 t3code 项目里最核心的后端代码是 router。它既承担参数校验也承担业务逻辑编排。下面这段代码是我在项目里使用的 postRouterimport { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure, } from /server/api/trpc; import { TRPCError } from trpc/server; export const postRouter createTRPCRouter({ all: publicProcedure.query(({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, include: { author: { select: { name: true, image: true } } }, }); }), byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { const post await ctx.db.post.findUnique({ where: { id: input.id }, include: { author: true }, }); if (!post) { throw new TRPCError({ code: NOT_FOUND }); } return post; }), create: protectedProcedure .input( z.object({ title: z.string().min(1, 标题不能为空), content: z.string().optional(), }) ) .mutation(({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });这段代码里有几个值得留意的点。第一publicProcedure和protectedProcedure是 tRPC 里非常实用的权限抽象。public 表示任何人可调用protected 表示必须登录后才能访问。第二输入校验用的是 Zod并且在校验不通过时抛出的错误会自动转换成 400 响应不需要你手动处理。第三TRPCError的 code 类型是限定的写错字符串编译器会直接提示这比普通 REST 接口里随手 throw 一个 Error 要安全得多。为什么我不在页面里直接操作 Prisma理论上 Next.js Server Component 里是可以直接查数据库的但对于需要权限控制的页面很容易漏掉校验。把所有数据库操作收敛到 tRPC router 里等于在每个操作入口都放了一道统一检查和日志记录点代码审计时也更清晰。3.4 前端页面怎么消费这些接口在页面组件里初始化好的 tRPC client 已经把后端 router 类型推导到了前端。我们不需要手动引入任何请求函数直接用 hooks 即可use client; import { trpc } from /trpc/react; export function PostList() { const { data, isLoading, error } trpc.post.all.useQuery(); const addPost trpc.post.create.useMutation({ onSuccess: () { trpc.useUtils().post.all.invalidate(); }, }); if (isLoading) return p加载中.../p; if (error) return p加载失败{error.message}/p; return ( ul {data?.map((post) ( li key{post.id} {post.title} - {post.author?.name} /li ))} /ul ); }这段代码其实就是整个 t3code 使用体验的缩影。让我感到舒适的地方在于post.all返回的数据结构直接出现在data的类型里如果你在 Prisma 模型里改了字段名前端这里会立刻报错你不会等到运行时才发现。useMutation的onSuccess里手动 invalidate 了列表查询这个操作等于告诉 tRPC“刚才的缓存失效了重新拉一次”。这是 t3code 里我用到最多的模式变更成功后刷新相关列表。要注意的是文件顶部的use client是必须的因为 tRPC hooks 依赖 React 的客户端状态管理不能放在 Server Component 里。很多第一次用 Next.js App Router 的人会把所有组件默认当服务端组件然后在里面调用 useQuery结果直接报错。我的经验是把“页面容器”做成服务端组件把“需要交互的区域”拆成独立的客户端组件两者通过 props 传数据这样既吃到了服务端渲染的好处又保留了交互能力。3.5 部署时的环境变量与构建注意点t3code 整个项目跑通业务后离真正可用还差一步部署。最常见的方式是 Vercel但你要提前把环境变量配置好。核心变量有三个DATABASE_URL数据库连接字符串在生成 Prisma client 时必须存在。NEXTAUTH_SECRET加密 session 的密钥可以用openssl rand -base64 32生成。NEXTAUTH_URL当前部署环境的地址Vercel 会自动注入但本地开发时需要手动配置。部署前建议先跑一遍pnpm build不要等到 Vercel 构建失败再回来看。我在本地构建时踩过一次坑因为.env里没有配置NEXTAUTH_SECRET构建阶段 getServerSession 尝试解密时直接抛错整个 package 阶段卡住。后来我把所有必须的环境变量都以示例形式写进了.env.example并在 README 里注明“没有这些值项目跑不起来”。这是一件小事但能帮你省掉非常多的生产事故。4. 踩坑实录t3code 开发中的典型问题4.1 tRPC 版本升级引发的前端 hooks 失效t3code 项目运行不到两个月我把 tRPC 从 v10 升到 v11结果前端所有.useQuery()突然全部报类型错误。查了文档才发现v11 调整了初始化方式原来的createTRPCNext被改成了createTRPCReact类型引用路径也变了。 升级前我建议你看一眼官方的 migration guide不要直接改依赖版本号。我的处理方式是先在一个分支上跑完pnpm dlx trpc/upgrade这类迁移工具再检查所有 import 路径。这里有个小经验tRPC 的版本跟 Next.js 的版本经常会有联动如果你用的是可以自动升级的脚手架模板不要锁定一个小版本不动也不要追最新版本。正确做法是固定一个大版本比如 v11然后在小版本间保持稳定等有重大更新时再专门安排时间升级。4.2 Prisma 在 serverless 环境的连接数膨胀这个问题在 Vercel 上部署 t3code 的时候特别明显。每个函数实例启动时都会创建 Prisma client如果使用传统数据库连接数很快会被打满页面表现为有时能打开有时直接 502。解决方向有两个一是把连接池参数写到 DATABASE_URL 里让 Prisma 通过 PgBouncer 这样的连接池访问数据库二是考虑用 Prisma Accelerate 这类服务在数据库前面提供一个代理层。如果你的数据量不大最简单的方案是确保 Prisma client 是全局单例。create-t3-app 默认生成的 db.ts 里其实已经做了全局缓存但很多人会在自己的代码里重新new PrismaClient()导致连接暴涨。我检查 t3code 代码时特意搜索了一遍new PrismaClient确保它只出现在src/server/db.ts里。所有 router 应该只通过 context 里的db访问数据库而不是自己直接 new。这一点写进团队规范后连接数问题基本就消失了。4.3 NextAuth session 在中间件里取不到t3code 里有一个场景某些页面需要登录才能访问我习惯在 Next.js 中间件里做跳转判断。但实测发现中间件运行的 Edge 环境并不支持直接读取数据库 sessiongetServerSession在里面拿不到值。这个问题让我折腾了很久最后解决办法是不要在中间件里依赖完整的 session而是用 JWT 策略在authOptions的callbacks里把关键用户信息写进 token中间件里只解析 token。callbacks: { async jwt({ token, user }) { if (user) token.id user.id; return token; }, async session({ session, token }) { if (session.user) session.user.id token.id as string; return session; }, }这段代码看着简单但它解决的是 t3code 里“页面权限控制”和“数据库记录用户”两个核心问题。权限判断在中间件层完成数据操作在 router 层完成两边各司其职。4.4 Tailwind 动态拼接类名不生效在写 t3code 的仪表盘组件时我用了一个动态类名拼接函数比如bg-${color}-500结果颜色死活不生效。原因很简单Tailwind 的 JIT 编译只会扫描源码里完整的类名字符串动态拼接出来的类名它看不到自然也不会生成对应样式。解决方法有两个要么在配置里使用 safelist 明确列出所有可能用到的颜色类要么把条件判断写成完整类名的对象映射。我后来选择了后者因为 safelist 需要手动维护容易漏。完整类名的方式虽然代码长一点但一眼就能看出所有可能状态。4.5 Context 里字段类型不匹配引发的连锁报错项目早期有人在createContext里直接写死了session: null来调试结果所有受保护路由的类型全部报错。因为 tRPC 的类型是自动推导的context 返回 null表示“这个路由永远不可能有登录用户”。后来我明白了context 里 session 的类型应该由 NextAuth 的 Session 类型自动推导不能手动写死或者偷懒。正确做法是让 provider 和 context 都基于同一个类型来源这样改一处另一端马上感知。这类问题最大的麻烦在于错误提示往往出现在页面调用处而不是 context 定义处。你看到一堆组件里的红色波浪线但找不到根因。我的排查习惯是先从报错信息最底层的 import 路径开始看再一层层向上追踪类型来源而不是在组件里瞎改。5. t3code 实测评价与适配建议5.1 开发体验对比tRPC、REST、GraphQL在 t3code 里反复使用 tRPC 之后我整理了一张对比表方便后续选型时参考维度tRPCRESTGraphQL前端类型安全极强编译期推导弱依赖手写类型中依赖 codegen学习成本低像函数调用低但概念多较高需要理解 schema调试难度中日志偏黑盒低直接看 URL较强有 GraphiQL生态成熟度中等社区快速成长极高高适用场景同仓库全栈、内部工具开放 API、跨团队多端复杂查询聚合我的感受是tRPC 不是来替代 REST 的而是给“前后端同仓库、同语言”的场景一个更高效的选项。如果你要做一个长期对外服务的公共 APIREST 依然是稳妥选择。但如果你只是要把团队内部工具做出来tRPC 的开发效率和类型安全性真的会上瘾。5.2 代码量和性能的真实感受以 t3code 里的 Post 资源为例我从模型定义到前端列表展示所有代码加起来不到一百行。如果用传统的 controller、service、repository 模式拆分单是样板文件就会超过这个数量。tRPC 把样板代码压缩到了最低所以你可以把精力集中在真正的业务逻辑上。性能方面因为所有请求都走同一个 HTTP 端点理论上会在日志统计上多一个路由字段但实际网络开销和普通 JSON 接口几乎没区别。我实测过一个小接口的平均响应时间大概是 8ms 到 20ms 之间这跟同等的 Express 接口没有什么明显差异。因为 tRPC 只不过是在 HTTP 之上增加了序列化协议底层的数据库查询和网络传输才是瓶颈。5.3 什么时候别用 t3code 方案写这么多推荐的话我也要泼一盆冷水。以下几种情况不建议把它用在生产项目里第一如果你的产品需要给第三方提供 OpenAPI 文档tRPC 的协议不是业界标准外部接入方会非常困惑。第二如果团队是多人前后端分离前端是 React 以外的技术栈比如 Vue 或小程序tRPC 没法直接发挥类型推导优势。第三如果业务有极其复杂的权限模型需要针对不同客户端隐藏字段REST 和 GraphQL 的按资源暴露能力反而更成熟。t3code 这个方案的黄金位置是“一个人或一个小团队把一个全栈应用从零做到上线”的场景。它让你少操心接口层多关注产品本身。这个定位不是缺点而是一种清醒。6. 一些我觉得真正重要的经验项目写到后期我对 t3code 的整体感受已经从“技术很酷”变成了“组合很稳”。最让我留意的不是某个库有多强而是它们之间的边界有多清晰。Next.js 管渲染TypeScript 管类型tRPC 管数据流Prisma 管数据库Tailwind 管样式每个工具都在自己擅长的位置上几乎没有越界。最后分享两个让我受益最多的小习惯。第一在写任何 router 之前先把 Prisma schema 想清楚字段设计不够好后面所有 router 都要跟着改成本极高。我建议 schema 定义用 10 分钟想清楚而不是上手就写等迁移了三版再来调整关联关系。第二遇到诡异 bug 时先检查依赖版本和.env再检查代码逻辑。t3code 这类现代全栈项目的坑一半以上来自环境或版本不兼容而不是你写的逻辑有问题。如果你也准备开一个新的内部项目我建议你直接试一次这套组合。第一次用 tRPC 的时候你可能会觉得“连接口文档都不写心里不踏实”但坚持把一个完整功能做完大概率会回不去。代码是长在类型系统上的数据结构一变整个链路会同时提醒你这种安全感就是 t3code 给我的最大价值。