T3 Stack实战:用tRPC与Prisma实现端到端类型安全的全栈工程化

发布时间:2026/10/28 16:16:00
T3 Stack实战:用tRPC与Prisma实现端到端类型安全的全栈工程化 近两年我一直在做全栈类的中型业务系统踩过最多的坑不在业务逻辑本身而在“前后端各有一套类型定义”这件事上。后端用 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 路由前后端类型直通PrismaORM 与数据建模数据库层的类型源头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 类型当成契约。只要前后端都是 TypeScripttRPC 的体验几乎是零成本。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.contextContext().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 场景下数据获取不能全都走 hookstRPC 的useQuery是前端 hooks 形态天然为客户端渲染设计。但一个内容站点需要 SEO首页文章列表必须服务端渲染。t3code 里有两种方案一种是使用trpcServer直接在getServerSideProps或getStaticProps里调用 router 的 query。通过serverClient创建时传入 headers 里的 session让服务端请求带上同样的上下文另一种是用 tRPC 的ssg预取在_app.tsx里提前 identifyconst 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_URLpostgresql://user:passwordhost:5432/db NEXTAUTH_SECRETyour-secret NEXTAUTH_URLhttps://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更省心但要注意函数冷启动和数据库连接上限。维度容器化 NodeServerless数据库连接长连接复用较稳定要与 PgBouncer 配合冷启动无存在流式响应支持好受平台限制运维成本需要管镜像和部署平台托管适合场景重业务、定时器、WebSocket官网、后台、轻接口如果数据库用的是 PostgreSQLServerless 部署时强烈建议用连接池而不是直连主库否则并发一上来数据库连接瞬间被打满。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 应用需要给第三方开放 APItRPC 的 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适配器可以很优雅地把请求链路可视化出来。这个部分我还在迭代中上面分享的部署和扩展经验可以直接套用链路观测等跑稳了再单独写一篇分享。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询