“t3code”这个代号,在我这边圈子里的第一反应,多半是 T3 Stack 那一套东西。不是硬件,也不是某个编码协议,而是由 create-t3-app 带火的那套 TypeScript 全栈组合:Next.js + TypeScript + Tailwind CSS + tRPC。这套组合这几年在独立开发者和中型团队里存在感极强,能用极少的代码把端到端的类型安全拉满。我去年拿它完整重写了一个内部的工具型 SaaS 项目,前后用了大概一个季度。这篇文章不聊官网文档里的 Hello World,而是把我从选型、落笔、跑通、再到部署和调优这条链路里的真实体感、踩过的坑、以及最终觉得“这套东西值在哪、亏在哪”的经验记录下来,给正在犹豫要不要入坑 T3 的伙计们做个参考。
1. 我为什么在那么多全栈方案里选中了 T3 Stack
选技术栈这事,很多时候不是“哪个最好”,而是“哪个在眼下的约束里最不难受”。当时我手头的情况是:一个偏后台管理的 SaaS 工具,团队加上我自己一共三个人,全是 TypeScript 背景,没有专职后端,也没有专职运维。业务逻辑中等偏复杂,但表单居多、报表居多、页面交互不算花哨,最大的需求是迭代速度。
候选方案当时有三个,我列个表说说对比感受:
| 方案 | 端到端类型安全 | 服务端开发成本 | 前后端通信效率 | 学习曲线 | 社区生态 |
|---|---|---|---|---|---|
| Next.js 全栈(API Routes 手写) | 需要自己维护类型 | 中等 | 中等,手写 API 多 | 平缓 | 极广 |
| Next.js + NestJS 前后端分离 | 需要额外生成 SDK/类型同步 | 高 | 中高,需处理 CORS | 陡峭 | 广 |
| T3 Stack(Next.js + tRPC) | 开箱即用,全链路类型推导 | 低 | 极高,无 HTTP 路由样板代码 | 平缓(需理解 tRPC 心智) | 中等且增速快 |
比较下来,T3 的核心价值在项目早期还不那么明显,等真正开始写业务代码,那种“接口层消融”的感觉才出来。不用写 OpenAPI 文档、不用维护 Postman 集合、不用在前后端之间反复对齐字段名。改一个 Prisma schema,跑一次迁移,服务端的类型就变了,客户端的调用立刻跟着变红或恢复正常。这个体验在传统 REST 架构里是几乎不敢想的。
当然,T3 Stack 不是一个“官方框架”,它更像是一套最佳实践的集合模板。Theo 那帮人把它打包进了 create-t3-app,目的就是省掉你搭脚手架的时间。但千万注意,模板只是起点,不是终点。很多人误以为用 create-t3-app 生成了项目就等于懂了 T3,这可是最大的误解。脚手架给你的是骨架,真正的活儿全在后面那些数据模型、认证逻辑、权限控制模块里。
2. 项目初始化的关键选择与工程细节拆解
2.1 初始化命令和交互选项,哪些选项值得认真选
初始化 T3 项目很简单,一条命令:
npm create t3-app@latest my-t3-project但交互式问答里几个选项真的要基于自家项目情况来勾,不是越全越好。我当时选了 Next.js(App Router)、TypeScript、Tailwind、Prisma、tRPC,跳过了 NextAuth,理由是内部工具需要对接企业 LDAP 认证,直接用 NextAuth 还得写自定义适配器,不如自己在服务端包一层认证逻辑,顺便把 tRPC 的 middleware 串起来。这个决定后来帮了大忙,给权限模型留出了很大自由度。
再说说 App Router vs Pages Router。T3 默认老版本用 Pages Router,新版本已经推 App Router 了。如果项目从零开始,我建议直接用 App Router。虽然早期它的服务端组件和客户端组件的边界有点烦人,但只要遵守一条铁律就能少踩很多坑:默认所有组件在服务端渲染,只有当某个组件明确需要 useState、useEffect、事件监听时才在文件顶部加 "use client"。我见过不少刚上手 App Router 的人,习惯性把所有组件都标记成客户端组件,最后 bundle size 直接爆炸,首屏性能优势全丢,然后反过来骂 Next.js 垃圾。
2.2 目录结构背后的设计逻辑,别乱动也别盲从
create-t3-app 生成的目录结构长这样:
src/ pages/ # 如果选 Pages Router app/ # 如果选 App Router server/ api/ auth/ db/ trpc/ trpc/ router/关键在src/server/和src/trpc/的分工。src/server/是纯粹的 Node.js 层,里面的 db、auth 不依赖 Next.js 的运行时环境,这保证了逻辑可以在非 HTTP 场景(比如定时任务、CLI 脚本)里复用。而src/trpc/是 tRPC 的胶水层,负责把 procedure 暴露成 HTTP 端点。
这里我要特别提醒一个细节:不要轻易把 server 层里的代码 import 到客户端组件里。Prisma Client 在浏览器环境里无法运行,一旦不小心 import 了,Next.js 编译时会直接报错。我团队里一个哥们儿当时为了省事,想在客户端直接查数据库,import 了prisma,报错后一脸懵。分层不是做样子的,它是在物理上防止你做出在浏览器里连数据库这种荒唐事。
2.3 Prisma 接入时最容易忽视的两个点
Prisma 是 T3 Stack 里数据模型的基石。大部分人照着文档跑npx prisma init和npx prisma db push就完事了,但还有两个点值得留意。
第一是Prisma 的日志输出。默认情况下 Prisma 的查询日志是关闭的,排查慢查询时很抓瞎。建议在src/server/db.ts里显式配置日志输出:
import { PrismaClient } from "@prisma/client"; import { env } from "~/env.mjs"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], }); if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;这个globalForPrisma的写法是防 Next.js 热更新时创建过多 PrismaClient 实例的标准姿势,网上都在抄,但很多人没想过为什么:开发模式下每个模块都会被重新执行,如果没有 global 缓存,每个热更新周期都会 new 一个新连接池实例,最后数据库连接数飙到上限,开发环境莫名其妙就挂了。这属于“不会立刻爆炸,但到了人多的时候一定爆炸”的典型坑。
第二是数据库迁移(migration)和 db push 的区别。很多教程让人用db push做开发阶段的表结构同步,图它快。但一旦项目进入多人协作或正式部署阶段,务必切换到正式 migration:
npx prisma migrate dev --name init npx prisma migrate deploy我后来在迁移正式环境时吃过亏:开发时一直用db push,导致本地数据库的 schema 状态和 migration 历史文件对不上,部署时migrate deploy直接拒绝执行,最后只能手工 baseline。那感觉真的很酸爽,花了大半天时间才把 migration 历史和真实数据库状态对齐。所以我的建议很直接:从项目第一天就用 migrate,别用 db push,前期多花两分钟,后期省两天的折腾时间。
3. tRPC 实践心得:类型安全不是白来的,但也不是万能的
3.1 procedure 设计里最核心的 query 和 mutation 取舍
tRPC 的核心理念是让 API 像“直接调用本地函数”一样,但本地函数是有语义的,query 应该保证无副作用,mutation 允许修改数据。这个边界一开始我们守得很好,但后来越写越飘,某次在 query 里隐式写了个日志记录到数据库,结果客户端组件在渲染时连续调了几次,日志表疯长。所以这类框架的纪律性来自于你的团队有没有共同遵守的 API 语义契约,而不是框架本身能检测出来。
tRPC 的 procedure 定义很简单:
import { z } from "zod"; import { createTRPCRouter, publicProcedure, protectedProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ page: z.number().int().optional().default(1) })) .query(async ({ ctx }) => { // 业务查询逻辑 return { posts: [], total: 0 }; }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string() })) .mutation(async ({ ctx, input }) => { // 业务写入逻辑 return { success: true }; }), });Zod 的 schema 在这里身兼两职:既是运行时校验,又是类型推导源。tRPC 不需要像 REST 那样在客户端手动定义 dto 类型,Zod 直接用它推断。这也就是为什么说 T3 Stack 把“类型安全”做到了链路级的根本原因:类型并不是两端各自维护后碰巧一致的,而是在一个地方定义,另一端自动获得。
3.2 鉴权中间件,tRPC 里最容易被忽视的机制
T3 项目的鉴权逻辑通常会在src/server/api/trpc.ts里配置。默认模板里有一个protectedProcedure,但别满足于现成的,真正值得花功夫的是中间件链的叠加。比如我这个内部 SaaS 里,权限不是简单的登录/未登录二分,而是分普通成员、项目管理员、全局管理员三级。
我在 tRPC 中间件里做了三层叠加:
export const requireAuth = t.middleware(({ ctx, next }) => { if (!ctx.session?.userId) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { userId: ctx.session.userId } }); }); export const requireProjectAdmin = requireAuth.unstable_pipe(({ ctx, next }) => { if (!ctx.user?.projectRole || ctx.user.projectRole !== "ADMIN") { throw new TRPCError({ code: "FORBIDDEN" }); } return next({ ctx: { projectAdminUserId: ctx.user.userId } }); });unstable_pipe这名字听着吓人,其实就是链式组合中间件,把多个校验逻辑拼成一个。这样做的好处是,每个路由在声明时就直接用语义明确的 procedure:projectAdminProcedure、globalAdminProcedure,阅读代码的时候一眼就知道谁的权限够不够。权限逻辑跟业务逻辑互相不脏,比在路由里写一层 if 再包一层 if 干净得多。
3.3 tRPC 的 batch 机制,使用体验上的隐藏加分项
还有一个很容易被忽略但实际体验提升非常明显的点:tRPC 默认是支持请求合并(batching)的。如果你用@trpc/react-query的客户端,在同一个事件循环内发起的多个查询会被打包成一个 HTTP 请求发给服务端。
我遇到过具体场景:一个 Dashboard 页面同时要加载用户信息、项目列表、待办事项、最近活动四个数据,按 REST 的做法要么并发发四个请求,要么设计一个聚合接口。tRPC 这边什么都不用做,React Query 的 hook 会自然地把这四个 query 打包成一个请求。这在网络环境差或者 HTTP/1.1 连接数受限时,首屏加载速度的提升肉眼可见。
但也要提醒一句:不要因为批处理就觉得所有数据都能塞进一个 query 里。如果一个页面同时依赖大量数据且业务压力大,可以考虑显式声明trpc.useQueries((t) => ...)或者干脆拆开控制在两个三个请求。批量请求只是一个优化手段,别把性能设计建立在它的恩赐上。
3.4 使用 tRPC 一段时间后,我反而想吐槽的几点
文档里没人告诉你的话,我来说:
- 错误处理比 REST 粗糙。REST 可以用不同的 HTTP 状态码加错误码做细分,tRPC 的 TRPCError 虽然也有 code,但默认都经过 HTTP 200 返回给客户端,然后由客户端解析里面的类型。你在服务端抛出 INTERNAL_SERVER_ERROR,肉眼看网络请求状态还是 200。监控告警系统如果依赖 HTTP 状态码判断服务健康状况,就会漏报。
- 不适合给第三方开放 API。tRPC 的端点不是一个易读的 REST 风格 URL,也没有原生的 OpenAPI 文档(虽然现在有插件,但毕竟是后置方案)。如果未来你有想把自己的数据开放给外部伙伴或移动端的打算,tRPC 不是很好的选择,至少不适合作为唯一出口。
- 非 TypeScript 客户端非常难受。如果团队里将来招了纯前端但只写 JavaScript 的成员,tRPC 的全链路类型推导他就感受不到,反而会觉得那套调用方式神神秘秘。倒逼全员用 TS 不是坏事,但要有心理准备。
这些限制不代表 tRPC 不好,而是说它像任何好用的工具一样,有自己最顺手的使用场景。这个话题后面展开讲。
4. 部署与性能调优:T3 项目上线前的最后一公里
写代码只是完成了一半,另一半在部署和优化上。T3 Stack 项目一般是 Next.js 应用,所以主流的部署方式很清晰:平台即服务(Vercel 是默认推荐),或者单独的 Node.js 服务器跑 Docker 容器。
4.1 数据库连接数瓶颈,最容易让应用“假死”的坑
T3 项目里的 Prisma 是跑在 Next.js 的 Node.js 运行时里的,而很多部署平台对单个实例的连接数有限制。如果数据库是 Postgres(很多云数据库默认连接数上限 100 到 300 不等),而 Next.js 水平扩展到多个实例,每个实例的 PrismaClient 默认按 CPU 数量乘 2 再加 1 计算连接池大小,实例一多,连接数很容易被吃光。
解决方案是显式设置连接池大小,并强烈建议启用 PgBouncer 之类的连接池代理:
new PrismaClient({ datasources: { db: { url: env.DATABASE_URL, }, }, // 在连接字符串里加 connection_limit 参数 });Prisma 的DATABASE_URL支持连接参数,比如?connection_limit=5&pool_timeout=10。这个参数控制的是 Prisma 在单个 Node.js 进程内连接池的最大连接数,而不是数据库侧的限制。我在部署时把连接池限制在 10 左右,单实例就足够撑住日常请求了,不是说图快,而是为了保护后端数据库不在流量突增时被打到连接数上限,从而拖垮整套服务。Prisma 的已知问题是:如果连接池熬不到空闲连接,query 会排队直到超时;数据库连接数被打满时,请求不会秒挂,而是排长队,最终表现为接口响应极慢、CPU 占满。这个是线上事故的经典前兆。
4.2 缓存策略:什么时候该用 React Query 的 staleTime
T3 默认集成的@tanstack/react-query是数据缓存的关键玩家。很多人只知道用它避免重复请求,却不清楚 staleTime 这个参数才是真正决定缓存行为和请求频率的核心。
在数据时效性分类上建议这么做:
| 数据类型 | 建议 staleTime | 说明 |
|---|---|---|
| 用户基础信息 | 5 分钟 | 非敏感、不变更频繁,降低频繁请求 |
| 项目列表 | 1 分钟 | 变更频率中等,允许轻微延迟 |
| 实时报表 | 0(默认) | 以最新为准,不缓存 |
| 仪表盘聚合数据 | 30 秒 | 折中,减轻服务端压力 |
代码示例:
const { data } = trpc.project.list.useQuery(undefined, { staleTime: 60_000, });有个细节要理解:staleTime 不是“过了这个时间就自动刷新”,而是“过了这个时间后,当组件重新挂载或窗口重新聚焦时才会重新拉取”。它不会主动轮询。如果要主动刷新,需要refetchInterval。这个区分很重要,很多人被 staleTime 名字误导,以为它能做自动定时任务。
4.3 服务器端渲染和数据预取,别把 T3 打成纯客户端应用
很多人用了 tRPC 之后,会觉得一切数据都在 useEffect 里拉就行,完全忽略 SSR/SSG。结果就是一个 Next.js 项目白瞎了服务端渲染的能力。create-t3-app 会默认配置好@trpc/next和@trpc/react-query两套东西,前者专门用于服务端预取。
服务端预取的核心语法长这样:
import { createTRPCContext } from "~/server/api/trpc"; import { appRouter } from "~/server/api/root"; import { createCaller } from "~/server/api/root"; import { serverClient } from "~/trpc/server"; // 在 Server Component 里直接调用 const posts = await serverClient.post.list({ page: 1 });注意这里和客户端调用长得很像,但多了一个await,因为它是在服务端直接执行函数而不是发 HTTP 请求。这样首屏 HTML 里就带有数据了,客户端 hydration 时无需再等接口往返,既提升了首屏渲染速度,也顺便对 SEO 友好。内部工具可能不在乎 SEO,但首屏加载速度永远是用户体验的一部分。
5. 遭遇过的两个典型疑难杂症,排查链路和最终解法
写代码免不了踩坑,T3 项目也不例外。下面两个疑难杂症是我实际遇到且花了不少时间解决的,写出来帮大家省排查时间。
5.1 客户端收到 401,但错误详情被吞掉了
现象:登录后访问/dashboard,页面里某些 tRPC 查询失败,控制台只显示 "UNAUTHORIZED",但看不到任何堆栈或业务提示。
排查链路:
- 确认
ctx.session在服务端是否真的存在。当时我们用的是自定义的 session 机制,存在 HttpOnly cookie 里。 - 在 tRPC middleware 中打日志,检查
ctx.session.userId。 - 发现服务端日志有 userId,说明 session 没问题。
- 继续查客户端请求头,发现浏览器发的请求里没有带 cookie。
根因在第三个坑:Next.js 的 fetch 默认不走 cookie。如果你是在 Server Component 里通过serverClient调用 tRPC,服务端接口的请求不会带上浏览器上下文里已有的 cookie;或者反过来,客户端组件通过 fetch 调服务端时,没有显式开启credentials: "include"。
最终解法:在 tRPC 客户端请求配置里显式设置credentials。
export const api = createTRPCNext<AppRouter>({ config() { return { links: [ httpBatchLink({ url: "/api/trpc", fetch(url, options) { return fetch(url, { ...options, credentials: "include", }); }, }), ], }; }, });这种问题之所以难排查,是因为你第一眼看到的报错是 UNAUTHORIZED,第一反应肯定是去查鉴权代码和数据库里的用户,但实际上问题发生在很底层的浏览器 fetch 行为上。
5.2 Prisma 在 Serverless 环境的频繁连接中断
现象:应用跑在 Serverless 函数上,过一段时间后接口偶尔报PrismaClientInitializationError,无法连接数据库,重启函数就好了。
一开始还以为是数据库连接数超限,想着调整connection_limit参数了事。排查完发现不是——Serverless 环境的特点是函数会被冻结和唤醒,数据库连接在被冻结期间显然无法保活,OSS 层面的 socket 悄然关闭,等到下次唤醒时老连接还挂在连接池里,访问当然失败。
解法其实很简单:在 Prisma 配置里开启 socket 超时,并设定合理的连接生命周期:
DATABASE_URL="postgresql://user:pass@host:5432/db?connect_timeout=10&pool_timeout=10&socket_timeout=10&connection_limit=5"关键是connect_timeout和socket_timeout。不设这些参数,默认值会非常大,在资源不可达时客户端会一直傻等,白白浪费函数执行时间。设了之后,至少能快速失败,然后由客户端重试机制去恢复连接。
6. 实际体验里的最终结论:什么项目适合 T3 Stack,什么项目该绕道
项目做到后期,我对 T3 Stack 的适用边界有了比较清晰的判断:
适合 T3 Stack 项目:
- 内部管理系统、后台面板,用户数量有限,不需要对第三方开放复杂 API
- 前端和后端同属一个团队,且团队全是 TypeScript 技术栈或有意愿统一到 TypeScript
- 业务逻辑以数据增删改查为主,需要快速迭代出原型或 MVP
- 一个小而精的团队(1 到 5 人)想要少写一遍接口文档的
不适合 T3 Stack 的项目:
- 需要大量处理文件上传、二进制流、复杂 WebSocket 通信的场景(tRPC 支持有限)
- 需要同时服务 Web、iOS、Android、第三方服务商等多端客户端的场景(API 出口设计非常别扭)
- 后端有严格的性能要求,需要极细粒度控制数据库查询和缓存行为的场景(tRPC 中间的透明封装反而成为调试障碍)
- 团队里已经有一个成熟的 REST API 资产,沉淀了大量文档、测试用例、缓存策略的场景(迁移成本大于收益)
如果选了 T3 Stack,还可以考虑附加的有价值模块:Zod 真的很香,也用得很好,未来单独再写一篇文章专门说 Zod 与 tRPC 的联动;Prisma 如果有棘手问题,可以考虑迁移到 Drizzle ORM,社区对 T3 的讨论已经很多了。
我个人在实际操作中的体会是,T3 Stack 极大的愉悦感跟极大的隐忧都来自同一点——全链路类型安全。它让最开始的开发速度快到起飞,但也让后期的性能排查和对外开放变得束手束脚。关键是要认清自家业务的本质,把适合的部分用好,不适合的部分绕开。比如我自己的项目到现在还在跑,核心业务全部走 tRPC,但对外接口特意留了一层 REST。这套组合拳到目前为止都比较稳,真要让我回到当初重新选一次,我还是会选 T3。