t3code 是我最近一个内部项目重构的代号——用 T3 Stack 把公司那个传了三代的数据报表工具彻底重写了一遍。T3 Stack 这两年在前端圈出镜率相当高,核心就是把 TypeScript、Tailwind CSS、tRPC、Next.js 组合在一起,官方给的三条原则是类型安全、务实、周密。这篇博文献上从零搭到上线压测的完整过程,包括关键设计决策、踩过的坑和最后的选型判断,希望能给正在纠结"要不要上 T3 Stack"的你一些参考。
1. 为什么要把内部工具重写为 t3code:一个真实项目的决策过程
1.1 老代码的三个致命问题
这个报表工具公司用了快三年,后端是 Express,前端是 React 函数组件搭配手工拼的接口。提需求的人越来越多,代码越来越乱,最让我头疼的是三个问题:
- 类型断层:前端调接口的时候手写 interface,后端一改字段结构,前端完全不知道。经常是页面白屏之后打开 Network 面板,才发现接口返回的字段名变了。
- 接口拼凑:一个报表详情页要调五六个 REST 接口,然后在组件里手工 join 数据。多一次网络往返就多一层概率性故障,接口之间对不上的情况几乎每周都出现。
- 重复请求:组件挂载以后在 useEffect 里 fetch,依赖数组稍微写错一点,要么数据不刷新,要么无限循环。两个组件同时需要同一份数据时,没有缓存层,只能各自拉一遍。
这还不是最致命的。最致命的是每次我改完接口,还要打开一个十几页的在线文档去手动更新接口说明,而前端同事早就放弃看文档了。整个团队的协作模式变成了"后端改完喊一声,前端自己猜"。
1.2 T3 Stack 的三原则是怎么说服我的
真正让我决定认真评估 T3 Stack 的,是它提出的三条原则——类型安全、务实、周密。翻译成大白话就是:能靠编译器拦住的问题不要拖到运行时,能用简单直接的方式解决就不要引入额外的复杂度,每一步取舍都想清楚后果。
先说类型安全。T3 Stack 从数据库到服务端到客户端,全程 TypeScript,最吸引人的地方是 tRPC。它是真正意义上的"函数即接口":后端写一个函数,前端 import 这个函数的类型之后直接调用,参数校验、返回类型、错误处理全部自动推导,不需要手写 OpenAPI 规范,不需要生成 SDK,更不存在"文档过期"这件事。
再说务实。T3 Stack 默认用的是 Next.js,这套东西在 React 生态里足够主流,SSR、静态生成、边缘函数都有现成方案,不用自己拼搭建的零件。Tailwind CSS 虽然争议不小,但对于一个以表单、表格、图表为主的内部工具来说,它的原子类写法确实是出效果最快的选择,团队已经用了一年多,不存在额外的学习成本。
至于周密,我在初始化项目的时候体会最深。create-t3-app 会让我在创建项目时就明确选哪些模块,而且默认配置都是经过实战检验的。比如它默认不做 Redux,不做 styled-components,不为还没发生的问题提前上重型方案,用最小集合先把事情跑通。
我给这个项目起代号叫 t3code,一方面是这个技术栈的简称,另一方面也是在提醒团队:写逻辑之前先想类型,让编译器做我们的第一个 code reviewer。
2. 用 create-t3-app 初始化项目:命令、选项和目录结构
2.1 初始化命令与模块选择
初始化本身很简单,一条命令:
npm create t3-app@latest随后会出现交互式选项,我需要逐个确认要包含的模块。发起初始化的时候我把 TypeScript、Next.js、Tailwind CSS、tRPC、Prisma、NextAuth 全部勾上了,这样的好处是一次性把全链路类型安全打通,缺点是初始化时间明显变长,依赖安装完接近几百兆。如果只是想先体验一下,完全可以把 Prisma 和 NextAuth 留到第二步,毕竟把一条空的链路跑起来之后再接数据层,心理压力会小很多。
勾选完成后,脚手架会提醒我配置环境变量。这一步实际挺关键,因为后续生成出来的代码里,数据库连接串、认证密钥、站点地址全都要靠.env提供。模板里还有一个.env.example,我建议把它当成必填清单,一行行核对过去,避免后面运行测试时出现一长串环境变量缺失的报错。
2.2 目录结构里的设计边界
初始化完成之后,src 下的目录结构是这个样子:
src/ components/ pages/ api/ trpc/[trpc].ts server/ api/ root.ts routers/ db.ts auth.ts styles/ utils/最需要注意的是server/这个目录。脚手架默认它只跑在服务端,所有涉及数据库、密钥、服务端上下文的代码都要放在这里,客户端组件绝对不能 import 里面的内容,否则打包时会有非常明确的报错。刚开始我图省事,直接在前端组件里 import 了 Prisma client,结果在浏览器 console 里看到了数据库连接串相关信息,这个问题如果上线会非常严重。正确做法是只通过 tRPC 暴露出去的数据接口来获取服务端数据。
另一个设计点是pages/api/trpc/[trpc].ts这个 route handler。tRPC 的所有请求都会汇聚到这个单一接口下,前端发出的调用本质上都是一个 POST 请求。对于习惯了 REST"一个资源一个接口"的人来说,这种做法需要时间适应,但它带来的好处是:所有 RPC 请求天然支持 HTTP 批量合并,一个页面多个查询可以同时发出去,减少了网络往返。
我还做了一次"最小闭环"验证。在没有写任何业务代码之前,先启动开发者服务器,打开默认欢迎页,在页面里调用api.health(脚手架现场生成的可替换示例 query),确认请求从浏览器发出、经过 Next.js API 路由、进入 tRPC router、返回结果,整条链路是通的。这步虽然不起眼,却能隔离掉"环境问题"和"代码问题",后续写业务时心里有底。
3. 数据库建模与 tRPC 查询:TypeScript 类型如何一路贯通
3.1 Prisma schema 设计和迁移流程
T3 Stack 默认用 Prisma 做数据库访问层。在设计报表工具的 schema 时,我第一步先把核心实体写出来:
model Report { id String @id @default(cuid()) title String description String? status ReportStatus @default(DRAFT) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId, status]) }这里的ReportStatus是枚举类型,把状态固定为 DRAFT、REVIEWING、PUBLISHED、ARCHIVED 四种。用枚举而不是字符串,对业务的好处是:状态流转变得可校验,后端在查询时可以按枚举过滤,前端拿到的数据里状态字段自动带上字面量类型,写 switch 判断时享受自动补全。
写完 schema 后执行:
npx prisma migrate dev --name initPrisma 会生成迁移 SQL 并同步数据库,同时会根据 schema 生成完整的 TypeScript 类型。从这一刻起,只要我在代码里写prisma.report.findMany(),返回结果的字段就全部被类型约束,"数据库字段和代码类型不一致"这种问题直接从根源上消失了。
3.2 tRPC router 的写法:从 query 到 protectedProcedure
接着我在server/api/routers/report.ts建立了报表模块的第一个 router:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const reportRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ status: z.enum(["DRAFT", "REVIEWING", "PUBLISHED", "ARCHIVED"]).optional(), page: z.number().min(1).default(1), pageSize: z.number().min(1).max(50).default(10), }) ) .query(async ({ ctx }) => { const where = { authorId: ctx.session.user.id, ...(input.status ? { status: input.status } : {}), }; return ctx.db.report.findMany({ where, orderBy: { createdAt: "desc" }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, }); }), });注意我在导出的方法里用的是protectedProcedure,它不是普通 procedure,而是一个已经接入会话检查的中间件包装。后面讲 NextAuth 时会展开说它的实现。这里先提一点:tRPC 的input用 zod 定义,这对前端是透明的。React 端调用api.report.list.useQuery({ status: "PUBLISHED" })时,TS 会自动推导出input的合法形状,传一个status: "NOT_EXIST"在编译阶段就会直接爆红。
3.3 第一次类型报错的完整排查链路
第一次把reportRouter接入页面时,我遇到了一个印象深刻的类型报错。前端调用一切正常,但在query回调里面写ctx.session.user.id时,TypeScript 提示说session可能是null,user也可能没有id属性。
排查过程我完整复述一遍,因为这个坑几乎每个第一次用 NextAuth 配 tRPC 的人都会踩:
- 第一步,查看
createTRPCContext的返回类型。在server/api/trpc.ts里,上下文对象是{ db, session },而session来自getServerSession(),它的类型是Session | null。 - 第二步,确认 NextAuth 的默认类型定义。默认情况下,
session.user只有name、email、image三个字段,id根本不在里面,所以哪怕运行时数据库里有 id,类型上也不存在。 - 第三步,修复策略。在
auth.ts中扩展Session和JWT类型,并在session回调里把用户 id 塞进去:
// src/types/next-auth.d.ts import type { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }再在 NextAuth 配置的callbacks.session里返回带id的 session:
callbacks: { session({ session, token }) { if (session.user) { session.user.id = token.sub!; } return session; }, }- 第四步,回到
trpc.ts里,protectedProcedure的实现中增加一道守卫,确保无会话时直接抛出明确的错误码,而不是让代码在运行时才炸:
import { TRPCError } from "@trpc/server"; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session }, }); });这一套组合拳打完,ctx.session.user.id的类型推导立刻变成了string,而且未登录用户会被挡在查询之外。从此以后,前端拿到的数据默认就是"当前登录用户自己的数据",再也不会看到别人家报表的字段。
4. Next.js 页面与 SSR:在服务端调用 tRPC 的几个细节
4.1 为什么我选 getServerSideProps 而不是 useEffect
报表工具虽然是个内部系统,但"首屏数据从哪来"这个问题依然值得认真考虑。很多习惯了纯客户端渲染的团队会直接在组件里写:
const { data } = api.report.list.useQuery({ page: 1 });然后 useEffect 里再处理 loading。对小项目这没问题,但当一个页面需要同时展示汇总数据、列表数据、用户信息时,客户端渲染会造成页面先空白,数据到了才渲染,刷新时还会有一闪而过的 loading 闪烁。
我在 t3code 里做了一个折中方案:首屏核心数据走 getServerSideProps 预取,列表翻页和筛选走客户端查询。这样用户每次打开页面,拿到的是完整 HTML,感官上更快,也不需要为每个页面写大量 loading 状态。
于是我在服务端直接创建 tRPC 的 caller:
import { createServerSideHelpers } from "@trpc/react-query/server"; import { appRouter } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; export const getServerSideProps = async () => { const helpers = createServerSideHelpers({ router: appRouter, ctx: await createTRPCContext({ req, res }), }); const reports = await helpers.report.list.fetch({ page: 1, pageSize: 10 }); return { props: { trpcState: helpers.dehydrate() } }; };这里最需要注意的细节是:getServerSideProps 的req和res必须传给createTRPCContext,这样服务端才能拿到 cookie、构建出带 session 的上下文。我最初踩过一个坑,贪图省事直接传了一个空对象上下文,结果服务端调用到了 protectedProcedure 时抛了 UNAUTHORIZED。
4.2 服务端调用不要直接享受批处理,但数据水合是免费的
服务端调用和客户端调用的一个区别是:客户端useQuery天然支持请求批处理,多个 query 会合并成一个 HTTP 请求,但createServerSideHelpers里的fetch是直接在 Node 进程里执行的,不走 HTTP,所以不存在"合并"问题,也没必要考虑。
真正爽的是数据水合。服务端把 tRPC 查询结果通过trpcState传给客户端后,客户端用api.report.list.useQuery()同参数调用时,会直接命中服务端已经查询过的数据,不会发第二次网络请求。
也就是说,我在服务端 fetch 了一遍,在客户端再用 hooks 取同样的数据,React Query 会自己判断数据已存在,直接进入 success 状态。这也意味着组件内的isLoading逻辑可以写得很干净,不需要担心刷新时闪 loading。
4.3 SSR 页面还要注意的一个隐藏问题
服务端调用 tRPC 时,如果某个 query 返回的数据体积很大,会被直接序列化进 HTML,哪怕客户端根本不展示。我在一个详情页里犯过这个错误,把一整个报表的所有明细行全部塞进了 getServerSideProps 的结果里,页面 HTML 一下子多了几 MB。
后来我把明细数据的加载拆成了客户端 query,只在用户滚动到详情区域时才请求,首屏 HTML 体积立刻降下来了。所以我的经验是:SSR 预取只做首屏真正需要的数据,不要把整条查询链全都搬到服务端。
5. 认证集成:NextAuth 与 tRPC 的中间件联动
5.1 NextAuth 配置里最容易忽略的三个点
T3 Stack 默认集成的登录方案是 NextAuth。我选择了 GitHub Provider 加 Prisma adapter,主要原因是内部团队本来就全员用 GitHub,少维护一套密码体系。
配置整体不复杂,但以下三个点我在实测里都被绊过:
- SESSION 策略要选 database。NextAuth 默认是 jwt 策略,jwt 用起来简单,但配合 Prisma adapter 时,两个用户几乎同时登录会偶发会话归属错乱。我在多台设备上同步测试时发现,database 策略配合数据库存储 session,稳定性明显更高,而且可以在后台做会话吊销。
- 环境变量必须完整。
NEXTAUTH_SECRET和NEXTAUTH_URL缺一不可。我经历过一次因为.env没同步到部署环境,登录跳转后被 NextAuth 拦截报安全错误的诡异情况,排查半天才意识到是两套环境变量不一致。 - Provider 的 Client ID 和 Client Secret 不要写进代码库。这个可能看起来像废话,但我在 README 里见过直接把 provider 配置贴出来的仓库,敏感信息一旦进 Git 历史,后面回收成本很高。
5.2 protectedProcedure 的权限控制怎么设计
认证模块接入 tRPC 之后,我只在根路由里定义了一次上下文:
const createTRPCContext = async ({ req, res }) => { const session = await getServerSession({ req, res }); return { db, session }; };由于我会在正确位置调用getServerSession,整个应用里的所有 procedure 都能拿到 session。需要登录的查询和变更,统一使用protectedProcedure,不需要登录的公开数据使用publicProcedure。这样写的好处是可以很直观地在 router 定义处看出这个接口的暴露面。
我还加了一层更细的权限控制。报表工具里有"查看者"和"管理员"两种角色,管理员能改配置项,查看者只能看数据。于是我在 protectedProcedure 的基础上扩展了一个adminProcedure:
const adminProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.session.user.role !== "ADMIN") { throw new TRPCError({ code: "FORBIDDEN" }); } return next(); });角色字段我存在数据库用户表里,通过 session 回调带进 session 对象,权限判断全部收敛到中间件层,业务代码只需要选对 procedure 就行。这样一来,前端组件里就不要再写一大堆if (user.role === "ADMIN")的分支判断了,因为数据入口已经做了控制。
6. 部署与优化:打包体积、缓存策略和真实体感
6.1 部署环境选择与 Serverless 函数拆分
t3code 最终部署在 Vercel 上,数据库用一个独立的 PostgreSQL 实例。选择 Vercel 不是因为它是最快的,而是因为 Next.js + tRPC + Prisma 这条链路在 Serverless 环境里已经被大量验证过,踩坑成本最低。
不过 Serverless 环境下需要额外注意一个点:Prisma client 的体积会影响冷启动时间。首次部署时我发现页面加载偶尔要等一两秒,排查之后发现是函数包里塞了 Prisma 的二进制引擎文件。
优化方式是两板斧:
- 在
package.json构建脚本中加入prisma generate,确保生成的 client 匹配当前平台。 - 在
next.config.js里配置serverComponentsExternalPackages: ["@prisma/client", "bcrypt"],避免 Next.js 打包器把 Prisma 二进制文件重复打包,造成体积膨胀。
优化完后冷启动时间从一秒多压到了可接受范围。另外我把一些非核心但耗时的查询(比如按月生成报表汇总)拆到了单独的 serverless function,通过定时任务触发,避免用户点击时即时计算。这种做法对内部工具尤其重要,因为报表数据会随业务增长越来越重,提前做一层异步化会让体验稳定很多。
6.2 数据请求层的性能优化细节
部署完成之后,我重点优化了列表页的请求模式。最初所有明细数据都走一个listquery,数据量一涨就卡,后来做了三件事:
- 列表改成分页查询,用 React Query 的
keepPreviousData保持翻页时不会闪空。 - 统计数据单独一个 query,加
refetchInterval: 30000让它 30 秒自动刷新一次,不影响列表交互。 - 筛选条件全部通过 tRPC 的 input 传给后端,由数据库完成过滤,而不是前端把全量数据拉到本地再 filter。这就是 tRPC + zod 的好处:input 的校验逻辑是服务端和前端共享同一份代码,类型安全不是纸面承诺。
还有一个小细节是httpBatchLink。T3 Stack 默认配置里就启用了请求批处理,同一个事件循环里发起的多个 tRPC 请求会被合并成一个 HTTP POST。我在页面里同时调用report.list和report.stats时,Network 面板里只能看到一个请求,页面的首屏加载时间因此少了一次往返。
6.3 上线压测后的真实体感
压测工具我用了简单的 k6 脚本,模拟 20 个并发用户持续访问列表页和详情页,持续 10 分钟。观察到的结论是:Serverless 函数在并发增加时会自动扩展,数据库连接池成了瓶颈。
我后来给 Prisma 的连接池配置加了比较保守的上限,并且开了 PgBouncer 做事务池。对内部工具来说,20 个并发已经是日常峰值,这套配置在压测里 CPU 和内存曲线都平稳,页面 P95 响应时间维持在 700ms 左右。坦白说这个数字并不是极致性能,但对一个内部报表工具而言,稳定可维护远比极限并发有意义。
7. 什么样的项目适合 T3 Stack:选型边界与我的最终建议
7.1 适合与不适合的场景
t3code 上线之后,我经常被同事和朋友问到同一个问题:T3 Stack 适合所有新项目吗?我的判断是"一定不是",下面这张表是我在实际使用后整理出来的参考:
| 适合的场景 | 不适合的场景 |
|---|---|
| 全栈 Web 应用,前后端由同一团队维护 | 需要向第三方系统大规模暴露 API 的开放平台 |
| 后台管理、内部工具、数据看板 | 纯内容展示站,几乎无交互逻辑 |
| 以表单、CRUD、权限控制为核心的业务系统 | 需要多语言异构服务、团队各自为政的大规模微服务 |
| 团队全员 TypeScript,不想维护接口文档 | 已有成熟 REST 体系和 SDK 生成链路的组织 |
第三方的 API 消费方如果完全不是 JavaScript 技术栈,tRPC 路由暴露出去的意义就不大,因为对方没法直接 import 类型,最终还是得面向 OpenAPI 文档,这时候 tRPC 相对 REST 的优势就没了。
7.2 给初次尝试 T3 Stack 的团队三条建议
按我踩过的坑,如果你决定上 T3 Stack,这三个建议应该能帮你少走弯路:
- 先把最小闭环跑通,再慢慢加模块。初始化的时候可以只选 TypeScript、Next.js、Tailwind,等熟悉了 tRPC 的写法,再引入 Prisma 和 NextAuth。一次全选会让"环境问题"和"学习问题"混在一起,排错时很痛苦。
- 数据库 schema 是你的契约,不是后端的私有实现。Prisma schema 一改就牵动整个类型链路,所以前期花在设计 model 上的时间非常值得。我后来养成一个习惯:改 model 之前先想清楚这个字段会被哪个 tRPC query 消费、会对前端类型产生什么影响。
- 不要把 tRPC 当成万能胶水。跨服务调用、消息队列、定时任务这些场景绕过 tRPC 直接调用内部函数或 Prisma 没任何问题,硬要在服务之间用 tRPC 反而会造成耦合。t3code 里的定时汇总任务就直接用 Prisma 操作,没有套 tRPC 壳子。
7.3 项目落地之后的一点感想
t3code 这个项目从决策到上线前后用了三周左右,最直观的变化是团队协作模式变了。以前每次后端接口有变更,群里至少要艾特一圈人,现在改完 Prisma schema,TypeScript 类型会立刻同步,前端编译阶段就能看到哪些地方需要调整。文档不用维护了,因为类型定义本身就是文档。
我个人在实际操作中的体会是:T3 Stack 最大的价值不是某一个框架或工具,而是它把类型安全种进了开发习惯里。你在写 router 的 input 时就顺手定义了校验规则,在写 Prisma schema 时就决定了前端能拿到什么字段,这些决定都发生在写业务逻辑之前,而不是之后去补。如果你准备接一个前后端同仓的全栈项目,t3code 这种链路值得认真试一次。