近两年我一直在做全栈类的中型业务系统,踩过最多的坑不在业务逻辑本身,而在“前后端各有一套类型定义”这件事上。后端用 Java 或 Node 写了一套 DTO,前端又用 TS 手撸一遍接口类型,两边字段一不一致全靠开会和自觉。直到我认真研究并实践了 t3code 这套基于 T3 技术栈的工程化项目,才体会到什么叫“类型从数据库一路穿到按钮”,这才有了这篇文章。
这篇文章不是工具文档的翻译,也不是教程复读。我会结合自己实际参与的项目,把 t3code 的项目结构、tRPC 请求闭环、生产部署经验、以及二次开发时的注意点一次性讲透。适合正在接触 T3 Stack 的全栈开发者、想重构现有前后端分离架构的团队,以及对端到端类型安全感兴趣的朋友。
1. 类型安全为什么值得把整个技术栈重新排一次座次
很多团队选型时先看框架热度,再看生态和招聘难度,类型安全往往排在最末。但真正进入维护期之后,类型问题造成的返工远超预期。我现在倾向于把“类型安全”当成架构级需求来对待,t3code 之所以让我感兴趣,就是因为它把类型安全前置到了技术选型层面。
1.1 从“两头各写一遍类型”到“全链路都是同一个类型”
传统前后端分离项目里,最常见的做法是后端定义接口返回结构,前端根据接口文档或 Swagger 生成一份 TS 类型。这里的问题不在于“多写一遍”,而在于两份类型之间没有编译期的绑定关系。后端的字段名只要改了一个,前端可能要到运行时才发现数据变了,甚至数据变了但页面不报错,只是显示异常。
t3code 用的 tRPC 解决思路完全不同:后端把 API 直接定义成 TypeScript 函数,前端像调用本地函数一样调用远程过程,类型推断完全由编译期完成。换句话讲,你不再写/api/post/create这种字符串形式的接口,而是直接写trpc.post.create.useMutation(),返回值、入参、错误类型全部跟着函数签名走。
这个体验初看没什么大不了,但当我改了数据库字段之后,前端调用处直接爆红,那种“被类型系统追着改代码”的安全感,是用过就回不去的。
1.2 Next.js、Prisma、NextAuth、Tailwind 在 t3code 里各自的位置
T3 技术栈通常包含四个人:Next.js 负责应用框架,TypeScript 负责语言基础,tRPC 负责 API 通信,Prisma 负责数据库访问,有时候还会加 NextAuth 做认证、Tailwind 做样式。t3code 的工程实践里,每个组件都有自己的明确边界:
| 组件 | 核心职责 | 在 t3code 中的定位 |
|---|---|---|
| Next.js | 路由、渲染模式、SSR/SSG | 应用外壳,负责页面与 API 入口 |
| TypeScript | 类型系统 | 所有跨端共享类型的载体 |
| tRPC | 远程过程调用 | 替代 REST 路由,前后端类型直通 |
| Prisma | ORM 与数据建模 | 数据库层的类型源头 |
| NextAuth | 会话与认证 | 提供用户态上下文,配合 tRPC 中间件做鉴权 |
| Tailwind | 原子化 CSS | 提高样式迭代速度 |
这里最容易被忽略的是 Prisma 和 tRPC 的衔接。Prisma 会根据schema.prisma生成客户端类型,tRPC 的 router 返回值直接引用这些类型,前端再通过 tRPC 拿到推断后的类型。一个字段从数据库到前端页面,中间没有任何手工声明的断层。
1.3 tRPC 与 REST、GraphQL 的本质差别
很多第一次接触 tRPC 的人会问:为什么不用 GraphQL?我的回答是:场景不一样。
GraphQL 解决的是“客户端按需取字段”和“多端契约管理”的问题,它需要一个 schema 语言,通常还要配合 codegen,类型由生成结果提供。tRPC 则更轻,它不需要语言无关的 schema,直接把 TS 类型当成契约。只要前后端都是 TypeScript,tRPC 的体验几乎是零成本。
REST 的缺点是接口分散、字段类型需要二次维护,但它胜在简单直接、工具链成熟。t3code 中使用 tRPC 的典型定位是内部 BFF 接口,而真正需要开放给第三方 App 的公开 API,我依然建议单独用 REST 或 GraphQL 暴露。tRPC 不是取代一切,而是把内部的类型摩擦降到零。
2. t3code 的项目骨架:目录边界怎么划才能不变成一锅粥
我见过太多项目前三个月风生水起,半年后目录结构开始失控。t3code 的目录边界设计值得单独拿出来讲,因为它不只是“好看”,而是让类型安全的链路真正跑起来的基础。
2.1 server 端和页面端的边界是“流程”而不是“文件夹”
看 t3code 类项目的源码时,很多人第一眼会困惑:为什么 router 放在src/server,页面直接放在src/pages,它们之间没有传统的services层?
并不是所有业务都需要一层services。t3code 的逻辑是:数据库访问交给 Prisma,业务编排直接写在 router 的 procedure 里,页面只负责 UI 和调用 tRPC。如果业务复杂到某个 procedure 超过一百行,再抽成一个独立的 service 函数放到src/server下面,而不是在src/server里先预建一堆空壳目录。
我实际维护下来,这个边界比“controller-service-repository”三层更不容易腐烂。原因是 tRPC 的 procedure 天然承担了 controller 的职责,Prisma 承担了 repository 的职责,中间需要手动抽象的只有真正的业务逻辑。手工代码越少,类型断层的风险越低。
2.2 路由注册表与上下文设计:入口越集中,权限越好管
t3code 的 tRPC 入口通常集中在两个文件:trpc.ts和root.ts。trpc.ts负责初始化 tRPC 实例、定义公共中间件、注入上下文;root.ts负责把各个业务 router 合并成一个 appRouter。
我通常会这样组织:
// src/server/api/trpc.ts import { initTRPC, TRPCError } from '@trpc/server'; import { type Context } from '../auth'; const t = initTRPC.context<Context>().create(); const isAuthed = t.middleware(({ next, ctx }) => { if (!ctx.session?.user) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return next({ ctx: { session: ctx.session } }); }); export const router = t.router; export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(isAuthed);这个文件里最容易出错的地方是 Context 类型的设计。如果你的 Context 需要从数据库读取用户信息,最好在中间件里用 Prisma 查询一次并把结果挂到ctx上,而不是把全部用户表数据塞进去。多个 procedure 共享用户信息时,可以单独写一个getUserhelper,避免每个接口都重复查库。
src/server/ api/ root.ts trpc.ts routers/ post.ts user.ts db.ts auth.ts这个结构的好处是:一旦新增业务模块,只需要在routers目录下面加一个文件,然后在root.ts里挂载一次。权限控制通过protectedProcedure统一解决,不散落在各个 handler 中。
2.3 数据库模型层的约束必须前置
用 Prisma 时我见过很多人只把schema.prisma当成建表工具,写完之后一堆业务约束放在应用层判断,数据库层形同虚设。t3code 项目的做法通常是在 schema 层直接把约束定义清楚:
model Post { id String @id @default(cuid()) title String @db.VarChar(200) content String @db.Text published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model User { id String @id @default(cuid()) email String @unique name String? posts Post[] }把@unique、@default、@db.VarChar这类约束写在 schema 里,看起来只是顺手,实际上意义重大。原因在于 Prisma client 的类型是从这里生成的,数据库约束直接决定了你能写进 client 的调用类型。如果你想省这一步,后面应用层的校验代码会越写越厚,而且总有漏网之鱼。
我常用的一条原则:凡是数据库能保证的约束,不要留给应用层做第二道防线。应用层只做“数据库约束之外需要业务上下文才能判断的校验”。
3. 一条业务请求在 t3code 里的完整闭环:从点击按钮到数据库落库
理解了目录结构之后,我们来看一条真实请求在前端、tRPC router、Prisma 之间怎么流转。我用“创建一篇文章”这个最常见场景展开。
3.1 输入校验、中间件鉴权、数据落库三个动作的顺序
首先是定义 Prisma schema,就是上面那部分。接着在 router 里写 procedure:
// src/server/api/routers/post.ts import { z } from 'zod'; import { router, protectedProcedure } from '../trpc'; const createPostInput = z.object({ title: z.string().min(1).max(200), content: z.string().min(1), published: z.boolean().optional(), }); export const postRouter = router({ create: protectedProcedure .input(createPostInput) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, published: input.published ?? false, authorId: ctx.session.user.id, }, }); }), });注意这里的执行顺序:tRPC 先跑中间件做鉴权,再对input做 Zod 校验,最后才进入 mutation 函数。顺序不能反过来,否则未登录用户可能会先触发一次校验错误,暴露业务字段结构,虽然不是致命问题,但信息暴露越少越好。
前端调用侧也非常直接:
const utils = trpc.useUtils(); const mutation = trpc.post.create.useMutation({ onSuccess: () => { utils.post.list.invalidate(); }, }); function handleSubmit(data: { title: string; content: string }) { mutation.mutate(data); }这里还有一个容易被忽略的细节:utils.post.list.invalidate()。创建完数据后,你必须让前端的列表查询重新验证,否则旧数据会一直滞留在缓存里。很多新手第一次写 tRPC 都会发现“创建成功了但列表不更新”,就是因为漏了 invalidate 这一行。
3.2 Zod 校验的错误信息能一字不差地回到前端
传统 REST 项目里,后端校验失败返回400 { message: 'xxx' },前端要根据错误码自己映射文案。tRPC 的好处是 Zod 校验错误会原样序列化成结构化信息,前端拿到的error对象里有完整的fieldErrors,可以直接绑定到表单控件上。
如果我们创建一个自定义错误:
import { TRPCError } from '@trpc/server'; throw new TRPCError({ code: 'CONFLICT', message: '该文章的标题已经存在', });前端捕获时:
mutation.error?.message; // '该文章的标题已经存在'这个体验和“前端本地校验”已经非常接近了。我实际使用中的建议是:把 Zod schema 单独抽成一个文件,放在src/server下。因为前端表单可能也复用这份 schema 做即时校验,虽然 tRPC 不会自动帮你同步到前端,但你可以手动 import 共享类型文件。在 t3code 的实践中,我会单独建立src/types或src/schemas目录,把跨端共享的 schema 放进去。
3.3 SEO 场景下,数据获取不能全都走 hooks
tRPC 的useQuery是前端 hooks 形态,天然为客户端渲染设计。但一个内容站点需要 SEO,首页文章列表必须服务端渲染。t3code 里有两种方案:
一种是使用trpcServer直接在getServerSideProps或getStaticProps里调用 router 的 query。通过serverClient创建时传入 headers 里的 session,让服务端请求带上同样的上下文;另一种是用 tRPC 的ssg预取,在_app.tsx里提前 identify:
const ssg = trpcReact.ssg.initServer({ ctx }); await ssg.post.list.fetch();然后把数据通过dehydrate注入 React Query 的 hydration 缓存。前端页面直接trpc.post.list.useQuery(),它会先读取缓存,不发二次请求。
我用这两种方案时的判断标准是:页面若以内容展示为主,选服务端直查;页面若以交互为主、SEO 需求弱,选客户端 hooks。最忌一个页面里混用太多数据获取方式,既难缓存也难排查。
服务端直查还会引入一个坑:getServerSideProps里如果直接调用受保护的 procedure,要手动构造ctx.session,否则中间件会因为拿不到 session 而报错。t3code 项目里通常会提供一个createContext辅助函数,服务端场景直接传 req,别自己拆 token。
4. 生产环境部署时的几个实际问题:我踩过的和帮你避开的
开发环境跑通 demo 只需要五分钟,真正让人头疼的是部署阶段。t3code 相关项目我前后部署过三四个环境,这里把最有代表性的问题列出来。
4.1 部署前置检查:环境变量、Prisma 迁移、构建缓存
上生产环境之前,至少确认下面这组环境变量:
DATABASE_URL=postgresql://user:password@host:5432/db NEXTAUTH_SECRET=your-secret NEXTAUTH_URL=https://your-domain.com如果用了第三方 OAuth,还要有对应的GITHUB_ID、GITHUB_SECRET等。最容易忽略的是NEXTAUTH_URL,本地开发它是http://localhost:3000,上生产如果忘了改成域名,NextAuth 的回跳地址会错乱,用户登录后会被弹回来或者直接 500。
Prisma 的迁移不要等到部署后在服务器上临时执行。我习惯在 CI 流程里跑:
npx prisma migrate deploy npx prisma generate注意是migrate deploy,不是migrate dev。dev会尝试创建新的迁移文件并交互式确认,主要用于本地开发;deploy只把已存在的 migration 应用到数据库,适合 CI。生产环境如果在构建镜像时直接跑prisma generate,一定要确保目标平台与本地一致,否则会出现 client 二进制不匹配。
4.2 上线后最容易翻车的三个坑
坑一:Prisma Client 初始化为 undefined。 现象是构建成功,但运行时上报PrismaClient is not defined。原因通常是@prisma/client的postinstall没有正确执行,或者某些框架把 Prisma 当外部包排除。我记得有一个比较稳妥的做法是统一使用 singleton 模式:
// src/server/db.ts import { PrismaClient } from '@prisma/client'; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'], }); if (process.env.NODE_ENV !== 'production') { globalForPrisma.prisma = db; }用 global 缓存是为了避免开发环境热更新时创建多个 PrismaClient 实例,连接数暴涨。这个问题在 serverless 环境和本地开发都值得注意。
坑二:反向代理环境下的 NextAuth 回跳地址错乱。 如果前面挂了 Nginx 或云负载均衡,NextAuth会根据请求头里的 host 判断当前站点地址。代理层如果没有正确透传X-Forwarded-Host,登录回调地址就会变成内网 IP。解决方式是在 NextAuth 配置里固定NEXTAUTH_URL,并确保代理不重写Authorization和Cookie头。
坑三:在 Server Component 或getServerSideProps里直接使用了 tRPC 的 hooks API。 例如写trpc.post.list.useQuery()放在 RSC 里,前端会直接报错。因为 hooks 依赖 React Query 的 Provider 和请求上下文,服务端预取必须用服务端形态的 client,并配合 dehydrate/hydrate。这个错误在本地开发时未必能触发,往往是在部署后首屏渲染阶段才暴露。
4.3 部署形态:单一容器和 Serverless 怎么选
t3code 这类全栈项目可以两种形态部署。如果业务包含长连接、定时任务或大量 SSE 推送,我倾向做成 Node 服务器部署在容器里;如果业务是传统 CRUD 加上轻交互页面,Serverless 平台(如 Vercel)更省心,但要注意函数冷启动和数据库连接上限。
| 维度 | 容器化 Node | Serverless |
|---|---|---|
| 数据库连接 | 长连接复用较稳定 | 要与 PgBouncer 配合 |
| 冷启动 | 无 | 存在 |
| 流式响应 | 支持好 | 受平台限制 |
| 运维成本 | 需要管镜像和部署 | 平台托管 |
| 适合场景 | 重业务、定时器、WebSocket | 官网、后台、轻接口 |
如果数据库用的是 PostgreSQL,Serverless 部署时强烈建议用连接池而不是直连主库,否则并发一上来,数据库连接瞬间被打满。t3code 项目里通常会把DATABASE_URL和连接池地址拆成两个环境变量,部署时按环境切换。
5. 从 t3code 出发扩展业务:三条边界和一份扩展清单
t3code 只是一个起点,业务总是要长大的。我在几个项目里用了类似架构,扩展业务时沉淀了一些可复用的套路。
5.1 新模块的添加套路:Schema 先行、Router 次之、页面兜底
每新增一个业务模块,我基本下面这个顺序:
- 在
schema.prisma增加 model,建好索引、外键、唯一约束。 - 跑
npx prisma migrate dev,生成迁移文件,然后npx prisma generate。 - 在
routers/下新建 router,定义 Zod 输入 schema、公开或受保护的 procedure。 - 在
root.ts里挂载该 router。 - 前端页面写
useQuery/useMutation,调用invalidate让列表更新。
很多同学会先写页面,再写接口,最后补数据库,顺序倒了以后会觉得处处都差一口气。把 schema 放在最前面,是因为所有类型都源于 schema,它定了,后面每一步都是填坑式开发。
5.2 类型安全不是万能的:什么时候该拆出 tRPC 生态
tRPC 的端到端类型安全是所有代码都在同一类型系统内的前提下成立的。一旦有非 TypeScript 服务介入了,这种优势就会打折扣。我遇到过几种情况:
- 消息队列消费者用 Python 实现,它读不到 TS 类型;
- 定时任务需要部分 service 逻辑,但不想启动整个 Next 应用;
- 需要给第三方开放 API,tRPC 的 session 和 input 模式不适合外部调用。
我的做法是:把可复用的业务逻辑独立成src/server/lib下的纯函数,尽量让它们不依赖 Next.js 的 req/res。这样即使以后拆微服务,核心逻辑也能平移。t3code 的全栈集成确实方便,但不要让所有逻辑都长在 tRPC router 上,否则以后想拆分时就得把函数从 router 里一个个抠出来,非常痛苦。
5.3 团队协作时,怎么保持类型安全不劣化
代码是团队写的,类型安全也会被松弛掉。最常见的劣化方式是到处用any,其次是把 zod schema 写成全字段optional,最后是前端绕过 tRPC 直接 fetch 某个 URL。
我提议几条直接可落的规约:
- 开启
strict: true,禁止在业务代码里出现显式any,必要时用unknown加类型守卫收窄。 - 共享的 Zod schema 或普通 TS 类型统一放
src/types或src/schemas,不允许页面里内联声明接口类型。 - 所有数据库查询必须走 Prisma Client,不能绕过 ORM 拼接 SQL。
- tRPC 的
procedure禁止直接从trpc.ts全量导出,路由的权限中间件尽量使用protectedProcedure,防止有人偷懒用公开 procedure 实现了敏感接口。
实际维护中,最后一条最管用。只要把受保护 procedure 作为默认导出,把 public procedure 命名得很刺眼,团队在开发时自然会倾向于先选 protected 版本,权限漏洞也会少很多。
我印象最深的一次问题,就是团队有一个内部接口误用了publicProcedure,导致未登录用户能读到部分数据。虽然只是非敏感信息,但那次之后,我直接把公开和受保护的 procedure 在不同文件里导出,让使用者必须显式选择。工程规范这种东西,最好靠结构和命名约束,而不是靠代码 review 时肉眼监督。
如果要把 t3code 再往外延伸,我还会考虑加一层日志追踪:在 tRPC 的onError钩子里记录 procedure 路径、错误码、耗时,把这些数据喂给可观测平台。现在排查问题时最缺的就是“这条请求到底经过了哪几步”的链路视图,tRPC 的 procedure 天然是链路上的一个 span,配合 OpenTelemetry 的@trpc/server适配器,可以很优雅地把请求链路可视化出来。这个部分我还在迭代中,上面分享的部署和扩展经验可以直接套用,链路观测等跑稳了再单独写一篇分享。