t3code实战:T3 Stack如何实现前后端类型安全一体化
在很长一段时间里我都是后端写一套接口文档、前端照着字段对接的传统开发流直到接手一个对类型安全要求极高的中台项目才第一次认真了解 t3code 背后这套 T3 Stack 的完整打法。当时最惊讶的不是它堆了多少新框架而是从数据库到页面组件类型居然一路畅通无阻改了个字段名想让前后端一起报错它真的就全都给我标红了。这个体验直接改变了我搭全栈应用的思路。这篇文章我把 t3code 相关的技术选型逻辑、完整搭项目的过程、以及中间踩过的几个坑从头讲一遍适合打算做全栈项目的开发者参考也适合那些已经听说过 T3 但一直没搞明白它到底解决什么问题的人。1. t3code 的核心逻辑把类型安全铺满整个开发链路1.1 先搞清楚 t3code 到底凑了什么第一次看到 t3code 这个概念很多人的反应是又一个全家桶。实际上它并不是一个具体的框架而是一套组合方案官方叫 T3 Stack。这里的3不是三代的意思而是三门技术刚好都以 T 开头TypeScript、Tailwind CSS、tRPC。配合最常用的宿主框架 Next.js再加 Prisma 做 ORM、NextAuth.js 做认证这就是一套完整的类型安全全栈样板。Next.js承担路由、渲染模式、API 入口是整套项目的骨架TypeScript给所有代码加类型约束但它的价值要在 tRPC 和 Prisma 配合下才真正放大Tailwind CSS负责样式不参与业务逻辑却是 T3 应用很顺手的一部分tRPC让前后端共享类型调用 API 像调用本地函数一样不需要手写接口文档Prisma数据库模型和查询代码的类型也参与推导。这里最容易被误解的是 tRPC。它并不是像 REST 或者 GraphQL 那样的网络协议本质上它是一个 RPC 调用层客户端通过 tRPC 暴露出来的 hooks 去调用服务端函数类型定义从前端到后端是天然共享的。你改一个入参类型前端调用的地方立刻有类型提示也能直接报错。1.2 为什么需要端到端类型安全而不是只给后端上 TypeScript很多项目后端已经是 TypeScript 了接口层也做了一层类型校验但前端拿到的仍然是一堆普通 JSON。前端调用时靠的是手动声明的 interface一旦后端改了字段前端没同步更新运行时就会出诡异的 undefined。端到端类型安全解决的恰恰是这个沟通问题类型是唯一的没人需要手动维护第二份。t3code 的实现方式是把服务端的 procedurestRPC 对接口函数的称呼作为类型导出前端通过import type { AppRouter }拿到整个路由的类型结构。React Query 会根据这个类型自动给出每个查询的 data、isLoading、error。这种设计在多人协作时的价值很大。原来前后端各留一份接口定义经常出现对不上版本的情况现在改类型只需要改一处全项目都会跟着反应。我实际体验下来TypeScript 报错变多了但往往不是坏事错的在编译期就拦下来而不是等到用户点按钮才报错。2. 初始化 t3code 项目的完整实操从脚手架到跑通 Hello World2.1 create-t3-app 的交互式初始化T3 官方提供了create-t3-app脚手架不需要手工集成各种依赖它会一步一步问你需要的功能模块。我建议第一次用的时候把 TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth 全部选上因为后面加模块比一开始多用一点要麻烦得多。npm create t3-applatest my-t3-app进入交互式选项后项目名建议用小写加中划线避免后续包名解析出问题。选择模块这一步我踩过一个坑如果你暂时不想要 NextAuth可以先不选但数据库建议直接选上 Prisma因为 tRPC 如果没有任何实际数据读写很难理解它的价值和数据流动方式。初始化完成后目录结构大致如下my-t3-app/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ └── root.ts │ │ └── db.ts │ ├── pages/ │ │ ├── api/ │ │ │ └── trpc/[trpc].ts │ │ └── index.tsx │ ├── styles/ │ └── utils/ │ └── api.ts2.2 运行前必须处理的依赖与配置很多人在npm run dev之后就卡住因为 Prisma 还没有生成客户端代码。正确的顺序是先创建.env文件把DATABASE_URL配好。T3 默认用 SQLite 最省事文件路径file:./db.sqlite执行npx prisma db push同步数据库结构执行npx prisma generate生成 Prisma Client最后再npm run dev。启动之后访问http://localhost:3000会看到一个默认页面到这里最基本的链路已经跑通了。这里有一个非常影响体验的配置tRPC 的 API 路由文件必须放在src/pages/api/trpc/[trpc].ts不要自定义路径。如果移了位置客户端和服务端之间的链接就断开了控制台会报一个请求返回 404。我一同事当时自定义成src/pages/api/trpc.ts排查了半天才发现路径不对。3. 数据库模型与 tRPC Router 联动核心代码这样写才顺3.1 Prisma Schema 怎么设计才有扩展性tRPC 的类型推导能力以数据库模型为基础所以 schema 设计直接影响前端类型体验。以最经典的 todo 应用为例// prisma/schema.prisma generator client { provider prisma-client-js } datasource db { provider sqlite url env(DATABASE_URL) } model Task { id String id default(cuid()) title String completed Boolean default(false) createdAt DateTime default(now()) userId String }定义完成后npx prisma db push数据库的表就创建好了。这里我建议 id 不要用自增整数用cuid()更方便多实例部署也能避免前端遍历时把 id 当数字处理的不一致问题。有了模型src/server/db.ts里会默认导出一个db实例import { PrismaClient } from prisma/client; import { env } from ~/env.mjs; const globalForPrisma globalThis as unknown as { prisma: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (env.NODE_ENV ! production) globalForPrisma.prisma db;这段代码可以在开发热更新时复用同一个 Prisma 实例避免连接数暴涨。改成生产环境后不会开启全局复用是符合预期的。3.2 tRPC Router 的创建与暴露tRPC Router 是服务端逻辑的入口。T3 默认在src/server/api/root.ts里聚合所有子路由建议沿用这个习惯不要把文件拆得太散先按业务模块分 router再在 root 里合并// src/server/api/root.ts import { createTRPCRouter } from ~/server/api/trpc; import { taskRouter } from ./routers/task; export const appRouter createTRPCRouter({ task: taskRouter, }); export type AppRouter typeof appRouter;在 task router 里定义查询与变更// src/server/api/routers/task.ts import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const taskRouter createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) { return ctx.db.task.findMany({ orderBy: { createdAt: desc }, }); }), toggle: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const task await ctx.db.task.findUnique({ where: { id: input.id } }); if (!task) throw new Error(任务不存在); return ctx.db.task.update({ where: { id: input.id }, data: { completed: !task.completed }, }); }), });这里的zod是 tRPC 的类型校验库。publicProcedure表示无需登录即可调用后面接入 NextAuth 后会换成protectedProcedure。这样写的最大好处是入参校验放在服务端同一处前端不需要重复写校验逻辑而前端调用toggle时IDE 能直接提示需要一个{ id: string }入参。3.3 为什么 query 和 mutation 要分开而不是一个函数搞定新手经常问增删改查不都是发请求吗为什么 tRPC 非要分开query和mutation这背后和 React Query 的设计有关。query是用来获取数据的结果会被缓存重复调用时可以直接复用mutation是改变数据的操作执行完后通常需要让相关查询失效并重新拉取。const utils api.useUtils(); const toggleMutation api.task.toggle.useMutation({ onSuccess: () { utils.task.getAll.invalidate(); }, });invalidate会让 React Query 重新拉取getAll页面数据自动更新不需要手动 setState。如果把一个写操作定义成 query数据就永远不会自动刷新非常容易产生 UI 和数据不一致的问题。我在项目中最常用到的三段式写法是useQuery读数据useMutation写数据在 mutation 的onSuccess里 invalidate 相关 query 的缓存。这套循环写顺手之后状态管理的负担会小很多全局 store 也不再是必要选项了。4. 前端数据消费与页面渲染tRPC hooks 的实际体验4.1 写一个完整的双向联动页面假设要做这样一个页面展示任务列表点击按钮切换完成状态并显示当前未完成数量。前端代码可以直接这样写// src/pages/index.tsx import { api } from ~/utils/api; export default function Home() { const { data: tasks, isLoading } api.task.getAll.useQuery(); const toggleTask api.task.toggle.useMutation({ onSuccess: () { utils.task.getAll.invalidate(); }, }); if (isLoading) return div加载中.../div; const doneCount tasks?.filter((t) t.completed).length ?? 0; return ( main classNamemx-auto max-w-xl p-6 h1 classNametext-2xl font-bold任务清单/h1 p classNamemt-2 text-gray-600已完成 {doneCount} 项/p ul classNamemt-4 space-y-2 {tasks?.map((task) ( li key{task.id} classNameflex items-center gap-3 border p-3 input typecheckbox checked{task.completed} onChange{() toggleTask.mutate({ id: task.id })} / span className{task.completed ? line-through : } {task.title} /span /li ))} /ul /main ); }令人舒服的是tasks里的每一项都带有id、title、completed、createdAt的完整类型提示。即使 Prisma 模型加了一个新字段前端这里也会立刻出现这个字段的补全。4.2 处理加载态、错误态和乐观更新的最佳实践T3 项目里初始代码只显示了数据加载但真实项目中加载态和错误态绝对不能少。tRPC 的 hooks 直接返回isLoading、isError、error这几个状态处理起来非常直观if (isError) return p classNametext-red-500加载失败{error.message}/p; const utils api.useUtils(); const toggleTask api.task.toggle.useMutation({ onMutate: async (variables) { // 取消正在进行的查询避免覆盖乐观更新 await utils.task.getAll.cancel(); const previousData utils.task.getAll.getData(); utils.task.getAll.setData(undefined, (oldTasks) oldTasks?.map((task) task.id variables.id ? { ...task, completed: !task.completed } : task, ), ); return { previousData }; }, onError: (err, variables, context) { if (context?.previousData) { utils.task.getAll.setData(undefined, context.previousData); } alert(操作失败${err.message}); }, onSettled: () { utils.task.getAll.invalidate(); }, });乐观更新的思路是先立刻更新本地缓存让界面变得很快请求失败时再把缓存回滚到之前的状态。这种模式在 todo 应用里看起来有点小题大做但在用户高频率操作的业务场景下体感差异非常明显。我个人强烈建议把这套写法当成基础款来掌握。4.3 绕开一个让人困惑的坑ctx 里为什么直接有 db第一次看 tRPC 代码时我对ctx.db特别困惑为什么这个 context 里自动就有数据库实例这其实是在src/server/api/trpc.ts里显式注入的export const createTRPCContext (opts: CreateNextContextOptions) { return { db, session: opts.req.session, }; };createTRPCContext返回什么后续所有 procedure 的ctx里就有什么。想加日志、加请求 ID、加用户上下文都是往这里加。这是 tRPC 扩展性最强的一个口子。官方默认把session也注入进去就是故意让你在写业务逻辑时可以方便地读取当前登录用户而不用每一处都手动处理请求头。5. 认证状态接入与部署落地最容易翻车的地方5.1 把 NextAuth 接进 tRPC 的正确姿势只做到任务列表这样的 demo 肯定不够真实项目逃不开登录态。T3 将 NextAuth 预置了进来默认配置在src/server/auth.ts。如果是新项目初始化时建议选择 Discord、GitHub 或者邮箱 Provider 中的一种做测试。核心是把session传给 tRPC 的 context同时提供一个protectedProcedureimport { createTRPCRouter, publicProcedure, protectedProcedure } from ~/server/api/trpc; export const taskRouter createTRPCRouter({ myTasks: protectedProcedure.query(async ({ ctx }) { return ctx.db.task.findMany({ where: { userId: ctx.session.user.id }, }); }), });protectedProcedure的实现其实很简单本质就是在进 handler 之前做一次 session 判断没登录就直接抛异常。这样前端调用myTasks时如果未登录会收到一个明确的报错而不是拿到空洞的数据。实际配置过程中最常见的坑是NEXTAUTH_SECRET 没配。开发环境可以不配也能跑但生产环境启动时 NextAuth 会直接报错。部署前记得生成一个随机字符串openssl rand -base64 32然后填到环境变量的NEXTAUTH_SECRET里。5.2 部署时的数据库迁移与连接池问题本地开发用 SQLite 很省心但一旦部署到线上我建议尽快切到 PostgreSQL。T3 官方文档对部署有具体说明切换的关键步骤是把datasource db的 provider 改成postgresql在.env中把DATABASE_URL换成 PostgreSQL 的连接串执行npx prisma migrate deploy而不是db push分别对应生产环境的迁移流程和开发环境的快速同步Prisma 到数据库的连接记得开启连接池连接串添上?connection_limit5之类参数避免 serverless 环境反复创建连接。这里我踩过一个大坑在 Vercel 上部署的 Next.js 函数是无状态的如果DATABASE_URL不带连接池配置数据库连接数会很快被打满。后来在 Prisma 官方文档里找到方案用 PgBouncer 或者连接串参数把连接池开起来才解决。这个配置在生产环境是必须的不然并发一高就崩。5.3 前后端协同的线上排查经验tRPC 的报错默认是TRPCClientError服务端抛出的消息会被安全地遮蔽掉一层这是为了防止把内部细节泄露给用户。线上排查时我建议把日志采集做在 tRPC 中间件里每次请求把 path、耗时、状态码写一条结构化日志。可以用 console 或接入你的日志平台能大大节省排错时间。一个常见困惑是为什么线上 tRPC 报错信息不像本地那么具体这不是 bug是默认安全策略。真要看到完整错误可以在createTRPCClient里配置customFetch由自己决定哪些 header 与错误需要透传或者在服务端中间件里把shape的 error 信息替换为内部 logger 记录。不要为了图方便关掉这个遮蔽线上排错靠日志就好。6. 关于 t3code 项目结构的整体使用体会很多人问我T3 Stack 是不是只适合小项目或者只适合什么都用 TypeScript的团队。我的实际回答是它更适合那些已经厌倦了重复维护类型定义和接口文档的团队。项目大小不是关键关键在于你们是不是愿意接受以类型为准的开发方式。只要是前后端都愿意用 TypeScript 的项目这套模式带来的协作收益就会很明显。如果团队里前端只会 JavaScript不熟悉 TypeScript那 T3 的上手曲线会陡一些。相反如果团队对类型比较敏感你会发现大部分时间都在写业务逻辑而不是纠结类型对不对、字段有没有同步。这是一个非常本质的体验差异。另外再说说测试这件事。tRPC 和 React Query 的可测试性都很好因为 context 可以被 mock不需要真的启动一个 HTTP 服务。T3 项目的核心逻辑都集中在 router 层可以写针对 router 的单元测试直接通过调用 procedure 的方式验证不用经过 HTTP。我个人测试下来的效果是这种测试覆盖的是业务规则不容易受前端状态影响比端到端测试稳定得多。最后分享一个小技巧很多人刚开始写 tRPC 时会把所有查询都放在一个大 router 里。我建议按业务域拆 router再按页面维度拆 selection。不要一上来就做全局缓存策略先把 React Query 的默认缓存文档读一遍搞清楚staleTime和gcTime的含义再结合你的数据刷新频率调整否则很容易出现刷新了页面还是旧数据的困惑。这篇文章写到的代码和方法全部都在 T3 的默认约定内跑通它们不需要额外装太多东西。如果你正在做全栈应用的选型或者已经在用这套方案但总觉得哪里不顺手照着上面第七节的思路检查一遍你的数据流大概率会有收获。