t3code 最佳实践:用 TypeScript 和 tRPC 实现端到端类型安全的全栈开发 t3code这个名字在圈子里并没有官方定义但关注全栈开发的同学大概能猜到——它更多是大家在讨论 T3 StackThe T3 Stack这套技术组合时约定俗成的一种编码实践代号。简单说就是把 TypeScript、Next.js、Tailwind CSS、tRPC 这几样东西捏在一起用端到端类型安全的方式写 Web 应用。我刚开始接触时也觉得“这不就是换了个框架组合吗”真正跑完一个完整项目才发现它解决的是长期困扰我的那个问题前端调接口、后端改字段、数据库换类型谁都不敢动一动就各种对不上。如果你也受够了前后端联调时来回对着文档改类型的日子这篇文章会把 t3code 的整套思路、关键配置、实操流程和踩坑记录一次性讲清楚。1. t3code 到底是什么先搞懂这套编码实践的设计初衷1.1 从“t3”的来历说起T3 Stack 是 Theo 提出的一套全栈技术选型核心成员是 Next.js、TypeScript、Tailwind CSS、tRPC。t3code 这个名字可以理解为在这套技术栈之上形成的一整套代码组织方式和命名习惯。它不是一个新的框架也不是什么高深理论而是一组“约定 工具链”。约定指的是你在写代码时遵循的一些模式比如 API 路由统一用 tRPC 的 router 组织、输入校验统一放 Zod schema、页面组件统一走服务端组件。工具链指的是 create-t3-app 这个脚手架它把上述技术整合成一套开箱即用的项目模板。很多第一次接触 t3code 的人会疑惑我用 Next.js 的 API Route 也能写接口为什么非要引入 tRPC 这一层答案是类型安全跨越了边界。传统的开发模式下后端定义接口、前端手写请求函数、数据库模型又单独维护一份类型定义等于同一个业务对象在三个地方各写了一份“说明书”只要有一个人忘了同步线上就会出现结构对不上的 bug。t3code 的核心思路就是让 TypeScript 的类型系统贯穿数据库、服务端和客户端让“说明书”只存在于一处其余地方自动推导。拿生活中的场景类比传统模式就像是三个人各拿一版图纸在盖同一栋楼二号图纸里说门宽 80 厘米三号图纸里却写 75 厘米最后装门的时候才发现问题。t3code 则像是三人共看一块屏幕上的同一张图纸无论谁改了尺寸其他人都立刻看到最新版。1.2 核心价值把类型安全延伸到数据库和 UIt3code 组合里每一个成员承担的职责很清晰技术职责类比Next.js应用框架负责页面渲染和路由房子的骨架和墙体TypeScript静态类型系统负责在编译期捕获错误施工前的图纸校验Tailwind CSS原子化样式方案负责界面快速搭建装修时的标准规格材料tRPC前后端类型安全的数据通信层内部运输管道快递单自动匹配Prisma可选ORM 层负责数据库操作和类型生成仓库管理系统出库入库都有记录最有价值的是 tRPC Prisma 的组合效果。Prisma 会根据 database schema 自动生成 TypeScript 类型tRPC 的 router 在定义查询和变更时输入输出都带上这些类型。前端调用时不需要写任何请求代码也不需要手动定义 interface调用方拿到的返回值就是服务端返回值的精确类型。这意味着你在写const user await trpc.user.get.query()的时候user 对象上的所有字段在你的编辑器里都是有自动补全且类型校验的。刚开始用的人容易把 t3code 理解为技术选型其实它更像是一种“约束式开发”。它通过强制的类型流转把很多本应在运行时才能暴露的问题提前到了编译期。我在实际项目中最大感受是重构 API 字段时心态完全不一样了。以前改个字段名要全局搜索引用还怕漏掉现在改完类型前端哪里报错就说明哪里需要改没有报错的地方基本可以放心。2. 环境准备与脚手架搭建20分钟跑通一个 t3code 项目2.1 初始化项目与选项取舍实际操作中不需要手动把 Next.js、tRPC、Prisma 一个个装配起来直接用官方脚手架 create-t3-app 即可。命令只需要一行npm create t3-applatest执行之后终端会询问一系列选项这里把每个选项的取舍逻辑讲清楚不要盲目一路回车。第一项是包管理器。自己项目无所谓选 pnpm 或 npm 都可以。团队协作时建议保持一致避免团队成员各用各的导致 lockfile 冲突。第二项是是否使用 Next.js 的 App Router。这个要认真选。App Router 是 Next.js 13 之后的方向但如果你对服务端组件的概念还不熟可以先选 Pages Router因为它更直观学习曲线平缓。我的建议是新项目尽量选 App Router虽然初期有点绕但后续的缓存能力和布局复用优势很明显。第三项是鉴权方案脚手架会提供 NextAuth.js 或 Clerk 等选项。如果你暂时不需要登录功能可以都跳过后面在 tRPC 的中间件里自己加逻辑也来得及。数据库和 ORM 的选择上create-t3-app 会把 Prisma 作为默认选项数据库通常先选 SQLite 作为起步。千万别觉得 SQLite 太“玩具”我开发阶段几乎都用它通过环境变量切换成 MySQL 或 PostgreSQL 也就改一行 URL 的事。先让项目跑起来比一开始就上复杂环境重要得多。还有一个容易被忽略的选项是否初始化 Git 仓库。如果脚手架环境里检测不到 Git它会问你要不要创建 git 仓库建议选是这样初始版本就是一个干净的 commit方便后续回退。2.2 目录结构与配置文件的职责初始化完成后项目结构大致如下prisma/ schema.prisma # 数据库模型定义 src/ app/ # App Router 的页面目录 pages/ # 如果你选了 Pages Router server/ api/routers/ # tRPC 路由定义 db.ts # Prisma Client 实例 auth.ts # 鉴权配置如果选了 trpc/ server.ts # tRPC 服务端初始化 client.ts # tRPC 客户端工具函数 .env # 数据库地址、鉴权密钥等t3code 对目录划分其实有一种隐含的纪律凡是涉及数据库访问和业务逻辑的代码统一放在src/server下面凡是页面组件和 UI 代码都放在src/app或src/pages下面。这样做最大的好处是你不会在组件代码里看到一堆 SQL 或者 Prisma 调用边界清晰。配置文件方面tsconfig.json里脚手架默认配置了路径别名/*对应src/*。我强烈建议项目里统一使用/server/...和/trpc/...这样的引入方式而不是相对路径。随着项目变大../../../这种写法不仅难看而且重构时极其脆弱。.env文件要盯紧t3code 的做法是所有的环境变量都集中在.env例如DATABASE_URL、AUTH_SECRET等。脚手架生成的.env.example是给你的队友参考用的.env本身要加入.gitignore不要提交到仓库。我见过太多次因为环境变量互相覆盖导致本地跑不通的情况排查了半天才发现是.env里写错了。3. 核心细节tRPC 路由、输入校验与鉴权编码要点3.1 用 Zod 定义输入把校验前移到边界tRPC 本身不做输入校验它把这块工作交给配套的 Zod。Zod 是一个 TypeScript 优先的 schema 声明库最直观的特点是你用 Zod 定义校验规则规则本身就携带类型信息TypeScript 可以直接用z.infer推导出对应的类型。实际写一个路由时通常的做法是import { z } from zod; import { createTRPCRouter, publicProcedure } from /trpc/server; const createPostInput z.object({ title: z.string().min(2).max(100), content: z.string().min(1), }); export const postRouter createTRPCRouter({ createPost: publicProcedure .input(createPostInput) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: input, }); }), });在这个例子里z.object定义的每一个字段约束比如min(2).max(100)不仅生效在服务端入口处的校验环节团队成员在调用trpc.post.createPost.useMutation().mutate()时编辑器也会强制提示他必须传满足条件的 title 和 content。也就是说校验逻辑不用在前端重复写一遍也不会出现在后端忘了加校验的情况。我建议把每一个 router 的输入 schema 独立成一个变量而不是直接在.input()里内联。这样后续复用同一个 schema 时可以引用不会写两遍。另外Zod 的z.infer也可以直接用于导出类型比如一些跨模块复用的 DTO 类型没必要在多个文件里重复定义 interface。3.2 路由拆分与 procedure 复用tRPC 的 router 支持嵌套这一点和 Next.js 的目录路由很像。你可以有一个最外层的appRouter里面挂载userRouter、postRouter、commentRouter等。这种拆分方式让每个模块的代码隔离也方便多人维护时减少冲突。比路由拆分更重要的是 procedure 的复用。在 t3code 编码实践中几乎每个项目都会定义两类基础 procedure一个是publicProcedure谁都能调用的比如获取公开文章列表另一个是protectedProcedure需要登录后才能调用的比如发布文章。protectedProcedure 的实现其实就是给 publicProcedure 加一个中间件在中间件里检查当前会话有没有登录用户const protectedProcedure publicProcedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED, message: 请先登录, }); } return next({ ctx: { user: ctx.session.user, }, }); });这里最关键的一点是经过中间件之后下游的 resolver 拿到的 ctx 类型会自动多出一个非空的user字段。也就是说你在每个 protectedProcedure 的实现里不需要再去判断“用户是否存在”因为类型已经替你保证了这个前提。如果你在 resolver 里仍然尝试对 user 做空值判断反而会提示类型错误这种“被类型逼着精简代码”的感觉正是 t3code 的魅力所在。3.3 从组件里调用不用手写 API clientt3code 开发体验里最直观的爽点是前端调用。如果你用 App Router需要在app/_trpc/Provider.tsx里初始化 tRPC 客户端并包裹整个应用。接着在任意客户端组件中就可以直接调用use client; import { trpc } from /trpc/client; export function PostCreateButton() { const utils trpc.useUtils(); const createPost trpc.post.createPost.useMutation({ onSuccess() { utils.post.list.invalidate(); }, }); return ( button onClick{() createPost.mutate({ title: new post, content: hello, })} 发布文章 /button ); }这里的trpc.post.createPost链式调用返回的 mutate 函数、变量类型、返回值类型全部都是自动推导出来的。最常用的useUtils().post.list.invalidate()则是在某个 mutation 成功后让相关的查询请求自动重新拉取。我在实际项目里大量依赖这个 API它把前端状态同步的成本降到了最低不需要手动管理请求时机和缓存失效。新手很容易把 tRPC 理解成 axios 的替代品其实它本质上是 RPC 框架。你不用定义 URL、不用设置 method、不用写响应拦截器这些底层细节全部封装掉了。如果你从传统 REST API 项目迁移过来头两天会有点不适应因为你会下意识地想去“找到那个请求函数放在哪”但实际上它根本不存在你只需要调用一个和函数名同构的 API。4. 实操过程从零实现一个“文章发布”功能模块4.1 需求拆解与数据模型设计以文章模块为例把 t3code 的完整开发闭环走一遍。假设需求是用户可以发布带标题和正文的文章其他人可以浏览文章列表并且列表页能看到作者头像和发布时间。先设计 Prisma 模型。文章这里是核心实体还要关联作者所以需要一个简单的用户模型。在实际项目中用户表通常由 auth 方案管理这里为了演示简洁直接用 Prisma 定义最小化的模型model User { id String id default(cuid()) name String? posts Post[] } model Post { id String id default(cuid()) title String content String createdAt DateTime default(now()) author User? relation(fields: [authorId], references: [id]) authorId String? }定义好模型后记得执行npx prisma db push npx prisma generatedb push会把模型同步到数据库generate会重新生成类型文件让 Prisma Client 里的post、user这些操作带上新类型。这个步骤在每次改动 schema 后都必须执行否则 tRPC 路由里拿到的数据类型还是旧的。4.2 后端路由与数据库写入实现有了模型就可以写 router 了。把文章模块的路由拆成list和createPost两个 procedure。list 用querycreatePost 用mutationexport const postRouter createTRPCRouter({ list: publicProcedure .query(async ({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, include: { author: true }, }); }), createPost: protectedProcedure .input(z.object({ title: z.string().min(2).max(100), content: z.string().min(1), })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { ...input, authorId: ctx.user.id, }, }); }), });注意这里 createPost 用的是 protectedProcedure因为写文章必须登录。ctx.user.id就是之前在中间件里注入的用户字段不需要再通过 session 到处查。这样设计之后前端的按钮没有登录时应该隐藏但即便有人绕过前端直接调用接口服务端也会返回UNAUTHORIZED错误这个安全兜底非常重要。4.3 前端页面与类型推导接入页面采用 App Router 的话列表页尽量做成服务端组件直接调用查询会比较奇怪——tRPC 在服务端组件里使用需要额外配置最常见的是在服务端组件里直接使用 Prisma 查询或者把客户端组件分层。这里我更推荐的做法是外层服务端组件负责读取数据内层客户端组件负责交互展示。但如果你只想使用 tRPC 一条链拉通可以做成客户端组件再用trpc.post.list.useQuery()调用。这种模式对小型项目来说非常简单缺点是首屏会有一个加载过程不利于 SEO。我给一个通用方案列表页用服务端组件直接访问ctx.db来拿数据发布按钮用客户端组件调用 tRPC mutation。像这样的混搭在实际项目里非常常见也是 t3code 被诟病人说他“并不是银弹”的地方——你仍然需要根据场景判断哪一块数据适合走 tRPC哪一块直接走 ORM 更省事。前端页面的代码大致如下// app/posts/page.tsx服务端组件 import { db } from /server/db; import PostList from ./PostList; export default async function PostsPage() { const posts await db.post.findMany({ orderBy: { createdAt: desc }, include: { author: true }, }); return PostList initialPosts{posts} /; }而内部PostList组件如果需要刷新数据再在合适的位置调用trpc.post.createPost.useMutation()成功后再router.refresh()刷新服务端组件的数据。这种将数据获取与交互更新解耦的思路比把所有逻辑塞进一个客户端组件更容易维护。4.4 端到端验证与联调检查写完功能和页面后我习惯按下面这个清单做验证缺一不可输入边界验证不填 title 直接请求确认服务端返回 400 级错误填超长字符串确认被 Zod 拦截。未登录验证退出登录状态下调用 createPost mutation确认收到UNAUTHORIZED。类型完整性验证故意给 mutation 传入一个不存在的字段确认 TypeScript 报错。数据落库验证发布一篇新文章看列表页是否出现并刷新页面确认持久化。数据库约束验证删除用户后文章是否级联处理或者保留 author 为 null取决于关系定义。这五项做下来基本能发现大部分问题。我在实际项目里发现最隐蔽的问题往往是数据库层面的比如 authorId 的关联约束没设好删除用户导致文章完整性问题。这类 bug 在前端根本测不出来只有用上面第 5 条来回测才会暴露。5. 常见问题与排查技巧实录5.1 tRPC 返回类型报错Procedure returns void刚上手写 tRPC 路由时容易遇到“这个 procedure 返回的怎么是 void”的困惑。排查后发现只要 mutation 或 query 处理函数里没有显式returntRPC 会根据返回类型推导为 void前端调用时拿到的结果就是空的。这个看起来很“基础”的问题真正坑人的地方在于当你后续给这个 procedure 的返回体增加字段但忘记加上return时前端拿到的一直是 undefined但不会报任何编译错误。我的建议是每个 mutation 在业务上尽量返回更新后的实体比如 createPost 返回创建的 postupdatePost 返回修改后的 post。这样既方便前端直接使用结果更新本地状态也能避免“看起来没报错但数据根本没用上”的幻觉。5.2 App Router 里用 tRPC 的坑App Router 的服务端组件没法直接绑定 tRPC client 的 hooks因为服务端组件不执行 useEffect 这类客户端逻辑。如果你在页面加载时就想把 tRPC 的 query 结果渲染出来一种做法是让整个页面模块变成use client但这样会丢失 SSR 和 SEO 优势。我实际测试下来比较可靠的方案是“服务端数据负责首屏客户端 tRPC 负责交互”。不要在服务端组件里纠结如何调 tRPC直接用 Prisma 或 server db 读取数据交互场景比如发布文章、删除文章、点赞等再用客户端组件的 tRPC hooks。虽然这意味着一个页面里有两套数据获取逻辑但分工明确性能和体验都不吃亏。5.3 部署后白屏/环境变量缺失本地跑得好好的部署到生产环境却白屏或接口全挂大多数情况出在环境变量。t3code 项目的环境变量被代码里多个地方引用比如DATABASE_URL没设会导致 Prisma 启动失败AUTH_SECRET没设会导致鉴权模块报错。排查时不要只盯.env.local先确认部署平台的 Environment Variables 里是否一一对应。另外注意生产环境数据库如果是线上数据库要记得执行npx prisma db push或者跑迁移脚本否则 Prisma 连接的是旧表结构路由查询必报错。我建议部署流程里加一个构建前脚本npx prisma generate这个命令通常在你本地postinstall已经跑过但部署平台不一定执行显式加到构建步骤里更保险。还有一个容易被坑的点如果你的数据库是云端托管连接串里的内网地址和外网地址结果完全不同很多人本地用内网 IP 跑通部署后连不上就是这个原因。5.4 性能优化按需加载与缓存策略t3code 的架构下数据请求像函数调用一样丝滑代价是如果组件里频繁调用 query可能会造成大量重复请求。比如列表页多个子组件都调用了trpc.post.list.useQuery()如果没有做缓存复用同一个接口会发多次。tRPC 内置了 React Query 的缓存机制默认情况下相同 key 的请求会共享缓存。一般不用担心重复请求问题。但是要注意staleTime的默认值如果不设置组件挂载时往往还是会重新请求。我的习惯是在 Provider 里设置一个合理的默认staleTime比如 30 秒对不常变化的数据设为 5 分钟。这样既保证了数据新鲜度也减少了无意义的网络开销。另一个优化点是把 tRPC 的 URL 聚合生产环境建议在 Next.js 的 rewrites 里把/api/trpc固定便于在 CDN 层做缓存策略。这些细节看似不起眼但对流量稍大的项目提升非常明显。做 t3code 这一年多我最大的体会就是“类型安全不是请客吃饭”它确实需要你先适应一套新规矩但一旦上手你会发现自己再也回不去以前全靠手写接口类型和文档同步的日子。如果你准备启动一个中型全栈项目建议把这些组合亲自试一遍给彼此一点磨合时间它会把开发体验拉到一个相当舒服的水平。