用 T3 Stack 构建全栈项目:tRPC 如何让前后端类型自动同步

过去大半年我都在折腾一个叫 t3code 的个人项目,起因特别朴素:我在做一个内部工具,前后端联调花的时间比写业务逻辑还多。类型不匹配、字段对不上、接口文档过期,这些问题每隔几天就来一轮。后来我把技术栈整体切到 T3 Stack,把 tRPC 作为全栈通信的主干,整个项目瞬间安静了很多。这篇博文就围绕 t3code 这个实战项目展开,讲讲 T3 Stack 到底是什么、怎么从零搭起来、核心链路怎么打通,以及部署和实测阶段那些普通文档里不会写的问题。

这套东西特别适合两类人:一类是受够了"前端一个类型、后端一个类型、文档又是第三种类型"的开发者,另一类是刚玩 Next.js 全栈、想找个不折腾的脚手架快速出活的人。如果你既想让代码有类型安全,又不想引入 GraphQL 那一整套复杂度,这篇文章应该能给你一条可复现的路径。

1. t3code 是什么:T3 Stack 在解决什么痛点

先把这个项目的定位说清楚。t3code 不是一个开源框架,而是我基于 T3 Stack 做的一个全栈项目代号,业务形态是个带积分体系的轻量商城。选它当实验品的原因很直接:有 Prisma 的数据建模、有 NextAuth 的登录态、有 tRPC 的实时类型推导,还有一批需要前后端协作的场景,基本覆盖了日常业务开发的大半问题。

1.1 T3 Stack 的来龙去脉

T3 Stack 最早是 Theo 搞出来的一个技术栈组合,主推四样东西:Next.js 负责页面和 SSR,TypeScript 负责全链路类型,Tailwind CSS 负责样式,tRPC 负责 API 层。后来 Prisma 和 NextAuth 也常被纳入默认配置,因为它们和前面四样配合得实在太顺。

我理解 T3 Stack 的核心哲学就一句话:能少一层就少一层。传统项目里,前端调接口要经过 REST 定义、接口文档、DTO 转换、类型声明,链路长且容易断。tRPC 直接让前后端共享同一个 TypeScript 类型世界,后端定义好的 procedure,前端调用时能自动拿到输入和输出类型,没有中间商赚差价。

拿 t3code 里的一个实际场景讲:用户查自己的积分明细。传统写法要写一个GET /api/points?userId=xxx,然后前端再声明一个PointsResponse接口。在 tRPC 里,后端写一个getMyPoints的 procedure,前端trpc.getMyPoints.useQuery()调用,返回类型自动就是后端的 Prisma 输出类型,不需要手写第二遍。

1.2 项目定位:一个真实可跑的积分商城

t3code 的业务模型很简单:用户登录、签到得积分、积分兑换商品、管理员审核订单。这不是一个炫技项目,而是一个把 T3 Stack 每个关键部件都"用脏"的实操项目。

简单归简单,它把 T3 Stack 的几大核心能力全踩了一遍:

  • 数据库建模用了 Prisma,涉及用户表、商品表、积分流水表、订单表四个实体;
  • 认证用 NextAuth 的 Credentials 登录,顺带处理 session 回传;
  • API 层只用 tRPC,包含公开 procedure、登录后 procedure、管理员 procedure 三种权限级别;
  • 前端数据流全靠 tRPC hooks,涉及 query、mutation、infinite query;
  • 部署选的是 Vercel + PostgreSQL,但生产环境里也验证了自托管方案的坑。

1.3 为什么选这三件套而不是其他全家桶

有人会问,现在全栈方案这么多,NestJS 也能做,Blitz.js 也能做,为什么非要 T3 Stack。我的对比经验是这样:

  • 跟 NestJS 相比,tRPC 不需要在前后端之间定义 controller、service、DTO 层,少掉的这些样板代码不是一个量级的;
  • 跟 GraphQL 相比,tRPC 没有 schema 文件同步的负担,改一行后端代码,前端编译立刻感知,本质上是"把类型当作协议";
  • 跟传统的 Express + React 双仓库相比,T3 Stack 默认就是 monorepo-less 的单仓库,Next.js 同时承载前后端,开发和部署心智都简化了。

T3 Stack 的代价也很明确:它对"前后端都是 TypeScript"有硬性要求。如果以后端 Java 为主,或者前端要用 Vue 生态,这套组合就帮不上忙。它适合的永远是一开始就能全栈 TypeScript 的团队或独立开发者。

2. 初始化:create-t3-app 与工程目录的细节

现在进入实操环节。t3code 在初始化阶段我就踩了不少无关紧要但又很耗时的坑,这里把完整流程和每一步的选择逻辑讲清楚。

