资讯动态

T3 Stack全栈笔记:tRPC端到端类型安全实战

发布时间:2026/10/27 6:48:32 来源:尧图企业网站定制
t3code 这个项目是我最近一周折腾出来的一个全栈练习仓库名字里藏着三件事T3 指 T3 Stack——TypeScript、Tailwind、tRPC 这套组合code 则是我给自己定下的规矩仓库里每个能跑通的核心链路都要配上可直接复现的代码。整套项目的形态是一个带数据库、带登录态、带增删改查的笔记应用用来验证“端到端类型安全”到底能给日常开发省下多少工作量。如果你最近正在 REST、GraphQL 和 tRPC 之间纠结或者想看看 Prisma 自动推断出来的类型是怎么一路传到 React 组件里的又或者只是需要一个能抄作业的 Next.js 全栈骨架这篇笔记应该能帮你省下不少探索时间。我会从选型动机讲起把初始化脚手架、tRPC 链路、数据库迁移、前后端联动和部署这几个环节都过一遍凡是踩过坑的地方都会单独标出来。1. 为什么 T3 Stack 值得专门开一个仓库来沉淀1.1 “T3”不是软件版本号而是一种取舍方式首先要澄清一件事T3 Stack 里的 T3 经常被误以为是某个框架的第三代其实它来自三个都以 T 打头的技术——TypeScript、Tailwind CSS、tRPC由 Full Stack 开发者社区推广开以后又被补充上了 Next.js 和 Prisma 这两个默认搭档。很多人第一次看到这套组合时都会问这不就是“把最新潮的库都堆一起”吗我一开始也有这个疑虑但真正写完 t3code 之后我更愿意把它理解成一组经过验证的默认选择每个成员都在解决类型安全链条上的一个具体环节少了任何一个你都会在某处退化成手写重复代码。这套组合里真正核心的差异化成员是 tRPC。它把“后端函数”直接暴露成前端可调用的 RPC 端点前端不需要写 URL、不需要维护请求函数、不需要手动声明返回类型所有类型都是从服务端代码里“长”出来的。Tailwind 解决样式层的一致性Prisma 解决数据库模型的类型同步Next.js 负责页面路由和渲染策略而 tRPC 是串联这一切的线路。还需要理解 T3 Stack 的三个设计原则类型安全优先、不做过度工程、保持协作简单。类型安全优先意味着从数据库到组件的数据流动全程有 TS 类型约束不做过度工程意味着你能用一个 tRPC mutation 解决的问题就不必为了“可扩展性”硬塞一层 REST Controller保持协作简单意味着前后端共享一个代码库时函数签名就是接口文档改一个输入字段编译期立刻告诉你哪些页面会受影响。这几个原则听上去简单但在一个真实项目里落地之后重构体验会变得完全不同。1.2 为什么不用 REST 或 GraphQLREST 和 GraphQL 当然都不缺使用者但 t3code 这个场景下它们都有自己的摩擦成本。REST 的问题是“类型契约”需要额外维护后端写一个/api/posts接口返回结构变了前端如果忘了同步更新接口类型等到运行时才发现字段不存在。常见补救方案是用 OpenAPI 生成客户端类型但这等于又引入一套生成工具链而且生成代码的维护和迭代始终隔着一层。GraphQL 的端到端类型体验不错但 schema、resolver、fragment、缓存配置这一套学习曲线和样板代码很重几个人维护的中小型项目很容易被工具复杂度拖垮。tRPC 的处理方式简单得多后端定义一个createPost函数输入用 zod 声明前端直接调用api.post.create.useMutation()入参类型和返回类型都是自动推导的。不需要额外定义接口文档不需要翻文件确认字段名。用一个生活化的类比REST 像是餐厅向顾客公开的菜单任何客人都能点但万一后厨改菜谱菜单得手工更新tRPC 更像内部食堂厨师和服务员用同一张菜谱改菜谱时所有人看到的是同一份文件。方案类型契约适合场景主要代价REST OpenAPI需要手工维护或代码生成开放 API、多语言客户端端到端类型难以一直保持同步GraphQLschema 即契约部分自动复杂数据图谱、多端聚合查询样板代码多、缓存策略复杂tRPC服务端函数签名直接传递前后端都是 TypeScript非 TS 客户端无法直接复用我并不是说 tRPC 万能。如果你的服务必须开放给第三方、小程序或非 TypeScript 客户端那么 HTTP OpenAPI 仍然是更稳妥的选择。t3code 的适用边界很明确前端是 React/TS、后端也是 TS 的典型内部系统团队只有几个人甚至没有专职后端时tRPC 可以让一个人把全栈扛完。2. 从零初始化 t3code脚手架选项与目录结构2.1 用 create-t3-app 跑通最小骨架初始化 T3 Stack 项目非常简单官方提供了交互式脚手架npx create-t3-applatest t3code运行后会问你几个问题下面是我给 t3code 做的选择使用 TypeScript模板里默认开启不可取消。这正是 T3 Stack 的根基。选择 App Router 还是 Pages Router我选了 App Router。如果你不熟悉 React Server Components也可以先用 Pages Router但从长期维护角度App Router 已经是 Next.js 的默认方向。是否启用 Tailwind CSS选是。样式方案先定下一个避免后续每写一个页面都要纠结 class 命名。是否启用 Prisma选是。数据库层是类型安全链路的重要起点。是否启用 tRPC选是。前文说了这是整套组合的灵魂。是否启用 NextAuth.js选是。笔记应用需要登录态来关联作者。是否启用 Docker看情况本地开发依赖 Postgres 的话可以选。我这次为了减少环境干扰没有启用直接用远程开发数据库。接下来模板会帮你装依赖并生成配置文件还会初始化一个 git 仓库。这里有一个容易忽略的环节模板默认生成的是示例代码包含一个健康的数据库状态检查和基础页面。我会先把示例跑起来再开始改自己的逻辑这样便于在出问题时确认“模板本身没有问题问题出在我的改动上”。2.2 生成目录的核心结构初始化完成后的目录结构大致如下我挑选几个关键位置说明用途t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ └── routers/ │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── react.tsx │ │ └── server.ts │ ├── utils/ │ └── env.js ├── .env └── package.json这套结构最关键的一点是业务代码被清晰地分成了两层src/server下面的东西只跑在服务端比如数据库连接、认证配置、tRPC 路由src/app是页面和客户端组件调用src/trpc/react.tsx里导出的 hooks。我第一次接触这个结构时总想把 tRPC router 直接放进src/server/api/root.ts后来发现管理路由的正确方式是每个业务域一个文件放到src/server/api/routers/目录下再在root.ts里聚合。t3code 里我建了两个业务路由文件post.ts管理笔记user.ts管理用户信息。这样单个文件不会膨胀新人在项目里找代码也不需要全局搜索。2.3 环境变量整套应用的第一个隐形坑T3 模板用src/env.js做环境变量校验启动时会用 zod 检查必须的字段。最典型的三个字段是DATABASE_URLPrisma 连接数据库的地址格式通常是postgresql://user:passwordhost:5432/dbnameNEXTAUTH_SECRETNextAuth 签发 session 的密钥可用openssl rand -base64 32生成NEXTAUTH_URL本地是http://localhost:3000部署后要改成线上地址模板会在你初始化时自动创建.env文件推荐把真实密钥放到.env.local里并且确保.env*.local都被 gitignore 覆盖。我这次踩过一个比较经典的坑在.env里填了一个带特殊字符的数据库密码比如密码里有%或#没有做 URL 编码Prisma 连不上数据库报错信息却指向“超时”。后来把密码改成 URL 编码形式才恢复正常。所以凡是数据库密码里有特殊字符记得先做编码转换。3. tRPC 端到端链路拆解从数据库 Schema 到页面按钮3.1 定义 Prisma 模型作为类型起点t3code 的核心业务是笔记所以我先定义了Post和User两个模型。这是整个类型安全链条的起点因为 Prisma 会基于这个 Schema 生成客户端类型之后所有 tRPC 的返回类型都从这里推导model User { id String id default(cuid()) name String? email String unique posts Post[] } model Post { id String id default(cuid()) title String content String author User? relation(fields: [authorId], references: [id]) authorId String? createdAt DateTime default(now()) updatedAt DateTime updatedAt }这里我选择cuid()而不是自增数字是因为笔记系统的 ID 会在 URL 里出现自增数字容易暴露数据数量也容易被遍历抓取。表准备好后运行npx prisma migrate dev --name initPrisma 会创建表并生成客户端类型。这里要提醒一句migrate dev只适合开发环境它会自动执行迁移并重新生成 client随手敲这个命令已经成为我改完 schema 后的肌肉记忆。3.2 写一个笔记路由Router、Procedure 与 zod 校验接下去定义 tRPC 路由。t3code 的src/server/api/routers/post.ts长这样import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const postRouter createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ take: 50, orderBy: { createdAt: desc }, include: { author: { select: { name: true } } }, }); }), getById: publicProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ ctx, input }) { return ctx.db.post.findUnique({ where: { id: input.id } }); }), create: publicProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), }), ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, }, }); }), });这里有几个设计细节值得展开。createTRPCRouter把多个 procedure 聚合在一个命名空间下前端调用时就是api.post.getAllpublicProcedure表示无需登录也能调用后面我们会用protectedProcedure替换它input(z.object(...))用 zod 做运行时校验确保非法请求在进入数据库之前就被拦截。你可能会问既然有 TypeScript 类型为什么还需要 zod 来校验输入因为 TypeScript 的类型只在编译期有效请求从浏览器传到服务器时字符串到底是什么你可能一概不知道。zod 补上了运行时校验这一环并且它的类型推导还能直接成为 TS 类型也就是“一份定义双重效果”。这比我以前做 REST 时在 controller 里手写if (!title) return 400要干净得多。3.3 前端调用useQuery 与 useMutation写完后端前端调用几乎是零成本的。比如创建一个笔记的弹窗提交后触发 mutationimport { api } from ~/utils/api; function PostList() { const posts api.post.getAll.useQuery(); const createPost api.post.create.useMutation({ onSuccess: () { posts.refetch(); }, }); return ( div button onClick{() createPost.mutate({ title: 标题, content: 正文 })} 发布 /button {posts.data?.map((post) ( div key{post.id} h2{post.title}/h2 p{post.author?.name}/p /div ))} /div ); }注意看useQuery的返回值posts.data已经带有Post[]类型createPost.mutate的入参已经匹配 zod 输入定义。改一个数据库字段从 Prisma 到 React 组件的类型会全部联动更新而不是靠前后端各自维护“看起来差不多”的类型这就是端到端类型安全的核心价值。3.4 tRPC 的底层机制与请求链路理解 tRPC 是怎么工作的会有助于排查一些奇怪问题。它的核心思路是把 Vuewell, React组件里的 hook 调用翻译成到服务器的 HTTP 请求走的是 Next.js 的同源 API routes。T3 模板默认用的是httpBatchLink一次页面加载里多个 tRPC procedure 的调用会被合并成一个批处理请求发到服务端减少网络往返。这一个设置帮 t3code 在列表页同时加载“文章列表”“用户信息”“系统配置”时省了不少请求数。React Query 的参与也很重要useQuery背后的缓存、重试、失效机制都来自 React Query而不是 tRPC 自己实现的。你可以在src/trpc/react.tsx里看到客户端 Provider 的配置其中就包含 React Query 的默认设置。理解这层关系之后调试refetch不生效时就不会去 tRPC 文档里乱找而是直接排查 React Query 的缓存配置。4. Prisma 迁移与认证联动的真实踩坑笔记4.1 改表不是“改代码”那么轻松t3code 在第一次迭代里加了“笔记收藏”功能需要在Post模型上加一个favoriteCount字段。这个过程看起来简单在 schema 里加一行运行迁移命令。但实际上我翻过两次车。第一次是直接在favoriteCount上加了非空约束却忘了给默认值结果迁移时 Postgres 报错因为已有数据行无法填充这个字段。解决办法是加default(0)。第二次是后来觉得冗余统计字段不优雅改成新建一个Favorite模型表示收藏关系这时又涉及关系外键的onDelete行为用户注销时他收藏过的笔记该怎么处理。默认行为是限制删除也就是有收藏关系的用户不能被删。我在 schema 里显式声明了onDelete: Cascade之后迁移才通过。这让我养成了一个习惯每次修改 schema不只是跑迁移而是先在脑子里过一遍“这条数据被删除时关联数据怎么办”。很多时候生产事故不是 SQL 写错了而是关系行为没有提前设计好。4.2 seed 脚本没有初始化数据就没法联调空数据库没有办法验证列表页、详情页的 UI所以 t3code 写了prisma/seed.tsimport { PrismaClient } from prisma/client; const db new PrismaClient(); async function main() { const user await db.user.upsert({ where: { email: demoexample.com }, update: {}, create: { email: demoexample.com, name: Demo User, }, }); await db.post.createMany({ data: Array.from({ length: 10 }, (_, i) ({ title: 示例笔记 ${i 1}, content: 这是一段用于联调的示例正文。, authorId: user.id, })), }); } main() .catch((e) { console.error(e); process.exit(1); }) .finally(async () { await db.$disconnect(); });要让npx prisma db seed能执行还需要在package.json里配置prisma: { seed: tsx prisma/seed.ts }这里有个小技巧seed 脚本里我用upsert而不是create这样重复执行 seed 不会因为唯一键冲突直接崩溃。联调阶段“重置数据库 重新 seed”是我的标准操作npx prisma migrate reset --force然后npx prisma db seed。4.3 认证与 tRPC 的联动protectedProcedure 的写法NextAuth 配置在src/server/auth.ts中tRPC router 里默认开放了publicProcedure但笔记应用里 “创建笔记”必须登录。T3 模板为此提供了更安全的基类protectedProcedure它的实现原理是先确认 session 存在再把ctx.session注入到后续代码中。一个 post 路由可以这样改造import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const postRouter createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), }), ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });ctx.session.user.id的类型是从 NextAuth 的 session 类型里推导出来的不需要像以前那样从 JWT 里手动取一下再转类型。这里有一项配置容易漏如果你自定义了 NextAuth 的session回调记得把user.id塞进 session 对象否则上面代码里的ctx.session.user.id会报类型错误。我刚开始用useSession()时发现获取不到 id花了一会儿才想起是回调配置的问题。4.4 列表缓存的并发问题mutation 之后该不该 refetch页面里的createmutation 成功后我最初只在回调里refetch()但连续快速创建两条笔记时第二条 refetch 有可能先返回导致列表短暂少一条。更稳妥的方案是直接使用 mutation 返回的新数据手动更新列表缓存或者对列表查询加上staleTime来减少多余请求。t3code 里getAll的选项最后是这样的const posts api.post.getAll.useQuery(undefined, { staleTime: 5 * 1000, refetchOnWindowFocus: false, });一个内部笔记系统实时性没那么强五秒内不重复请求是合理的。refetchOnWindowFocus: false则可以避免你切到其他标签页再切回来时整页列表“哗啦”刷新一次。5. 部署到线上前我会重新检查的几个关键细节5.1 环境变量、构建脚本与数据库迁移的时序接触过 Vercel 部署的人都知道前端部署和数据库迁移要分开处理。如果DATABASE_URL指向的是生产数据库直接在构建过程中执行prisma migrate deploy并不安全因为并发流量进来时表可能还在重建状态。t3code 的做法是在本地或 CI 里先跑npx prisma migrate deploy成功之后再触发布在 Vercel 项目设置里配置DATABASE_URL、NEXTAUTH_URL、NEXTAUTH_SECRET本地跑npx prisma migrate deploy部署数据库变更再 push 代码触发生产构建确保package.json里有postinstall: prisma generate这样生产环境安装依赖后会自动生成 Prisma Client部署完成后先打开首页看数据库连接是否正常再找一个需要登录才能访问的页面测试 session。这个检查顺序能快速定位问题到底出在数据库还是认证配置上。5.2 服务端调用 tRPC 的另一种方式t3code 里页面是 App Router服务端组件里不能直接用useQuery但可以通过 tRPC 的 server caller 直接调用路由函数。模板中src/server/api/root.ts会导出一个createCaller典型用法import { createCaller } from ~/server/api/root; const caller createCaller({ session: null, db }); const data await caller.post.getAll();这样服务端组件可以做首屏数据预取再配合客户端组件做交互更新。用法上有一个注意点createCaller里的 context 要自己组装如果你忘了传sessionprotectedProcedure就会报未登录错误。这算是 Server Component 与 RPC 模式之间的一个小摩擦点但解决了它之后首屏渲染和 SEO 的问题就基本无忧了。5.3 日志与错误观测上线后最容易被忽略的一环本地开发时服务端报错终端直接打印上线后没人帮你盯着服务端日志。t3code 里我在 tRPC 客户端加了 logger link至少在开发环境能看到每个请求的耗时和参数import { loggerLink } from trpc/client; // ... links: [ loggerLink({ enabled: (opts) process.env.NODE_ENV development || (opts.direction down opts.result instanceof Error), }), httpBatchLink({ url: /api/trpc }), ],如果你上线后发现某个 mutation 频繁报错但界面上只是弹一个 500没有更多信息这种日志配置就是救命稻草。再往前一步可以接 Sentry通过 wrapper 把 tRPC 的错误上下文带上。我个人的准则是先把请求链路日志打通再考虑加监控系统避免一上来就被埋点方案绑住。常见错误原因解决办法Prisma 找不到模型prisma generate没执行运行npx prisma generate数据库提示字段不存在迁移没同步到目标库运行npx prisma migrate deployctx.session.user.id报类型错误NextAuth 回调没返回 id检查session回调配置线上页面刷新后登录态丢失NEXTAUTH_URL不是线上域名检查 env 配置tRPC 请求 404api/trpc路由没挂载检查 app 路由目录结构到这里t3code 的核心链路其实已经完整了从数据库模型到 tRPC 路由从认证保护到前端调用从本地开发到线上部署每一环都有类型约束托底。我在实际把项目从 REST 迁到 tRPC 之后最大的体会不是“少写代码”而是重构变得安全了。改一个字段不用再前后端来回翻文件编译错误会直接告诉你哪里要改。如果你和我一样是几个人维护、验证速度优先的中小型全栈项目T3 Stack 的基本盘已经够扎实。最后再提醒一句不要为了用 T3 而上 T3先想清楚你的调用方是不是都能接受 TypeScript 生态再决定要不要入坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