用t3code这个代号,我带队把一个内部老项目从“前端调接口看文档猜字段”的泥潭里拉了出来,整个流程走下来最大的感受就是:类型安全不是锦上添花的装饰,它本身就是生产力。t3code不是某个单一的开源库,而是围绕T3 Stack这一套全栈TypeScript工程化实践的总称,核心玩法是把Next.js、tRPC、Prisma、Tailwind CSS和Zod这些工具拧成一股绳,让类型从前端组件一路穿透到数据库表结构。这篇文章我打算把整套搭建思路、核心链路实操和踩坑记录完整复盘一遍,适合正在做全栈TypeScript项目、尤其是受够了前后端类型不同步的团队参考。
1. 项目整体设计与核心思路拆解
1.1 T3 Stack到底在解决什么问题
先别急着抄代码,得先搞清楚T3 Stack这几个字母背后是在跟什么痛点对着干。传统的前后端分离开发模式,前后端各维护一套接口文档,前端用axios或者fetch去调后端地址,返回的数据结构靠“后端同事口头描述”或者看Swagger。这种模式能跑,但效率极低——改一个字段名,后端改了但没同步文档,前端跑过去一问,发现类型又对不上了,调试半天最后发现是某个嵌套对象的结构跟前端预期不一样。
t3code这套实践的核心逻辑不同:它把API层的类型定义直接绑定在前后端共享的代码之上。服务端定义好的Router,客户端的调用方在写代码的时候,IDE里能直接点出返回值的字段、类型、是否可空,字段改名之后前端编译期直接报错,根本拖不到联调阶段。这个“类型即文档”的体验,说句实话,一旦用上就回不去了。
T3 Stack这个名字其实来自“create-t3-app”这个脚手架,T3是Typo3的缩写(创始人的网名),但这不重要,重要的是它选型背后的取舍逻辑。整个栈里面,Next.js负责服务端渲染和页面路由,tRPC负责API通信,Prisma负责数据库访问和迁移,Tailwind负责样式,Zod负责数据校验。每一层都是各自领域里TypeScript支持最丝滑的工具,选它们不是随大流,是为了保证类型在整个链路里不“断头”。
1.2 端到端类型流的“为什么”与“怎么做”
很多第一次接触t3code的朋友都会问一个问题:tRPC跟REST API有什么区别?为什么不直接用GraphQL?我的理解是,REST的痛点在于Endpoint的URL、请求体、响应体三者之间没有强绑定,一个接口一条文档,天生跟类型安全绝缘;GraphQL虽然能解决过度获取问题,但引入了一套GQL Schema语言,然后还需要在TypeScript和GQL之间做代码生成,配置链路长,团队学习成本陡增。tRPC的思路则是直接用TypeScript函数作为API的入口,函数的入参和返回值就是天然的类型定义,不需要额外生成代码,编译器和IDE直接就能吃到类型信息。
用生活中类比的话,REST像是你打电话给前台,说“我要找财务部门的人”,对方给你转接,但你们之间说的是“内部密语”,只有人话转述才能理解;tRPC则像是你直接加对方微信,两个人用的是同一种语言,消息发过去对方原样收到,不存在转译损耗。这里的“同一种语言”就是TypeScript类型系统,服务端函数的参数类型和返回类型,客户端用完全一样的类型对象去消费。
这个端到端的类型流能成立,依赖的是Monorepo或单仓多目录共享代码。t3code实践中,前端代码和后端代码在同一个项目文件夹里,TypeScript的模块解析可以直接把类型定义引过来,不需要打包成npm包发布再安装。这就是t3code能实现“类型穿透”的基础前提——代码共享不能通过HTTP跨域解决,必须在编译期就在一个Tsconfig范围内。
1.3 t3code适合什么场景,不适合什么场景
聊完理念就得泼冷水了。t3code这套玩法不是万金油,它最舒服的战场是中小型全栈项目、内部管理系统、SaaS产品的MVP阶段,以及团队本身就以TypeScript为主要语言的场景。这类项目的特点是业务逻辑相对集中、API数量不会爆炸式增长、团队对全栈一体的接受度高。
反过来,如果项目是典型的多人分布式协作,前端团队和后端团队完全独立,API由后端平台团队统一发布并要兼容多端(iOS/Android/Web),甚至还有第三方开发者接入,那tRPC这种直接函数调用的模式就不太合适,因为类型共享机制要求代码库必须在一起,而且外部消费者没法直接使用你的TypeScript类型。另外,如果项目对性能的极限压榨到得像金融交易系统那样需要精细控制网络包,tRPC的JSON序列化协议也会成为一个瓶颈点。
但就我个人的实践经验来说,80%的业务系统都是t3code的舒适区。用了这套东西,你省掉的不只是接口文档的维护成本,更重要的是打断了“前后端扯皮”这个团队协作毒瘤,让每个开发者都能独立跑通从数据库到页面的完整链路。
2. 从零搭建t3code项目:脚手架与工程结构
2.1 初始化与依赖安装的实操细节
t3code项目的起步方式很简单,命令行直接跑一下脚手架工具:
npx create-t3-app@latest my-t3-app这个命令运行后会进入交互式配置界面,它会问你几个问题,这里我建议的选项如下:
- TypeScript or JavaScript:必须选TypeScript,这是t3code的灵魂所在;
- Next.js:使用App Router还是Pages Router?新项目无脑选App Router,毕竟这是Next.js官方现在主推的方向,后续生态更新都会优先对齐;
- tRPC:必须勾选,否则t3code不成立;
- Prisma:建议勾选,除非你已经确定要用其他ORM(但那就偏离T3生态了);
- Tailwind CSS:建议勾选,样式统一管理会少很多烦恼;
- Zod:建议勾选,tRPC的入参校验全靠它;
- ESLint / Prettier:都勾上,规范要从第一天抓起。
跑完之后,你会得到一个结构非常清晰的项目目录,大致长这样:
├── prisma │ └── schema.prisma ├── src │ ├── server │ │ ├── api │ │ │ ├── routers │ │ │ │ ├── post.ts │ │ │ │ └── root.ts │ │ │ ├── trpc.ts │ │ │ └── root.ts │ ├── trpc │ │ ├── server.ts │ │ └── react.tsx │ ├── app │ │ ├── api │ │ ├── page.tsx │ │ └── layout.tsx │ └── styles │ └── globals.css └── package.json这里我重点说几个容易被忽略的细节。第一,src/server/api/routers这个目录里每个文件对应一组业务相关的API接口(比如post.ts处理帖子相关,user.ts处理用户相关),最终在root.ts里通过createCallerFactory合并成一个统一的Router。第二,src/trpc/react.tsx这个文件封装了React Query和tRPC的连接器,前端所有数据请求都会经过这里,它导出一个api对象,组件里直接api.post.getAll.useQuery()就能触发请求,返回的data自带完整类型信息。
2.2 环境变量、数据库连接与初始化配置
脚手架跑完还不算完,得配置数据库。t3code默认生成的prisma/schema.prisma里用的是SQLite,但真实项目基本都会切到PostgreSQL或MySQL。改法也很简单,在.env文件里设置DATABASE_URL,比如:
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"但这里有个大坑:Prisma的DATABASE_URL和Next.js的环境变量是共用的,如果你的.env文件里写了中文注释或者带特殊字符的密码,会导致Prisma CLI解析失败。踩过一次之后我的习惯是:.env文件统一用英文注释,密码尽量不用@、#这类需要URL编码的字符,实在避免不了就提前进行百分号编码。
数据库连接字符串配置好之后,接下来要跑迁移命令:
npx prisma db push开发阶段用db push最省事,它直接根据schema.prisma里的模型定义把表结构同步到数据库,不需要生成迁移文件。但到了正式环境,必须要用npx prisma migrate dev生成规范的迁移SQL,否则团队成员和测试环境的数据库结构很难保持一致。这里插一嘴我个人的规范:开发分支上尽量用db push快速迭代,但每个功能合入主分支之前,一定要把变更沉淀为一篇migration文件,方便后续回滚和复盘。
3. 核心链路实操:数据库、API与前端状态同步
3.1 Prisma Schema设计:从业务需求到数据模型的映射
拿一个典型的博客系统举例,我们要做文章列表、文章详情、用户登录、评论这四块功能。先定义数据模型,在prisma/schema.prisma里写:
model User { id String @id @default(cuid()) email String @unique name String? posts Post[] comments Comment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String comments Comment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Comment { id String @id @default(cuid()) content String post Post @relation(fields: [postId], references: [id]) postId String author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) }设计数据模型时最关键的点是ID选择。t3code风格的项目我喜欢用cuid()生成字符串主键,而不是数据库自增整数。为什么?第一,cuid由客户端或服务端生成,不依赖数据库自增,批量创建时不需要等待数据库返回ID;第二,字符串ID在URL里更安全,不容易被遍历猜测;第三,cuid自带时间信息,便于排序。缺点是索引体积会比整数大一点,但这点成本在中小项目里完全可忽略。
3.2 tRPC Router定义:从query到mutation的完整链路
数据模型定好之后,接下来就是定义API Router。在src/server/api/routers/post.ts里,写一个文章相关的Router:
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({ where: { published: true }, orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); }), // 文章详情:入参是文章ID,出参包含作者和评论 getById: publicProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ ctx, input }) => { return ctx.db.post.findUnique({ where: { id: input.id }, include: { author: { select: { name: true } }, comments: { include: { author: { select: { name: true } } } }, }, }); }), // 创建文章:入参是标题和内容,出参是创建后的完整记录 create: publicProcedure .input( z.object({ title: z.string().min(1).max(200), content: z.string().min(1), }) ) .mutation(async ({ ctx, input }) => { // 注意:真实项目中这里应该从session获取当前用户ID return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: "dev-user-id", }, }); }), });这里有个初学者容易搞混的地方:publicProcedure是公开接口的意思,任何人不用登录就能调用。真实项目里需要登录后才能操作的接口,要用protectedProcedure,它内部会检查session,没有登录直接抛异常。t3code脚手架默认生成的src/server/api/trpc.ts里已经写好了这个逻辑,本质上是中间件的思路——每个procedure在执行前,先通过ctx拿到请求上下文里的session信息,然后判断是否合法。
我在实际项目里,还会给每个Router加一层“业务错误码”规范。比如创建文章的时候,如果标题重复,就直接throw new TRPCError({ code: "CONFLICT", message: "标题已存在" }),前端捕获到这个错误后可以直接弹提示框,不用再写状态码判断的逻辑。这个习惯保持了半年之后,团队再也没出现过后端返回错误但前端不知道是啥情况的窘境。
3.3 Zod校验:为什么它是tRPC的黄金搭档
tRPC的入参校验强烈建议用Zod,理由不只是“能校验”,而是“校验器本身就是类型的唯一来源”。当你写了上面那段z.object({ id: z.string().cuid() }),TypeScript编译器可以通过z.infer<typeof schema>反向推导出生效的数据类型,不需要再手写interface。这意味着,入参类型定义写一遍,前后端全部通用,绝不会有“校验器跟实际使用类型不一致”这种低级bug。
Zod的强大之处在于它把运行时校验和编译期类型绑定在同一个地方。用过Joi或者Yup的朋友应该有体会:校验器是一套代码,TypeScript类型是另一套代码,两套东西不一致的时候,没有报错,只有运行到相关业务逻辑时才会炸。Zod彻底消灭了这个问题,z.object里定义了哪些字段、什么类型、是否必填,推导出的类型一定跟这些约束完全一致。
实际开发中我会把Zod的校验器定义为“可复用的Schema”,放在一个独立的文件里,比如src/server/api/schemas/post.schema.ts,然后在Router里引用。这样前端如果需要在表单里做“同规则校验”(比如注册页面的密码强度),可以直接把Schema引入前端使用,真正做到一套规则多处生效。
3.4 前端数据请求:React Query驱动的数据流设计
t3code前端的核心交互方式,是通过src/trpc/react.tsx导出的api对象调用后端。这里举个具体例子,一个文章列表页:
"use client"; import { api } from "~/trpc/react"; export function PostList() { const { data: posts, isLoading, isError, refetch } = api.post.getAll.useQuery(); if (isLoading) return <div>加载中…</div>; if (isError) return <div>加载失败,请稍后重试</div>; return ( <ul> {posts?.map((post) => ( <li key={post.id}> <h2>{post.title}</h2> <p>作者:{post.author.name}</p> </li> ))} <button onClick={() => refetch()}>刷新</button> </ul> ); }这里React Query的作用是管理服务端状态,它做的事包括:缓存查询结果、依赖变更自动失效、失效后自动重新请求、请求并发去重。t3code脚手架已经把React Query和tRPC的Provider配置好了,你不需要手动new一个QueryClient再包装到全局,直接api对象上的useQuery、useMutation就是React Query的hooks。
很多新手容易犯错的地方,是在组件里手动用useEffect去调api.post.getAll.query()。这种写法有一个巨大的隐患:tRPC的query方法返回的是一个Promise,它不会被React Query追踪,也不会做缓存和并发去重。你在页面A和页面B同时请求同一个接口,就会发出两条重复网络请求,而且页面切换回来还会重新加载。正确的做法永远是使用hooks版本的方法,让React Query帮你处理这些底层逻辑。
再补充一个我自己用得很顺的实践:mutation后的缓存更新。比如创建完一篇文章后,列表页需要同步刷新,这时候不要手动去调refetch(),而是利用React Query的invalidate机制:
const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { // 让post.getAll的缓存失效,组件自动重新请求 void utils.post.getAll.invalidate(); }, });这样写的好处是,只要onSuccess触发,所有依赖post.getAll数据的组件都会自动刷新,不会有哪个角落漏掉同步。
4. 踩坑实录:t3code开发中的典型问题与排查思路
4.1 类型“幽灵错误”:Generics与tRPC边界的经典问题
这是我在项目推进过程中遇到的最频繁的一类报错。现象是:代码逻辑完全正确,浏览器的Network面板里数据也返回了,但TypeScript编译器在调用useQuery的那一行报Type 'undefined' is not assignable to type 'Post[]'。
排查思路要往类型推导方向走。tRPC的useQuery返回的data类型是基于服务端Router的输出类型推导的,但它在没有数据时确实可能是undefined——React Query的设计就是请求还没完成时data是undefined。这意味着你必须在渲染逻辑里处理data可能为undefined的情况,而不是假设它一定有值。
解决办法是加一个可选链或者兜底默认值:
const { data: posts } = api.post.getAll.useQuery(); // 兜底为空数组 const safePosts = posts ?? [];另一个常见类型问题是泛型嵌套太深,IDE报错信息像天书。比如你写了一个泛型函数接收TRPCPath,内部再去取GetInferredReturnType,一嵌套,报错就几十行。这种时候,不要硬抗,建议直接定义一个中间类型:
type PostListData = RouterOutputs["post"]["getAll"];RouterOutputs是tRPC内置的类型工具,从某个Router的某个procedure里提取输出类型,简洁且稳定。项目里所有涉及跨模块传递数据类型的,统一用RouterOutputs和RouterInputs,不要自己写复杂泛型。
4.2 Prisma的N+1查询与性能优化
用t3code写业务,最大的性能里的坑就是Prisma的“关联查询”。默认情况下,findMany只查询当前表的数据,关联模型不会自动带出来。如果你在代码里写了post.author,但include里没加author,Prisma会静默地返回null而不是报错,这就导致前端明明拿到了列表数据,但作者名字全是空的,排查起来很费劲。
更隐蔽的性能问题在循环里查询关联数据。我在代码评审的时候见过这样的写法:
const posts = await ctx.db.post.findMany(); for (const post of posts) { const author = await ctx.db.user.findUnique({ where: { id: post.authorId } }); // 处理业务逻辑 }一个列表10篇文章,就发了10次额外数据库查询,这就是经典的“N+1”问题。正确做法是用include把关联数据一次性查出:
const posts = await ctx.db.post.findMany({ include: { author: true, comments: { include: { author: true } }, }, });这里有一点要提醒:include的深度不是越深越好。如果某个接口返回的对象里嵌套了三层关联数据,但前端实际只用了一层,多余的数据就变成带宽浪费。实践上我习惯先看组件消费了哪些字段,再用Prisma的select精确控制返回的列,只多不少,保持一个平衡。
4.3 部署环节的环境变量、数据库连接与SSL证书问题
项目开发到上线阶段,又有一批新的坑等着你。t3code项目部署用Vercel、Railway或者自己的服务器都行,但无论哪种方式,环境变量都是第一道坎。Next.js在构建时会把NEXT_PUBLIC_前缀的环境变量内联进客户端代码,其他前缀的变量只在服务端可用。tRPC和Prisma的配置变量基本都在服务端使用,比如DATABASE_URL,不能加NEXT_PUBLIC_前缀,否则会直接暴露在前端打包产物里。
第二道坎是数据库SSL证书。云数据库(比如RDS或Supabase)默认要求SSL连接,而Prisma连接串有时需要带上sslmode=require参数。如果你的部署环境网络策略比较严格,还需要额外配置CA证书的路径。我踩过最惨的一次是本地连接数据库一切正常,部署到服务器后一直报P1017: Connection has been closed unexpectedly,排查了半天才发现是数据库白名单没放行服务器的公网IP。
部署层面还有一个容易被忽略的问题:Next.js自带的Server Actions和Middleware,如果它们也在同一项目里跑服务端代码,会共享DATABASE_URL,但它们执行的时机和生命周期跟普通API路由不一样,千万不要在Middleware里直接访问数据库。Prisma Client是长连接池模式,Middleware里调用查询很容易把数据库连接打满,导致正常API请求全部超时。我自己现在的规矩是:Middleware里只做认证、重定向和HTTP头处理,任何涉及数据库的操作一律放到Router里执行。
4.4 版本升级与依赖锁定:为什么锁定版本能救命
tRPC和Next.js的迭代速度相当快,经常会出现破坏性更新。t3code脚手架初始化的时候会把所有依赖的精确版本写入package.json,但问题在于团队后期会手贱跑npm install或者npx t3 init来升级。只要跑一次npm update,Next.js、React Query或者tRPC的版本就大换血了,轻则接口类型报错,重则整个应用运行不起来。
建议从第一天起就开启lockfile并定期审查依赖更新。遇到确实需要升级的场景,不要一次性升一个大版本,要看官方changelog,特别关注breaking changes部分。我用过的最务实的方法是:创建一个小分支,只升级一个核心库,然后跑一遍全量测试和类型检查,通过后再升级下一个。这虽然麻烦,但能避免“升级了Next.js发现tRPC不兼容,然后连带React版本也要升,最后项目启动直接红屏”的连环爆炸。
再分享一个锁版本的小技巧:package.json里不要用^和~符号,直接写成固定版本号,比如"next": "14.2.5",这样别人clone项目跑npm install时拉到的版本跟你完全一致,不会出现“我这边能跑你那边报错”的玄学问题。
5. 进阶玩法与团队协作规范
5.1 给Router加权限:protectedProcedure与角色控制的组合拳
前面提到过publicProcedure和protectedProcedure的区别,这里我想深入讲一下权限控制的进阶玩法。t3code的项目里,权限控制可以做得非常细,因为tRPC支持在Procedure上按需叠加中间件。
举个例子,系统里有普通用户和管理员两种角色。管理员才能删除任意文章,普通用户只能删除自己写的文章。这可以拆成两个中间件来实现:
const requireAdmin = t.middleware(async ({ ctx, next }) => { const user = ctx.session?.user; if (!user || user.role !== "ADMIN") { throw new TRPCError({ code: "FORBIDDEN" }); } return next({ ctx: { ...ctx, user } }); }); const adminProcedure = protectedProcedure.use(requireAdmin);这样定义出来的adminProcedure,既有登录校验又有角色校验,在Router里直接用即可。而且关键的是,这种校验逻辑是声明式的,后来接手代码的人一眼就能看出来哪些接口是公开的、哪些需要登录、哪些需要管理员。
权限控制的经验告诉我,千万不要在前端写了“隐藏按钮”就以为安全了,后端的权限校验必须独立存在。tRPC的Procedure中间件机制使得这个校验成本变得极低,这也算是我入坑t3code后工作效率提升最明显的一块。
5.2 团队协作里的类型契约与代码评审规范
t3code带来的类型安全,如果团队协作方式不跟上,效果会大打折扣。我的实际经验是,代码评审环节要把“类型是否从服务端正确传递到前端”作为一个硬性检查项。具体做法是,在PR描述里要求作者附上一张IDE中调用useQuery后自动补全字段的截图,这就等于把类型安全当作一种可验证的交付物。
另外,团队内部建议统一约定:业务数据请求必须通过tRPC Router,不允许在组件里直接用Prisma Client(服务端Pages Router或Server Actions里除外)。这个约定很基础但很重要,它保证了所有数据访问都经过统一的校验、鉴权、日志链路。
还有一个小规矩:Router的命名建议直接映射业务领域,比如userRouter、postRouter、commentRouter,而不要拆成过于细粒度的createPostRouter、deletePostRouter。因为tRPC的Procedure本身已经区分了query和mutation,过度的Router拆分只会让代码库变得冗余,增加找接口的时间成本。
5.3 往t3code里“加料”:扩展文件上传、定时任务与Webhook
TypeScript全栈项目不会永远是单纯的CRUD,很快会碰到文件上传、定时任务这类需求。t3code这套架构里,前端上传文件可以直接走Next.js的API Route,或者接入对象存储。请注意,文件上传不推荐直接走tRPC,因为tRPC的设计重点在结构化数据,对multipart文件流的处理不是它的强项。
我的做法是,在src/app/api/upload/route.ts里写一个普通的Next.js路由处理文件上传,上传完成后把返回的文件URL作为参数,再通过tRPC的mutation把URL与业务数据关联起来。这样保持了tRPC的类型安全和文件上传的实际性能要求,分工清晰。
定时任务的话,t3code项目里可以用node-cron挂在服务端进程上,也可以接入Vercel的CRON。但要注意,定时任务的代码跟tRPC Router代码不要在同一个函数里混着写,而是抽成一个独立的服务函数,在Router里调用。这样做的好处是可以单独测试任务逻辑,不会因为tRPC的上下文环境导致任务执行异常。
写在最后的个人体会
t3code这套东西,我用了一年多,最大的体会是:它不是在教你怎么写代码,而是在帮你减少“因为类型不一致而产生的心智负担”。以前写前后端分离项目,每次接口联调都像在猜谜语,现在打开项目,所有接口的类型定义就摊在那里,改哪里、怎么改,IDE和编译器都会告诉你。如果你已经受够了接口文档同步不及时、前端类型全靠手写、上线前疯狂联调Debug的日子,真心建议抽一个周末,用create-t3-app搭个项目跑一遍。不用追求一次搞懂所有原理,先把链路跑通,感受一下“类型安全”带来的踏实感,再回过头来打磨细节,你会发现这条路是值得走的。