☰
T3 Stack全栈笔记:tRPC端到端类型安全实战
2026/10/27 6:48:17 网站建设 项目流程

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 或 GraphQL

REST 和 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-app@latest t3code

运行后会问你几个问题,下面是我给 t3code 做的选择:

  1. 使用 TypeScript:模板里默认开启,不可取消。这正是 T3 Stack 的根基。
  2. 选择 App Router 还是 Pages Router:我选了 App Router。如果你不熟悉 React Server Components,也可以先用 Pages Router;但从长期维护角度,App Router 已经是 Next.js 的默认方向。
  3. 是否启用 Tailwind CSS:选是。样式方案先定下一个,避免后续每写一个页面都要纠结 class 命名。
  4. 是否启用 Prisma:选是。数据库层是类型安全链路的重要起点。
  5. 是否启用 tRPC:选是。前文说了,这是整套组合的灵魂。
  6. 是否启用 NextAuth.js:选是。笔记应用需要登录态来关联作者。
  7. 是否启用 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_URL:Prisma 连接数据库的地址,格式通常是postgresql://user:password@host:5432/dbname
  • NEXTAUTH_SECRET:NextAuth 签发 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 init,Prisma 会创建表并生成客户端类型。这里要提醒一句: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.getAll;publicProcedure表示无需登录也能调用,后面我们会用protectedProcedure替换它;input(z.object(...))用 zod 做运行时校验,确保非法请求在进入数据库之前就被拦截。

你可能会问:既然有 TypeScript 类型,为什么还需要 zod 来校验输入?因为 TypeScript 的类型只在编译期有效,请求从浏览器传到服务器时,字符串到底是什么你可能一概不知道。zod 补上了运行时校验这一环,并且它的类型推导还能直接成为 TS 类型,也就是“一份定义,双重效果”。这比我以前做 REST 时在 controller 里手写if (!title) return 400要干净得多。

3.3 前端调用:useQuery 与 useMutation

写完后端,前端调用几乎是零成本的。比如创建一个笔记的弹窗提交后,触发 mutation:

import { 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 是怎么工作的,会有助于排查一些奇怪问题。它的核心思路是把 Vue(well, 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.ts:

import { PrismaClient } from "@prisma/client"; const db = new PrismaClient(); async function main() { const user = await db.user.upsert({ where: { email: "demo@example.com" }, update: {}, create: { email: "demo@example.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 要自己组装,如果你忘了传session,protectedProcedure就会报未登录错误。这算是 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 deploy
ctx.session.user.id报类型错误NextAuth 回调没返回 id检查session回调配置
线上页面刷新后登录态丢失NEXTAUTH_URL不是线上域名检查 env 配置
tRPC 请求 404api/trpc路由没挂载检查 app 路由目录结构

到这里,t3code 的核心链路其实已经完整了:从数据库模型到 tRPC 路由,从认证保护到前端调用,从本地开发到线上部署,每一环都有类型约束托底。我在实际把项目从 REST 迁到 tRPC 之后最大的体会不是“少写代码”,而是重构变得安全了。改一个字段不用再前后端来回翻文件,编译错误会直接告诉你哪里要改。如果你和我一样是几个人维护、验证速度优先的中小型全栈项目,T3 Stack 的基本盘已经够扎实。最后再提醒一句:不要为了用 T3 而上 T3,先想清楚你的调用方是不是都能接受 TypeScript 生态,再决定要不要入坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询