用tRPC+Prisma打造端到端类型安全的全栈应用
如果你跟我一样常年被“前端一个类型、后端一个类型、数据库又一个类型”这件事折腾那你大概能理解我把新项目取名t3code时的想法——不是花哨的代号而是想把 TypeScript 的类型安全从浏览器一路打通到数据库。t3code这个概念并不复杂它不是一个框架也不依赖某个神秘命令它只是以 T3 这套组合Next.js TypeScript tRPC Prisma Tailwind NextAuth为底座把 Web 全栈应用的整条链路做成一个类型闭环。这套搭法适合谁呢适合所有不想维护两套接口定义、反感手写 DTO、又希望前后端能够像“同一个项目”一样协作的开发者。后面我会从选型思路讲起一直到数据模型、认证接入、业务闭环、部署上线中间穿插我实际踩过的坑。项目本身不大但足够说明 T3 这套组合在真实场景下是怎么运转的。1. 从类型割裂说起为什么我给项目取名 t3code并押注 T3 这套组合1.1 边界多类型就断头REST 全栈的日常我最早做全栈项目时用的是 Next.js Express Prisma。听起来挺正统但实际开发里经常出现一个让我崩溃的循环先在schema.prisma里加字段然后在 Express 路由里重新写一遍返回结构再去前端定义 interface最后还要自己保证这三处永远同步。只要哪次偷懒少改一处线上就会冒出一个诡异问题。举个真实例子后端返回的createdAt经过 JSON 序列化后是字符串前端代码却把它当成Date对象处理调用toLocaleDateString()时直接白屏。前端同事说“你接口类型写错了”后端同事说“我数据库里本来就是 Date”数据库管理员说“我都存成 timestamp 了还怪我”——这种“类型断头”是全栈项目最大的隐性内耗。这种情况的本质是REST 接口 手写 DTO 前端 interface在三个地方维护着三份“看起来一样但其实没人保证一致”的契约。改一次字段要牵动三处只要有一个地方漏了bug 就像潜伏的定时炸弹。把这三份契约合并成一份就是t3code立项时最想解决的核心问题。1.2 为什么 tRPC 而不是 GraphQL给“过程”而不是给“协议”有人可能会问要端到端类型安全GraphQL 不是也能做到吗确实能但代价不低。GraphQL 需要你维护一套schema.graphql还需要 resolver 和 codegen 步骤等于引入了一个新的中间层和一个新的构建环节。团队小的时候这套成本挺重的我没记错的话光配置 codegen 就能磨掉一个下午。tRPC 的思路完全不同它不定义协议只暴露 TypeScript 函数。客户端写trpc.snippet.list.useQuery()服务端写一个listSnippets的 procedure两边的类型是直接打通的因为客户端 import 的就是服务端的AppRouter类型。没有 schema 文件没有 codegen也没有 DTO。服务端函数的返回值是什么客户端就能推导出什么。这里的关键区别是REST 把“函数调用”包装成“HTTP 资源”GraphQL 把“函数调用”包装成“查询协议”而 tRPC 直接让你调用函数。对于内部 API 来说函数调用比协议描述更自然也少一层心智负担。我从不用any糊弄类型而 tRPC 恰好能逼着你把类型写完整这跟我做项目的习惯非常匹配。对比一下常见方案方案端到端类型学习成本schema 文件codegen适用场景REST 手写 DTO无靠自觉低无无公开 API、第三方开放平台GraphQL强但需 codegen中有有复杂聚合查询、多端复用tRPC强天然继承低无无内部全栈应用、BFF、快速迭代1.3 t3code 的三个立项指标我决定在一个真实小项目上验证 T3 这套组合给它起了代号t3code核心定位是做“代码片段收藏器”登录后可以创建 snippet、给自己打标签、按语言筛选。选择这个业务是因为它覆盖了认证、CRUD、列表、表单、删除足够代表绝大多数管理后台场景但又不会大到让人失去耐心。立项时我给自己定了三个可验证的指标端到端类型完整率在服务端改动返回字段后前端代码能否在编译期直接报错而不需要找接口文档。上手成本一个新加入项目的开发者在没有看过 API 文档的情况下是否能从组件代码一路追查到数据库查询。安全边界数据库连接串、OAuth 密钥、Session Secret 这些信息是否能确保不被客户端 bundle 带走。这三个指标在后面每一章里都会反复出现也是我判断一个全栈方案能不能长期用的标准。2. 骨架是如何长出来的t3code 的目录结构与配置边界2.1 create-t3-app 生成的骨架每个目录的职责t3code用了create-t3-app初始化因为它把 Next.js App Router、tRPC、Prisma、NextAuth 的样板代码都配好了。理论上你也可以手动拼但那些初始化细节容易出错比如 tRPC adapter 的版本匹配、provider 的 session 回读、superjson transformer 的挂载位置有一个环节对不上就会出现各种玄学报错。初始化后的关键目录结构长这样prisma/ schema.prisma src/ app/ api/trpc/[trpc]/route.ts _trpc/server.ts server/ api/ root.ts routers/post.ts trpc.ts db.ts trpc/ react.tsx server.ts我建议你把这个结构背下来因为它规定了哪段代码属于哪一侧src/server下面的代码只运行在服务端绝对不能出现在客户端 bundle 里。src/app/api/trpc/[trpc]/route.ts是 tRPC 的 HTTP 入口Next.js 把请求交给它它再转发给对应 procedure。src/trpc/react.tsx负责创建QueryClient和Provider客户端组件靠它拿到trpc对象。src/server/api/trpc.ts是“创建 tRPC 实例的工厂”里面定义了 context、错误格式化器和protectedProcedure。理解了这个分层后面写 router 才知道每个 procedure 应该放在哪一层。2.2 客户端与服务端的“防火墙”目录边界就是安全边界我在t3code里做过一次实验在src/server/db.ts里打印 DATABASE_URL然后从客户端组件 import 它。结果 Next.js 在构建时直接报错告诉我这个模块只能在服务端使用。这个报错看似烦人其实是框架在替你把关。很多人没意识到目录边界本身就是安全边界。Next.js App Router 里use client文件里的代码会被打包进浏览器如果里面不小心引用了prisma或者process.env.DATABASE_URL密钥直接就暴露了。T3 组合里 tRPC 的存在让客户端不需要直接访问数据库所有数据操作都通过 procedure 完成这相当于人为规定了“客户端只有通过 API 这一条通道”。所以我的规范是凡是访问数据库、读取密钥、操作文件系统的代码一律放src/server组件层只允许 importtrpc对象不准 importdb。这个规范不是靠自觉而是让目录结构天然地引导你这么做。2.3 环境变量最容易翻车的配置死角create-t3-app会在启动时校验环境变量漏掉一个就拒绝启动。这看起来挺烦但实际帮了大忙。我整理一下t3code里最核心的几个变量变量作用注意点DATABASE_URL数据库连接串本地可指向 SQLite 文件生产必须用 Postgres 连接串AUTH_SECRETSession 签名密钥生产必须设一个足够长的随机字符串AUTH_GITHUB_IDGitHub OAuth App ID和回调地址严格匹配AUTH_GITHUB_SECRETGitHub OAuth Secret泄露等于账户接管必须轮换NEXT_PUBLIC_APP_URL前端公开地址以NEXT_PUBLIC_开头才会进客户端 bundle踩过一次坑后我学到了区分“需要暴露给浏览器的变量”和“只给服务端用的变量”。凡是变量名前缀带NEXT_PUBLIC_浏览器就能看到凡是密钥类变量一律不给前缀。如果不小心给AUTH_SECRET加了NEXT_PUBLIC_前缀等于把 session 签名密钥送进了所有人的浏览器——这比数据库密码泄露还隐蔽因为你可能好久都发现不了。3. 关键的轮子怎么装认证、数据模型与 tRPC 路由的落地细节3.1 认证入组NextAuth 与 tRPC context 的整合t3code选择了 GitHub OAuth 登录因为配置最少。NextAuth v5 的写法是在app/api/auth/[...nextauth]/route.ts导出handlers和auth。接下来最关键的步骤是把 session 塞进 tRPC 的 context。src/server/api/trpc.ts里的逻辑是这样的import { initTRPC, TRPCError } from trpc/server; import { auth } from ~/server/auth; async function createContextInner(opts: { headers: Headers }) { const session await auth(); return { db, session, headers: opts.headers }; } const t initTRPC.contexttypeof createContextInner().create({ transformer: superjson, }); const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, user: ctx.session.user } }); });这样后面的protectedProcedure就能在 ctx 里拿到当前登录用户。我特别建议所有需要登录的查询都用protectedProcedure不要在单个 router 里再去判断session。原因很简单判断逻辑放在一个地方总比散落在几十个 procedure 里容易维护安全性也高得多想漏一个都难。3.2 Snippet 数据模型字段、关系与索引的设计理由t3code的schema.prisma一开始很简单但我在设计时特意做对了三件事主键用cuid()、外键带级联删除、查询频繁字段建索引。model User { id String id default(cuid()) name String? email String? unique snippets Snippet[] } model Snippet { id String id default(cuid()) title String content String language String default(text) authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) createdAt DateTime default(now()) updatedAt DateTime updatedAt index([authorId]) }为什么主键不用自增数字因为公开接口里你不想让任何人通过id1、id2猜出你有多少条数据cuid 天然带随机性分布式环境下也不会冲突。为什么onDelete: Cascade因为删除用户时顺手删掉他的 snippet不会有孤儿数据。当然如果你的业务需要保留用户历史就得改成软删除加一个deletedAt字段但那会让每个查询都要过滤一遍属于另一种复杂度。对于 snippet 收藏器这种个人工具级联删除是最符合直觉的。index([authorId])不是随便加的。几乎所有查询都是“找到某个用户的 snippets”这个索引会让where authorId ?的查询避免全表扫描。数据量小的时候你察觉不到区别数据量上去了这个索引就是命脉。3.3 路由的三种姿势publicProcedure、protectedProcedure 与 zod 校验t3code的 snippet router 用了三种不同姿势。公开的publicProcedure用来查热门 snippet登录后的protectedProcedure用来查自己的数据写操作用 zod 做输入校验。以创建 snippet 为例const createSnippet protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().default(text), }) ) .mutation(({ ctx, input }) { return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, authorId: ctx.user.id, }, }); });注意 input 里没有authorId也没有id这两个字段是从 context 或数据库侧生成的。很多人第一次写 tRPC 会顺手在 input 里放authorId那样等于让客户端自己声明身份任何一个懂点请求抓包的人都能伪装成别人创建数据这是严重的越权漏洞。Zod 在这里的意义不只是校验格式它还承担了类型推导输入是z.object的 schema你不需要再写type CreateSnippetInput { ... }TS 会从 schema 里推断出来。改规则就是改 schema类型跟着变不需要维护第二份定义。4. 跑通一个完整业务闭环从 UI 到底层数据库的全链路类型护航4.1 从需求到闭环做一个可用的 snippet 收藏器代码写多了之后我发现自己经常要看以前写过的代码片段但又懒得去翻 Git 历史。t3code的第一个完整功能就是解决这个问题的登录后看到自己的 snippet 列表可以创建新 snippet也可以删除不再需要的。看似简单但它完整覆盖了一条链路登录 → 列表查询 → 表单提交 → 数据刷新。我把列表页设计成了 React Server Component让服务端直接查库并渲染创建和删除操作则是客户端组件因为需要触发 mutation 和交互反馈。这个分工后面会展开说。4.2 RSC 直接拿数据_trpc/server 里的 caller在t3code里列表页用了 Next.js App Router 的服务端组件。服务端组件里不能用 hooks所以不能调trpc.snippet.list.useQuery()但可以用 tRPC 提供的服务端 callerimport { api } from ~/trpc/server; export default async function SnippetListPage() { const snippets await api.snippet.list(); return SnippetList initialData{snippets} /; }服务端调用和客户端调用的类型是同一套因为底层都是同一个AppRouter。差别在于服务端调用发生在请求阶段直接由 Node.js 执行不需要经过 HTTP 循环而客户端调用会发一个 HTTP 请求到/api/trpc/snippet.list。选择哪一种取决于这个页面是否需要 SEO、需要多少动态交互。如果页面内容本身就是登录后的私密数据服务端渲染会让白屏时间更短也能少一次客户端请求。这是 RSC 在t3code里最爽的地方。4.3 写操作与缓存失效useMutation 后的刷新策略创建和删除都在客户端做。t3code里创建 snippet 的表单调用了trpc.snippet.create.useMutation()成功后需要做一件事刷新列表缓存。const utils trpc.useUtils(); const createMutation trpc.snippet.create.useMutation({ onSuccess: () { utils.snippet.list.invalidate(); }, });这里我踩过一个明显的弯路有一版代码在onSuccess里重置了本地表单却没有 invalidate 列表结果新创建的 snippet 没出现在列表里。我当时一度以为 tRPC 坏了后来才意识到useQuery是有缓存的mutation 成功后缓存不会自己更新必须主动让查询失效。invalidate的作用不是“重新发请求”而是把缓存标记为过期下一次读取时自动重新拉取。粒度可以有粗有细你可以snippet.list.invalidate()也可以精确到某个参数的查询。日常开发里粗粒度就够了除非列表数据特别大或者查询特别多再去纠结细粒度缓存。这里提醒一下如果你用 React Query v5 的 API旧的trpc.useContext()已经改名为trpc.useUtils()。这两个东西本质是一个但名字变了照着旧文档抄代码会直接编译不过。4.4 一次改字段引发的编译错误链路护航的实际体验我想演示一个具体场景来验证端到端类型安全把Snippet模型里的title字段改名为name。改动只有一处就是schema.prisma。改完运行prisma generate然后编译项目你会发现所有使用snippet.title的前端组件齐刷刷报错。这就是我立项时定的第一个指标达成了。没有 tRPC 的情况下你需要手动搜索所有title引用再逐一修改有 tRPC 的情况下类型系统帮你把清单全列出来了。我者说的更直白一点以前是全靠 Team Leader 指出你漏改了哪里现在编译器在 CI 阶段就能拦住你。这个体验带来的安全感是做传统 CRUD 项目永远体会不到的。5. 实战里的意外清单我踩过的大坑与修复方案5.1 superjson 是不是万能的Date 序列化的坑t3code架构里用了 superjson 作为 transformer它的作用是让Date、Map、Set这类结构化数据在 tRPC 传输后还能保持原类型。我确实遇到过项目里某个 router 忘记挂 transformer前端拿到的createdAt是字符串而不是Date一调用toLocaleDateString就白屏。排查过程是这样的先在浏览器 Network 面板看响应体发现 JSON 里createdAt是2025-01-12T08:00:00.000Z接着看服务端日志确认数据库返回的是Date对象最后发现transformer: superjson没有挂到 server 端实例上。修复方法就是在initTRPC.context(...).create({ transformer: superjson })里补上。这个字段同时影响服务端和客户端必须保持两边一致否则编码解码不对称照样炸。5.2 React Query v5 带来的 API 迁移问题有一段时间我一直用旧的useContext()写法因为网上很多文章的代码还停留在 v4 版本。后来升级依赖之后整个项目编译失败我才发现新版 React Query 把useContext改成了useUtilscacheTime改成了gcTime。我的建议是新建项目就直接用最新版本习惯新 API老项目升级时可以先跑一遍 TypeScript 编译把所有报错位置当成待办清单。这类 API 迁移就是“编译器指引你改代码”的过程比人肉翻文档靠谱得多。5.3 RSC 里不能碰 hooks服务端调用与客户端调用的分工我在t3code里犯过一个认知错误试图在async function SnippetListPage()里调用trpc.snippet.list.useQuery()然后 React 直接告诉我useQuery只能在客户端组件里调用。这是因为 RSC 组件是服务端函数不能用 hooks。很多人会因此感到困惑既然我都能在服务端直接查数据库了为什么还要用 tRPC 的 hook其实两者解决的问题不同服务端 caller 是为了让 RSC 页面在渲染时直接拿数据而客户端 hook 是为了在浏览器交互后重新拉取数据。你可以把 RSC 理解成“服务端生成初始 HTML”把useQuery理解成“浏览器端继续维持数据新鲜度”。在t3code里列表页用 RSC 做首屏创建/删除后用 mutation 刷新两者各司其职。5.4 next-auth 环境变量与 session 突然失效session 失效这件事在本地很少发生一到生产环境就容易出现。t3code部署到 Vercel 后出现过一次“昨天还登录着今天所有请求都 401”的情况。排查了半天发现是AUTH_SECRET在生产环境没有设置。本地开发时 next-auth 会用开发模式生成一个临时密钥重启后可能会变所以本地偶尔也会出现“重新登录”的现象但生产环境如果没设固定密钥后果更严重。修复就是把AUTH_SECRET设置成一个足够长的随机字符串比如用openssl rand -base64 32生成。这个变量一旦在生产环境变了所有已签发的 session 都会立刻失效用户必须重新登录。我的经验是把这件事写进部署检查清单跟数据库迁移一样每次发布前检查一遍。5.5 构建时的数据库迁移锁小心一个命令让你滚回旧版这件事发生在不是t3code本身而是我用 T3 搭建的另一个测试项目上。当时我在 Vercel 的构建命令里偷偷加了prisma migrate deploy想着每次部署自动跑迁移多省事。结果有一次并发提交触发了两次构建两个实例同时抢数据库迁移锁一个失败回滚另一个构建成功但数据库状态已经不是预期。正确做法是构建阶段只执行prisma generate不要执行 migrate。数据库迁移应该在部署流程的单独环节执行或者直接在 CI/CD 里手动触发prisma migrate deploy。Vercel 这类平台并发构建很常见迁移脚本放构建里等于给自己埋雷。6. 部署与上线从本地到生产环境的最后一公里6.1 本地 SQLite 到生产 Postgres迁移不是换一行 URLt3code开发阶段为了省事用了 SQLite连接串是file:./dev.db。要上线时换成了 PostgreSQL我在 Neon 上开了一个免费库把连接串填进生产环境变量。看着只是把DATABASE_URL换掉但实际有几个细节要注意SQLite 和 Postgres 对Json字段的处理不同SQLite 基本是文本存储Postgres 有真正的jsonb类型。数字 id 的类型在两者上没有本质区别但如果用了无符号 BigInt 这类大整数Prisma 在两种数据库间会有兼容性差异。枚举类型在 Postgres 里是独立类型SQLite 里更像字符串约束迁移时要用prisma migrate dev重新生成 SQL。先把迁移文件提交到 Git 里再在生产环境执行prisma migrate deploy。别在生产库里直接prisma db push那会把 schema 变更直接同步到数据库而不生成迁移记录以后管理回滚会很痛苦。6.2 Vercel 构建配置与 Prisma 的连接优化Vercel 上用 Serverless 函数托管 Next.js每个函数实例都可能独立连接数据库连接数很快会爆。t3code目前的访问量不大但我还是提前做了优化在连接串里加了connection_limit3和pool_timeout20限制连接池的大小与排队时长。Prisma 官方不推荐在 Serverless 环境里保持长连接。如果你使用 Neon 这类 Postgres 服务可以用neondatabase/serverless作为 adapter或者干脆用 Prisma 的 Data Proxy。我的建议是先量级不搞复杂把连接限制住、把冷启动时间压到可接受范围内等真的出现并发瓶颈再上更重型方案。构建命令方面我最终用的是prisma generate next buildmigrate 单独在发布前手动执行绝不塞进构建命令。每次发布前我还会检查 Vercel 面板里的环境变量“哦AUTH_GITHUB_SECRET还是旧值先轮换再发布”。这个检查习惯帮我避免了好几次 session 失效事故。6.3 上线后的三件套日志、健康检查与备份Vercel 自带的日志可以看函数调用但没法像传统服务器那样方便地 grep 和聚合。t3code接入了 Sentry 做前端错误和服务器端异常的收集并在 tRPC 的onError里上报错误。说实话没有错误上报的全栈项目就像闭着眼开车用户白屏了你都不知道。我还写了一个/api/health路由里面做两件事一次简单的SELECT 1验证数据库连通一次返回当前时间确认函数运行时没有挂死。这个接口对监控系统很友好K8s 或第三方 uptime 服务都可以直接探测。备份这块Postgres 服务商如果自带每日备份就最好没有的话写个定时任务每天pg_dump然后传到对象存储。个人项目可以糙一点但数据真丢了的时候一份备份能让你少掉三天头发。写在最后的实际操作心得到目前为止t3code已经跟着我跑了几个月我把它当私人 snippet 收藏器在用。晚上临时想到一个小技巧打开页面贴进去打个标签就完事了。要说这套技术栈给我最大的改变大概是“改接口字段之前不再需要做心理建设了”改完 schema、跑一次prisma generate、看一眼编译报错清单剩下的交给类型系统。最后分享一个小技巧给schema.prisma里的查询字段建索引时不要想当然要对着你的查询模式来。我在做筛选页时一开始给language建了索引实际业务里更常按authorId createdAt排序后来才把索引改成针对这个组合。索引不是越多越好它能快速定位数据也会拖慢写入拿实际查询模式反向推导才是最靠谱的。