2.1 脚手架初始化步骤

T3 官方提供了一条初始化命令:

npm create t3-app@latest

执行之后会问你要启用哪些模块。t3code 当初勾选了 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth、ESLint,没勾 Pascal 和 Docker。选不选 Docker 取决于你是否要直接起本地数据库,我习惯直接用线上 PostgreSQL 的免费额度,所以没选。

项目名直接填t3code,脚手架会生成一个带完整配置的 Next.js 工程。这一步其实把大量配置工作都做完了,tsconfig.json、tailwind.config.ts、next.config.mjs都是可以直接用的状态,这一点非常省事。

2.2 目录里每一块都是干什么的

很多初学者初始化完就晕了,目录里src/server/api/trpc.ts、src/server/db.ts、src/trpc/server.ts看半天不知道谁干谁。我基于 t3code 的实际布局拆一下:

路径职责
src/server/db.tsPrisma Client 的初始化单例,负责数据库连接
src/server/api/trpc.tstRPC router 的根工厂,统一定义上下文类型和鉴权中间件
src/server/api/routers/按业务拆分 router 文件,比如user.ts、product.ts、order.ts
src/trpc/server.ts服务端调用 tRPC 的封装,供 RSC 和 Server Component 使用
src/trpc/react.tsx前端专用 tRPC hooks 的 Provider 封装
src/server/auth.tsNextAuth 配置,含 session 回调与 JWT 策略

我在项目里把"上下文"这个点想明白之后,整个 tRPC 才算真正通透了。所谓上下文,就是每个 procedure 被调用时都能拿到的全局对象,t3code 里包含 db 实例和 session 信息。这样每个 procedure 不需要自己 new PrismaClient,也不用手动查一遍登录态,直接在函数体里用ctx.session?.user就行。

2.3 环境变量与配置文件的一次性说明

.env文件在 create-t3-app 生成的工程里会被自动引用,但有个细节容易忽略:.env的字段模板在.env.example里。数据库连接串、NextAuth 的 secret、OAuth 回调地址,如果这三样没填对,启动时八成是白屏加报错,而不是给你一个清晰的提示。

以 t3code 的开发环境为例,连接串长这样:

DATABASE_URL="postgresql://username:password@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="openssl rand -base64 32 生成的随机串" NEXTAUTH_URL="http://localhost:3000"

有一个比较容易踩的点:如果你把NEXTAUTH_SECRET空着,NextAuth 在部分版本里不会报错,但每次部署后会生成新的会话密钥,导致线上用户频繁掉线。这个坑我在 t3code 的预发布环境里踩过一次,后来直接把.env的 secret 也同步进部署平台的变量里,掉线问题才彻底消停。

3. 打通数据链路:Schema、认证、Router 三件套

这一节是 t3code 的核心,也是 T3 Stack 最迷人的地方。数据库表怎么设计、登录态怎么进上下文、业务接口怎么暴露,这三件事在传统项目里是割裂的,但在 T3 Stack 里会被一次性焊死。

3.1 Prisma 模型设计:订单表与积分流水

t3code 的schema.prisma我有意设计了几组关联关系,方便验证 Prisma 在真实业务里的表现。用户表和积分账户用一对一,积分流水通过userId关联用户,订单表则通过组合外键同时关联用户和商品。

model User { id String @id @default(cuid()) name String? email String @unique points Int @default(0) orders Order[] txns PointsTxn[] accounts Account[] sessions Session[] } model PointsTxn { id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id]) delta Int reason String createdAt DateTime @default(now()) }

积分流水里我用delta而不是pointsDelta,纯属个人习惯,正数表示增加、负数表示扣减。这个设计让查询历史明细异常简单,而且后面跟业务对账时只需按SUM(delta)计算,不用管中间状态。

3.2 NextAuth 接入的配置细节

t3code 选择使用 Credentials 登录,因为我不想在演示项目里强依赖第三方 OAuth。Credentials 方案有一个关键问题:NextAuth 默认的 Credentials 不返回数据库用户对象,只返回调用者的返回值。因此我必须在authorize里查出完整用户,并让 JWT 和 session 回调把 id 塞进去。

export const authOptions: NextAuthOptions = { session: { strategy: "jwt" }, callbacks: { jwt({ token, user }) { if (user) token.id = user.id; return token; }, session({ session, token }) { if (session.user && token.id) { session.user.id = token.id; } return session; }, }, };

