用 TypeScript 把全栈项目焊成一个整体,这件事我干了快半年。你要是只看“t3code”这个名字,可能第一反应是某个开源库或者命令行工具,说白了它就是一套围绕 T3 技术栈构建的实战型全栈项目代号。所谓 T3,指的是 TypeScript、Tailwind CSS 和 tRPC 这三样东西的合体,再把 Next.js 作为应用骨架,最终实现从前端组件到后端数据库的完整类型安全。今天这篇不打算做概念复述,直接把我在 t3code 项目里的架构取舍、落地步骤、踩坑记录一次讲清,给你一份可以直接抄作业的全栈 TypeScript 实现方案。
1. 为什么我会把 t3code 当作全栈主力方案
1.1 一个代号背后的技术倾向
t3code 这个项目当初立项时,就是想把“类型安全”贯彻到一条链路的每一环。常规开发流程里,后端接口返回的数据长什么样、前端组件里拿到手的对象有没有多字段、数据库表结构改了会不会影响线上页面,这些全靠约定和自觉。一旦一个字段命名改了,你往往要全局搜索、肉眼盯人、拿着接口文档来回比对,非常消耗精力。
而在 t3code 这套结构里,数据库表结构、API 调用边界、组件 props 三层之间的类型是互通的。你在数据库模型里把字段改了,前端调用处会立刻标红,甚至不需要运行测试就能提前发现断裂点。这个体验用一句话总结就是:类型系统把“人与人之间的沟通成本”转成了“机器和机器之间的约束检查”。
这套方案的核心依赖就四样:Next.js 负责页面渲染和路由,tRPC 负责后端逻辑调用,Prisma 负责数据库映射,Tailwind 负责样式。很多人会问,为什么不多加个 Redux 或者 React Query?原因很简单,t3code 追求的是链路直接、依赖少、心智负担低。
1.2 t3code 最想解决的三个痛点
我最初搭这套项目,目标不是炫技,而是被三个非常现实的痛点逼的。
第一个痛点是接口联调时间太长。以前做前后端分离,后端把 Swagger 文档一贴,前端就得对着文档一个个写请求方法,等后端部署完还要用 mock 数据先跑通页面。整个过程充满了“字段名差一个字母”和“类型对不上”的低级错误。t3code 的 tRPC 是不需要生成 REST API 文档的,前端直接以函数调用的方式触发后端逻辑,参数和返回值的类型跟着代码走,不存在文档过度包装的问题。
第二个痛点是状态管理混乱。很多项目里,服务器数据、UI 状态、缓存数据混在一起,Redux 的 action、reducer、saga 一套下来直接让代码量翻倍。在 t3code 里,tRPC 的 query 自带缓存和失效机制,相当于把数据请求层的问题收敛到了一个库内部。
第三个痛点是全栈技能链割裂。你写前端时关心 UI 和交互,写后端时关心数据库和鉴权,中间好像隔着一条鸿沟。但 t3code 把前后端放在同一个代码库、同一个类型体系里,一个开发者用一套语言、一套模型就能把功能从按钮点到底层存储完整走通。
1.3 这套方案的适用边界
说实话,t3code 不是万能药,它适合的是中小型全栈应用、内部工具、创业项目快速验证这类场景。如果你的业务特别复杂,需要对接大量第三方服务,或者有独立的移动端客户端,那么 REST 或 GraphQL 作为一种公开 API 网关仍然是必要的。t3code 的定位是“应用内部的前后端调用边界”,不是“面向任意客户端的通用 API 平台”。
我做 t3code 的经验是:项目里可以同时存在 tRPC 和外部 REST API 调用。tRPC 管内部业务逻辑,REST 管外部系统集成,两者不冲突。别一听 tRPC 就想着把所有接口全换掉,那是给自己找麻烦。
2. t3code 的四个核心支柱拆解
2.1 TypeScript:全链路的契约层
在 t3code 里,TypeScript 不仅仅是给变量加个类型注解,它更像是一条数据契约。从 Prisma Schema 里定义的数据库字段,到 tRPC router 的输入输出,再到 React 组件的 props,所有信息都在代码层面显式可见。
有个很典型的体验,我用 Prisma 定义了一张订单表,其中有个字段叫status,一开始是字符串,后来业务上想改成枚举类型。在传统后端项目里,这件事要动数据库迁移、改后端逻辑、同步前端类型定义,还容易漏掉某个调用方。但是 Prisma 改完模型后,运行prisma generate,tRPC 路由的返回值类型自动更新,前端页面上的order.status立刻变成枚举类型检查,连测试用例都不用急着改,编译器先帮你兜住了一轮。
很多人觉得 TypeScript 学习成本高,在 t3code 里其实不需要写出特别复杂的泛型。你需要掌握的是基础的类型标注、interface、联合类型、泛型约束。系统会自动推断出绝大部分类型,你的主要工作反而是写清楚边界处的类型,然后让类型自己接管剩下的事情。
2.2 Next.js:服务端渲染与应用骨架
t3code 选择 Next.js 作为框架,有个核心理由:它既能服务端渲染,又能跑客户端逻辑,还能直接承载 API 路由,天然适配全栈开发。Next.js 的 App Router 模式下,每个页面目录都可以配置布局、加载态、错误处理,这会让项目结构非常清晰。
在 t3code 实践中,我的页面组件大部分是异步服务端组件。比如首页需要拉取用户数据,那么直接在服务端组件里调用 tRPC 的 query 方法来获取数据,再把数据传给客户端组件。这样做有几个好处:首次加载不依赖客户端 JavaScript 执行、服务端数据获取天然避免接口暴露、页面能更快地输出成 HTML。
当然,服务端组件也不是万能的,t3code 里交互频繁的地方仍然会用客户端组件。我习惯的分类方式是:静态展示用服务端组件,交互表单和实时更新用客户端组件,然后在客户端组件里通过 tRPC 的 mutation 和 query 来同步服务器状态。
2.3 Tailwind CSS:样式管理的降噪方案
Tailwind 在这套组合里看起来最不起眼,但实际体验后你会觉得它是减少琐碎代码的大杀器。t3code 里我再也不用想破脑袋给一个标签起 CSS 类名,也不用在多个样式文件之间来回跳转。直接在 JSX 上写flex items-center justify-between,样式和结构放在一起,改起来反而更直接。
有人担心 Tailwind 的类名会污染 HTML,其实在组件化开发下完全不存在这个问题。一个组件的模板、逻辑、样式放在同一个文件里,样式只服务于当前这一块功能,内聚性反而更高。如果再结合clsx或tailwind-merge这类工具处理条件类名,体验会流畅到飞起。
2.4 tRPC:前端直调后端函数的桥梁
tRPC 是 t3code 里最特别的一个环节。它不依赖 URL 路由表,而是让你在前端代码里直接 import 一个定义好的 procedure 并调用它。所有入参和出参类型都被 TypeScript 自动推断和校验,不需要手写接口请求映射。
我做了一个简单的用户列表路由,大致长这样:
// server/api/routers/user.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const userRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ page: z.number().min(1).default(1) })) .query(async ({ ctx, input }) => { const users = await ctx.db.user.findMany({ take: 20, skip: (input.page - 1) * 20, orderBy: { createdAt: "desc" }, }); return { users, page: input.page }; }), create: protectedProcedure .input(z.object({ name: z.string().min(2), email: z.string().email() })) .mutation(async ({ ctx, input }) => { return ctx.db.user.create({ data: input }); }), });前端调用看起来是这样的:
const { data, refetch } = api.user.list.useQuery({ page: 1 }); const createUser = api.user.create.useMutation({ onSuccess: () => refetch(), });核心魅力在于:如果我后端把list路由的返回结构改了,比如把{ users, page }改成{ users, total },前端data.users会立即出现类型错误,你根本不用去测试环境里猜返回值长什么样。这就是 t3code 整条链路里最爽的部分。
3. 从零搭建一个 t3code 同款项目
3.1 项目初始化与环境准备
初始化命令很简单,如果你用的是 create-t3-app,一条命令就能把整个骨架拉起来:
pnpm create t3-app@latest t3code交互式命令会让你选择需要的模块,我建议按需选配。我的项目选择了 Next.js、tRPC、Prisma、Tailwind,额外加了 NextAuth.js 做登录鉴权。数据库我用的是 PostgreSQL,连接字符串放在.env里,Prisma 会自动读取。
项目结构大致是:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── (auth)/login/page.tsx │ │ ├── dashboard/page.tsx │ │ └── layout.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ ├── routers/ │ │ │ └── trpc.ts │ │ └── auth.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── react.tsx │ └── styles/ │ └── globals.css └── package.json初始化完成后第一件事,是把数据库连接和认证密钥配置正确,再跑一次pnpm prisma migrate dev建表。不夸张地说,五分钟后你就能拥有一个带鉴权、带数据库、带类型安全 API 调用的应用骨架。
3.2 数据模型与权限边界设计
t3code 里数据模型是整个类型系统的源头,所以 table 设计要格外认真。我有几个实践心得:
字段尽量用语义化命名,别用 a、b、c 这种简写。因为字段名会被带到前端类型里,一个语义清晰的字段名能让前端代码更自解释。
枚举类型优先用 Prisma 的 enum,不要用字符串。字符串在业务演进中很容易出现拼写不一致。比如status直接定义成enum Status { ACTIVE INACTIVE BANNED },比写"active" | "inactive"更严谨。
关系字段的级联操作要在 schema 里显式声明。比如删除用户时是否同步删除其帖子,用onDelete: Cascade明确写出来,避免数据库残留脏数据。
权限控制方面,我推荐在 tRPC procedure 层面做区分,比如publicProcedure、protectedProcedure、adminProcedure。这三个层级分别对应匿名访问、登录访问、管理员访问。只要在创建 router 时选对了 procedure 类型,权限就已经被约束住了,不用在业务代码里反复写 if 判断。
3.3 路由组织与前端数据流设计
t3code 的 tRPC 路由组织可以按业务模块拆分。我的习惯是在routers文件夹里一个模块对应一个文件,比如user.ts、post.ts、comment.ts、analytics.ts,然后在root.ts里合并:
// server/api/root.ts import { createTRPCRouter } from "~/server/api/trpc"; import { userRouter } from "./routers/user"; import { postRouter } from "./routers/post"; export const appRouter = createTRPCRouter({ user: userRouter, post: postRouter, });前端调用时路径就是api.user.list、api.post.create这样,结构跟文件路径保持一一对应,找代码时不会迷路。
页面级别的数据流,我通常是这样一个循环:
- 页面组件读取 URL 参数,把它转成 tRPC query 的参数;
- tRPC query 自动带缓存,同一数据在多个组件里共享不会重复请求;
- 用户交互触发 mutation,完成后通过
onSuccess手动invalidate(失效)相关 query; - 相关页面组件自动重新请求数据,完成 UI 更新。
这个模式下,你几乎不需要手动管理全局状态。服务器状态这个概念被 tRPC 接管,本地 UI 状态用 React 自带的useState就够,除非有特别复杂的跨组件状态,才需要引入useReducer。
3.4 一个完整功能的诞生过程
我在 t3code 里做过一个非常典型的功能:用户的文章列表 + 创建文章。
数据库不断增加,需要定义Post模型,包括标题、内容、作者关系、发布时间字段,然后跑迁移。接着写 tRPC router,提供listByUser和create两个 procedure。前端写一个PostList组件,里面用api.post.listByUser.useQuery()拉取数据,再写一个PostCreateForm组件,用api.post.create.useMutation()提交新文章。提交成功后,onSuccess里让listByUser缓存失效,列表自动刷新。
这个流程从模型设计到页面可用,数据链路里的类型全程一致。字段名写错、类型传错,会在编译器阶段就爆出来,而不是等到点击按钮时才在控制台报错。
4. 常见问题与排查技巧实录
4.1 tRPC 查询结果不更新
这是我被问最多的问题,也是最容易踩的坑。tRPC 查询默认带缓存,每次进入页面并不会强制重新请求。如果你在某个 mutation 完成后没有invalidate相关 query,页面数据就是旧的。
解决办法:在 mutation 的onSuccess回调里调用api.useUtils().post.list.invalidate(),或者直接用refetch()。另外要看清作用域,跨组件的 query 失效,最好在全局的utils里操作,别在组件内部把 query key 拼错。
我排查这个问题的顺序是:先确认 mutation 是否真的执行成功,再看onSuccess是否触发,然后检查 invalidate 的路径跟 query 的路径是否完全一致。通常 90% 的问题出在最后一步路径写错。
4.2 服务端组件里调用 tRPC 报错
Next.js App Router 的服务端组件和客户端 tRPC Provider 是两个运行时环境。服务端组件里直接调用 tRPC 需要走server/api/trpc里的 helper 函数,而不能直接使用客户端那个api对象。
简单做法是:服务端组件里用 tRPC 的createCaller方式来直接调用 router。底层逻辑是同一个模块,但入口不同。你可以在trpc/server.ts里导出一个包装函数,专门给服务端组件用,避免每次写一堆初始化和 header 透传代码。
4.3 Zod 校验报错信息不够友好
tRPC 的输入校验默认用 Zod,配合zod的errorMap可以自定义错误提示。如果不做处理,用户输错邮箱时看到的是类似Invalid input: expected string, received number这种开发者向的信息。
我的做法是给每个输入对象写一个zodSchema,然后用superRefine做复杂逻辑校验,再结合errorMap输出中文提示。注意别为了校验一时爽把 schema 写得过分复杂,否则前端随着业务调整会很难维护。有几个特殊场景:分页参数要用z.coerce.number()做转化,因为 URL query 天然是字符串;可选字段用z.string().optional(),不要用z.string().nullable(),二者在类型推断上市不同的。
4.4 开发环境下热更新变慢
t3code 项目模块比较多的时候,dev 模式下热更新会明显变慢。常见原因是 Prisma Client 每次编译都重新生成,或者 tRPC 路由文件改动导致整条依赖链重编译。
我的处理经验:把@prisma/client单独做一层封装,避免业务代码直接 import Prisma Client;同时确认.env里的数据库连接不会在每次热更新时被重新读取,连接池参数也配置好。开发时如果感觉卡顿,优先怀疑是不是路由文件被拆得过多过散,可以适当合并一些低频改动模块,不用追求每个 procedure 单独一个文件。
4.5 服务端获取的 session 在客户端对不上
NextAuth 随便配一下就能用,但遇到 session 对不上,十有八九是session回调里存的数据不完整。t3code 里我推荐在auth.ts的session回调中把这个影子用户加载完整,而不是只存一个 user id。因为客户端拿到的 session 如果字段太少,前端组件里就不好判断权限级别。
还有一个容易踩的坑:secret没配,导致 sign 和 verify 的 key 每次重启都变,session 在服务重启后立刻失效。生产环境一定要把NEXTAUTH_SECRET写到.env文件里。
4.6 常见问题速查表
| 现象 | 可能原因 | 快速解决 |
|---|---|---|
| 查询结果不更新 | mutation 后未失效 query | onSuccess 里调用 invalidate |
| 客户端报 401 | 请求未携带 session | 检查 procedure 类型和 provider 配置 |
| 服务端组件调用报错 | 用了客户端 api 对象 | 改为 createCaller 方式 |
| Zod 报错不清晰 | 缺少 errorMap 自定义 | 配置 z 的 errorMap 或 superRefine |
| 热更新很慢 | Prisma Client 被反复编译 | 拆分 client 封装,整合路由文件 |
| 部署后样式不对 | Tailwind 内容没有包含相关文件 | content 配置指向懒加载组件 |
5. 更进一步:把 t3code 推到生产环境
5.1 部署架构和数据库迁移流程
t3code 的部署其实很简单,因为本质上是一个 Next.js 应用。主流选择是 Vercel 或独立的 Node 服务器+Docker。数据库一般用托管 PostgreSQL,连接字符串写在环境变量里。
需要注意:生产模式下 Prisma 的 migration 要单独跑,别在应用启动时自动执行。维护一个部署流水线:
- 代码合并到 main 分支;
- 流水线里先跑
prisma migrate deploy; - 再构建 Next.js 应用;
- 最后发布新版本。
这样数据库结构变更和应用发布是同步的,不会出现旧代码连着新数据库的兼容性问题。
5.2 性能优化要点
t3code 项目性能瓶颈通常出在数据库查询和页面渲染上。我最常用的优化手段:
- 对 tRPC 的 query 设置合理的 staleTime,减少重复请求;
- 对列表类的 procedure 设置分页,避免一次性把海量数据全拖回前端;
- 在 Prisma 查询里选择性筛选字段,不要一股脑返回全部列;
- 用 Next.js 的
loading.tsx和Suspense做局部加载态,不要全页面等数据返回再渲染; - 静态页面尽量用服务端组件预渲染,减少客户端 JS 体积。
5.3 测试策略与代码质量
纯类型安全并不意味着不用测试。我现在的做法是:业务逻辑的复杂函数写单元测试,涉及数据库交互的 procedure 写集成测试,页面组件只做关键路径的冒烟测试。tRPC 的 router 是纯函数集合,测试起来非常方便,直接把createCaller注册好,再注入一个测试用的 ctx 就可以跑逻辑。
这里需要留意的是测试数据库要隔离。比如用pg-mem或者单独的测试库,别把开发数据搞乱。
5.4 代码风格与团队协作
t3code 这类项目最大的协作优势是新同学上手非常快。只要会写 React 和 TypeScript,看一眼 tRPC 路由,基本就能理解整个请求链路。团队里建议统一用 ESLint + Prettier,把单引号、分号、尾逗号这些争议直接消灭在格式化阶段。
我在实际协作中还会用组内规范约定:mutation 命名统一用动词 + 名词的形式,比如updateProfile、deleteComment;query 命名统一用list、detail、summary这类简短明确的关键词。这样在合并代码和 code review 时,没人需要费劲猜“这个函数是干嘛的”。
6. 我在 t3code 里踩过的一些特别坑
再补充几个常规文档里很难看到的细节,它们是真的会在特定场景下横跳出来咬你一口。
第一个是 tRPC 的 query key 问题。tRPC 自动生成的 query key 包含 input 和 route path,这本来很省心,但当 input 里塞了一个Date对象或者BigInt,序列化时会出现意外结果。我的经验是:input 尽量只传基础类型,如果有时间范围,传时间戳字符串;如果真的需要复杂对象,建议用 superjson 做序列化配置,create-t3-app 模板默认带了 superjson,很多项目会忽略它的作用。
第二个是 Next.js 的缓存问题。App Router 的 fetch 默认带缓存,如果你在服务端组件里直接 fetch 第三方数据,页面可能出现陈旧内容。t3code 里我一般不用直接在服务端组件里 fetch 外部接口,而是通过 tRPC procedure 包装一次,把数据更新责任挪到 tRPC 缓存策略里。这样页面刷新时,数据的时效性由 tRPC 缓存控制,而不是被 Next.js 的 fetch 缓存悄悄接管。
第三个是环境变量维护。t3code 这类全栈项目一多,.env文件里的 key 就开始膨胀:数据库连接、认证密钥、第三方 API key、存储桶配置。别把它们散落在各处,建议在src/env.mjs里集中声明并校验,让所有环境变量类型安全。create-t3-app 默认就是这个结构,你只需要把自己的变量补进去即可。
第四个坑是关于 Prisma 和 tRPC 的类型循环引用。当你的路由里返回了 Prisma 模型实例,并且这个模型有复杂关系字段,TypeScript 可能会做出非常深层的递归类型,导致编辑器卡顿。解决办法很简单:在返回数据前用 Prisma 的select控制返回字段,避免把整个模型的所有关系都带出来。这既是性能优化,也是一种类型收敛。
7. 结尾的实话和下一步想做的事
如果你正好在纠结要不要把项目迁到 t3code 这种全栈 TypeScript 架构上,我的个人体会是:先别急着全量重构,挑一个内部工具或者一个小型子模块,用这套技术栈跑通一个完整功能。真实感受一下“类型从数据库模型一路流到页面按钮”的体验,再决定要不要扩大范围。
我自己跑了一年多 t3code 之后,明显的感受是代码里的防御性判断少了很多。以前要在后端反复校验入参、在前端反复判空、在联调时反复翻文档,现在这些步骤被类型系统和 tRPC 的运行时校验替代了一大半。不是说道路从此一马平川,但至少踩坑的密度和频率,比我过去用 REST 架构时低得多。
接下来我打算给 t3code 补上更细粒度的权限能力,比如按角色区分字段可见性,同时把测试覆盖率再往上提一档。如果你对这种全栈 TypeScript 的演进路线也有兴趣,或者已经踩到我没提到的坑,出来交流一下经验,我这边都愿意听。