这里很多教程会直接照抄,但不解释为什么。核心原因是:默认的 session 里只有 email 和 name,没有数据库主键。而后续所有业务查询都要WHERE userId = ?,没有 id 就得用 email 反查一次用户表,多一次查询不说,还容易埋下 N+1 的隐患。把 id 写进 token 和 session,省掉的不只是代码,还有每次请求的隐含成本。

3.3 tRPC Router:把类型安全焊死在请求上

t3code 的根 router 由createTRPCRouter聚合各业务子路由,这个结构在 T3 Stack 里是固定的:

export const appRouter = createTRPCRouter({ user: userRouter, product: productRouter, order: orderRouter, points: pointsRouter, });

每个子 router 上的 procedure 需要权限时,用protectedProcedure,这是我在trpc.ts里基于中间件扩展出来的:

export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, user: ctx.session.user } }); });

这个中间件在 t3code 里承担了"登录后才能调用"的守卫职责。它不依赖前端传 token,也不需要在每个业务函数里手动判断 session,一次配置,全站生效。管理员接口我会再套一个 role 判断,逻辑完全一样,只是换成检查ctx.session.user.role。

4. 前后端契约自动同步的三类实战场景

很多没实际用过 tRPC 的人觉得"类型同步"只是个营销词,或者以为就是编辑器提示好看一点。我在 t3code 里验证了三个场景,直接让"契约同步"变成了开发流程里的硬约束,而不是靠自觉。

4.1 改一个字段,前端立刻编译失败

有一次我给商品表加了个status字段,用来上下架。改完 Prisma schema、跑完prisma migrate,t3code 前端里所有用到商品列表的组件开始批量报错:

Property 'status' is missing in type ...

这个报错不是我手动改前端类型造成的,而是 tRPC 把 Prisma 的输入输出类型直接暴露给了前端。只要调用product.getAll.useQuery(),返回类型就是Product[]的精确形状。这种"改完后端,编译期立刻发现前端哪里漏了"的体验,用传统 REST 是做不到的。

4.2 受保护 procedure 的调用方检测

t3code 里兑换商品是个order.createmutation,只能在登录后调用。我用的是protectedProcedure,前端如果不小心在未登录页面直接调用,运行时返回UNAUTHORIZED,但更早一层,编辑器里就能看见 procedure 的上下文类型与未登录状态不匹配,这是类型层面的提醒。

我实际开发时反而很少依赖这个编辑器提示,因为大部分情况业务逻辑天然就是要登录的。真正值钱的是:这些条件被编译器和运行时双重校验,不会等到线上用户点按钮才发现。

4.3 无限滚动列表与 useInfiniteQuery

积分明细在手机上一定要做分页。tRPC 对无限滚动有官方支持,用法跟 TanStack Query 很接近:

const { data, fetchNextPage } = trpc.points.getMyHistory.useInfiniteQuery( { pageSize: 20 }, { getNextPageParam: (lastPage) => lastPage.nextCursor } );

实现NextPageParam只需要在 procedure 里返回一个可序列化的游标。t3code 用createdAt+id做游标,比skip/take稳得多,因为新增数据不会导致分页错位。这个细节是很多人容易忽略的,翻着翻着突然重复几条或者跳几条,多半就是用了skip/take而列表又在持续写入。

5. 部署和实测阶段的五个坑

初始化、开发、类型检查都顺利,不等于上线顺利。t3code 在部署到生产环境的过程中,我记录了不少值得展开的问题,挑五个影响最大的讲。

5.1 迁移执行与其他平台的差异

Vercel 部署 Next.js 很方便,但 Prisma 迁移这件事不会自动做。本地跑prisma migrate dev很顺手,线上数据库可没有这个命令。

我的方案是把数据库迁移拆出来,放在 CI 里执行,或者在有网络权限的机器上手动跑:

npx prisma migrate deploy

migrate deploy和migrate dev不一样,后者会触发生成新的迁移文件和 seed,前者只应用已经存在的迁移记录。生产环境必须用 deploy,这是原则性问题。另外,如果 Prisma Client 是构建时生成的,记得在部署平台设置prisma generate的构建命令,否则线上会提示找不到 client。

5.2 环境变量缺失的排查姿势

Next.js 在构建时就内联了大部分环境变量。如果你在.env里写好了,但部署平台的环境变量列表里漏了一个,最容易出现的现象是页面能打开、数据库相关接口全部报错。t3code 预发布时我遇到过一次DATABASE_URL配置错,但前端首屏没有直接展示这个错误,直到我点登录才看见底层异常。

排查这类问题,我从那次经历里总结出一个固定流程:先看构建日志有没有PrismaClientInitializationError,再看运行时日志有没有DATABASE_URL相关的 undefined,最后检查.env.production或平台变量的拼写。能进这三个位置翻一遍,80% 的环境变量问题都能定位。

5.3 生产构建体积与 tRPC batching

tRPC 有个自动批处理机制,会在极短时间内把多个 query 合并成一个 HTTP 请求。部署后我发现首屏请求数虽然少了,但单个请求体变大了。原因是 tRPC 内部默认启用 HTTP 压缩,大批量请求反而会占更多带宽。

解决办法是在服务端关闭或调整 batching 上限,也可以对 traceroute 做按需拆分。我的实际结论是:小项目不用管,等请求聚合超过 50 个以上再考虑优化。t3code 的积分历史页大概 20 个请求聚合,实测体积可接受,就没有过度调优。

5.4 缓存策略与 SSR 细节

Next.js 的 Server Component 和 tRPC 结合时,要特别注意缓存语义。useQuery是客户端缓存,默认是 stale-while-revalidate,数据不用等刷新就会先显示旧值。对积分明细这种敏感数据,我强制设置了refetchOnWindowFocus,防止用户在另一个标签页兑换后回到当前页看不到新积分。

服务端 render 时还有个隐藏问题:如果页面是静态生成的,服务端拿不到用户 session,tRPC 上下文的 session 就是 null。这时候你会看到受保护接口在首屏返回 UNAUTHORIZED,但前端水合后又正常。解决方式是明确把这类页面标记为动态渲染:

export const dynamic = "force-dynamic";

这个坑我在 t3code 的兑换记录页踩过,症状很迷惑,因为本地开发一切正常,只有部署到 Vercel 后才偶现。

5.5 压测结论与优化措施

最后给一个不严谨但真实的压测数据。t3code 在 16 核服务器上部署,用简单的场景模拟:100 并发用户同时刷首页和查积分。数据库连接池按默认值配置时,P95 响应时间大概 260ms,但 P99 会飙到 900ms。问题出在 Prisma 默认连接池和数据库实例的连接上限不匹配。

我把连接池上限调低,并设置合理的超时之后,P99 稳定在 420ms 左右。这不是什么了不起的性能,但作为一个小型业务系统已经够用。如果你要把 t3code 的模式复制到生产,建议一开始就把连接池参数当成一等配置,而不是默认值跑到底。

6. 用 t3code 做基本功训练:给同样想上手的开发者三个建议

文章的最后,不谈理论,就说说我实际用 t3code 玩下来之后,对打算上手 T3 Stack 的开发者最想说的三句话,每句话背后都有真实教训。

6.1 建议一:先放弃单独学 tRPC

tRPC 单独学很难——因为它脱离 Next.js 和 Prisma 之后就失去了"类型自动同步"的魔法感。我见过不少人先打开 tRPC 文档,看完创建 router 和 procedure 的章节就放弃了,觉得它就是个普通 RPC 框架。其实正确姿势是直接起一个 create-t3-app 项目,从写第一个业务 procedure 开始,让前端调用它,看一眼编辑器里的自动补全,立刻就能理解这个框架的价值。

6.2 建议二:用业务场景驱动学习,而不是按组件学

T3 Stack 的组成部件太多,按组件学容易陷入"每个都懂,合起来不会用"的困境。t3code 之所以顺,是因为我绑定了一个具体业务目标:我要做出一个能登录、能签到、能兑换、能看到历史记录的商城。每一个 Prisma 模型、每一个 tRPC procedure、每一个 NextAuth 回调,都是这个业务目标逼出来的。强烈建议你换个自己熟悉的业务场景从头走一遍,比你读十遍文档都有效。

6.3 建议三:注意版本锁定的问题

这是我最想叮嘱的。T3 Stack 是多个框架的组合,版本联动非常敏感。我曾在某个时间段里直接把 Next.js 升到最新版,结果 tRPC 服务端封装报兼容性错误,排查了一个晚上才发现是版本差异。后来我把 create-t3-app 生成的package.json视为一个版本基线,除非有明确升级理由,否则不单独升级任何一个核心依赖。

真想要升级的话,最安全的路径是重新跑一遍npm create t3-app@latest,再把业务代码移植过去,而不是在现有项目里逐个依赖升。听起来麻烦,实际上比排查跨版本 bug 的成本低太多。

t3code 这个项目现在还在继续跑,我偶尔会给它加几个小功能,比如管理员后台的订单审核、商品上下架、积分对账报表。每次加功能的时候我都能明显感觉到,在 T3 Stack 这套体系里,最爽的不是写代码快,而是改完后端类型的那一刻,前端早就帮你把关过了。如果你也在被接口同步和类型爆炸折磨,用 t3code 的结构亲手搭一个业务系统,你会回来跟我说同样的话。