t3code 这个名字乍看起来像某个第三方库的随手命名,但和我一起维护这个项目的人都知道,它其实是一整套面向现代全栈 Web 应用的代码骨架和工程实践。整个项目围绕 T3 Stack 展开,也就是 TypeScript + Tailwind CSS + tRPC 三个核心技术的组合,目标非常直接:从数据库到 API 再到浏览器端,全链路类型安全,消灭重复代码,减少联调成本。如果你正在搭一个全栈应用,或者被接口文档和前端类型定义同步维护的问题折磨过,t3code 的思路值得你花几分钟看完。
这个项目最早并不是为了“追新”,而是我在接手好几个中大型前端项目之后积累出的一个判断:很多项目最后维护不下去,不是因为业务复杂,而是因为前后端之间的“契约”没人维护。要么后端加了一个字段,前端 TypeScript 类型没更新,要么数据库改了结构,API 层跟着改一圈,前端稍微大意就线上报错。T3 Stack 恰好提供了一条更省力的路径,t3code 就是我把这套路径固化下来的产物。它适合三类人:想快速搭建全栈原型的技术负责人、正在从 JSP / Mock 接口中转移到工程化全栈的开发者,以及希望让团队代码风格和类型边界更统一的前端组长。
1. t3code 设计与选型思路拆解
1.1 T3 Stack 并不神秘,它只是把类型安全坚持到底
T3 Stack 并不是一个框架,也不是某个官方发布的标准包。它是一套被社区反复验证的组合:TypeScript 负责语言层面的类型约束,Tailwind CSS 负责样式原子化和快速布局,tRPC 负责在前后端之间直接复用类型定义和函数调用。t3code 在这三个基础之上,还整合了 Prisma 做数据库映射,使用 Zod 做运行时校验。
我见过很多人第一次接触 tRPC 时不太适应,尤其是有 REST 或 GraphQL 习惯的开发者。REST 粗暴但清晰,GraphQL 灵活但需要写大量的 schema 和 resolver。tRPC 走的是另一条路——它几乎没有和业务无关的协议描述,后端直接导出一组函数,前端像调用本地 async 函数一样调用远程接口。类型从 Prisma 的数据库模型开始,经过 Zod 校验层,最后直接进入 React 组件,中间没有手写 TypeScript interface 或 d.ts 文件,所以“契约漂移”问题从根上被切断。
1.2 为什么用 Tailwind 而不是组件库或 CSS Modules
很多团队问过我:既然用了 T3,为什么样式不直接上现成的组件库?原因在于 t3code 定位是脚手架和代码范式,不是给某个特定业务定制的后台模板。Tailwind 带来的收益不是某一个组件的样式,而是整套设计变量的一致性。颜色、间距、字体大小、阴影全部收敛到 config 里,团队协作时不需要争论“这个 padding 到底写 10 还是 12”,直接使用工具类即可。
另外,Tailwind 和 tRPC 有一个共同特性:减少上下文切换。使用 styled-components 或 CSS Modules 时,一个功能模块至少要维护组件结构、逻辑和样式三个维度。而在 Tailwind 的设计里,下拉菜单、按钮状态和布局结构都直接内联在 HTML 结构上,视觉层级一目了然。虽然文件会稍长一些,但考虑到 t3code 场景下的项目通常快速迭代,少跳转一次文件,心智负担就能降低不少。
1.3 选型的代价与取舍
任何技术组合都有代价。t3code 选择 TypeScript 全栈意味着要求团队对泛型和类型推导有一定掌握。tRPC 的生态相对较年轻,在 t3code 里我做了不少被逼无奈的兼容性处理。而且 tRPC 默认更适合单一服务端,如果未来要拆成多个独立后端,或者需要开放公众 API,REST 和 GraphQL 仍是更成熟的选择。
所以我在设计 t3code 时,把整个技术栈当作一个可以“局部替换”的架构,而不是全部锁死。API 层虽然默认是 tRPC,但它只放在一个独立目录里,未来即便切换到 Hono 或 Fastify 也不会动到前端核心逻辑。项目的边界比技术债更重要,这一点是 t3code 和那些教程型脚手架最大的区别。
2. t3code 的目录结构与核心配置
2.1 目录布局如何降低认知成本
t3code 采用 monorepo 结构,但没有一开始就上 Nx 或 Turborepo 的完整链路,而是用 pnpm workspace 做最基础的模块拆分。这样既能享受独立打包和依赖隔离的好处,又不需要维护复杂的任务编排配置。顶层目录大多是这样的:
t3code/ ├── apps/ │ ├── web/ # Next.js 前端应用 │ └── server/ # tRPC 服务与 Prisma 数据源 ├── packages/ │ ├── config/ # 公共 TSConfig / ESLint 配置 │ ├── db/ # Prisma schema 与数据库连接统一封装 │ └── validators/ # Zod 校验 schema,前端后端共用 ├── .env.example ├── pnpm-workspace.yaml └── turbo.json把 Prisma 的 schema 放在独立包packages/db里,是我比较坚持的一步。原因很简单:数据库模型是整个系统里变更成本最高的部分。如果每次 schema 变更都迫使 web 和 server 同时改依赖,很容易出现版本同步偏差。t3code 让apps/server依赖packages/db,apps/web在用到部分类型时也可以按需引入,配合 TypeScript 项目引用,确保全仓库的编译粒度是可控的。
2.2 环境变量与多环境管理
t3code 环境变量管理延续了 T3 社区的习惯,优先使用t3-env或手写 Zod schema 去校验process.env。这一点很容易被忽略。很多项目上线时才发现 DATABASE_URL 没配置、JWT_SECRET 是空字符串,又或者某个环境变量在不同环境里命名不统一。t3code 在启动阶段就做了一次严格的运行时校验,只要有缺失或类型不对,进程直接退出并打印具体原因。
我的经验是你的环境变量越早“失控”,后期排查故障就越痛苦。尤其是团队里有多个开发环境和预览环境时,靠一份.env.example根本无法保证所有人配置一致。t3code 的做法是提供两份文件:.env.example用于标注变量含义,env-schema.ts用于声明变量类型、默认值和必填要求。这样新增一个环境变量时,所有使用者都能在 IDE 里看到类型提示。
2.3 TypeScript 与 ESLint 的收敛
t3code 的packages/config里封装了一份相对严格的 TypeScript 配置,核心开启strict: true和noUncheckedIndexedAccess: true。很多初学者觉得这个选项很烦,因为访问数组或对象时总要多写一层判断。但实际运行下来,这种“麻烦”恰恰能提前拦住一大批数据访问错误。比如从数组里取第一个元素时,如果不检查 undefined,渲染层很容易直接抛错,而开启noUncheckedIndexedAccess之后,整个团队会被逼着处理可能的空值,长期看是值得的。
ESLint 这部分我也没有使用默认的 Next.js 规则就收手,而是补上了typescript-eslint的推荐规则和eslint-plugin-boundaries,用来限制目录之间的依赖方向。比如apps/web不能直接 importapps/server内部的代码,大家只能通过 tRPC router 进行调用。这是 t3code 能在团队协作里保持边界清晰的最后一道屏障。
3. t3code 的核心链路:Prisma + tRPC + Zod 实操
3.1 从数据库 Schema 到 API 层的自动化
在 t3code 里,数据库模型的变更流程是这样的:先改packages/db/prisma/schema.prisma,然后执行pnpm db:generate,Prisma Client 的类型定义会立即更新。接着如果接口入参有变化,就在packages/validators里修改对应 Zod schema。最后在apps/server的 tRPC router 中使用这个校验器。前端不需要关心数据库字段名,它只知道公共的packages/validators里的类型。整个过程依靠 TypeScript 推导,不需要任何 codegen 步骤。
举个例子,假设我们要给博客系统加一个“编辑文章”功能。比以前最常见的方式,就是打开 Prisma schema,找到 Post 模型,确保它有一个 title 和 content 字段。然后我们写一个 commit。我会展开细节:Prisma schema 其实长了这样:
model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }运行prisma db push之后,数据库表就存在了,同时 Prisma Client 会暴露post.update等类型安全的方法。此时你不需要手写PostResponse接口,只需要进入 tRPC router 里写逻辑。t3code 使用的 tRPC 版本比较规范,router 长成下面这样:
import { z } from 'zod'; import { createTRPCRouter, protectedProcedure } from '../trpc'; import { updatePostSchema } from '@t3code/validators'; export const postRouter = createTRPCRouter({ update: protectedProcedure .input(updatePostSchema) .mutation(async ({ ctx, input }) => { return ctx.prisma.post.update({ where: { id: input.id }, data: { title: input.title, content: input.content, }, }); }), });这里最容易被忽略的两个细节:一是protectedProcedure确保了未登录用户没机会执行这个方法,二是input在传入数据库之前已经经过 Zod 校验。很多项目会在 controller 层手动判断参数,但 t3code 把校验收敛到 tRPC 中间件中,每个 router 的代码反而变得很简洁。
3.2 前端调用如何做到“像调用本地函数”
前端部分我以一个 Next.js 页面为例。在使用 tRPC 的客户端时,你可以直接用 hooks 调用远程过程,而且完全不需要关心 HTTP URL、请求头格式或 mock 数据。下面这段代码就是编辑文章按钮的核心逻辑:
const utils = trpc.useContext(); const updatePost = trpc.post.update.useMutation({ onSuccess: () => { utils.post.list.invalidate(); }, }); async function handleSave(id: string, title: string, content: string) { await updatePost.mutateAsync({ id, title, content }); }注意这里的id、title、content全部有类型提示。如果你在updatePostSchema里没有声明content字段,前端代码会直接报类型错误。这就是 t3code 最核心的价值——它把接口契约从“文档”变成了“编译器”。只要后端类型变了,前端这边会在你保存代码的瞬间发现,不需要去读接口文档,也不需要打个包跑个联调环境才能确认。
实际部署时,tRPC 也支持在服务端组件中调用,比如 Next.js App Router 的 server component 里可以通过trpcServer直接查询数据。但如果你刚上手,我建议先使用 client component 加 mutation 的模式,这样比较容易理解数据流。
3.3 为什么 zod schema 放在独立 package
把packages/validators独立出来,是有意为之的“过度设计”。初期可能你会觉得一个简单的 input 校验直接写在 router 里也很舒服,但当项目变大后,前端表单校验和后端接口校验很可能出现两套逻辑。比如前端要求标题 3 到 20 个字符,后端也要求 3 到 20 个字符,但两边的写法略有偏差,就会导致前端校验通过了、后端却返回错误。
t3code 的做法是把这类 schema 全部提取到公共包,然后前端通过zodResolver介入 React Hook Form,后端通过 tRPC 中间件调用同一个 schema。唯一的一份验证规则,同时驱动 UI 提示和 API 拦截。如果你后续需要做文档生成或自动化测试,也可以基于这套 schema 直接导出类型和模拟数据。
4. 5 分钟搭建一套 t3code 开发环境
4.1 前置条件与项目初始化
在空白电脑上从零搭建 t3code 环境,核心工具只需要 Node.js 20 以上、pnpm 9 以上,以及本地的 Docker 或者一个免费数据库连接串。考虑到隔离性,我更推荐本地使用 Docker Compose 启动 PostgreSQL。t3code 根目录中有一个简易的docker-compose.yml,配置如下:
services: db: image: postgres:16-alpine environment: POSTGRES_USER: t3code POSTGRES_PASSWORD: t3code POSTGRES_DB: t3code ports: - "5432:5432" volumes: - pg_data:/var/lib/postgresql/data volumes: pg_data:启动数据库之后,执行pnpm install安装依赖。注意 t3code 使用 pnpm workspace,如果你习惯 npm,安装时长会明显增加,有可能触发依赖提升的各种坑。接下来复制.env.example为.env.local,并修改DATABASE_URL为你的真实连接串。
4.2 初始化数据库与配置文件
确认.env.local无误后,执行:
pnpm db:push这个命令会读取packages/db/prisma/schema.prisma,并把所有模型同步到数据库。它不会生成 migration 文件,只适合早期开发阶段。如果你已经进入正式开发周期,建议切换到pnpm db:migrate来生成可追踪的 SQL migration 文件。我踩过的坑是:刚起步时图省事一直使用db push,结果 schema 变动过于随意,后续想清理历史表结构就非常被动。所以从项目第一天就养成“每一个 schema 变更都对应一次 migration”的习惯,长期看省心得多。
数据库就绪后,启动开发服务:
pnpm devTurborepo 会同时起web和server,你在终端会看到两个端口,一个给 Next.js 前端,一个给 tRPC 服务。首次启动时如果server报错,多半是环境变量名称对不上,或者 PostgreSQL 还没完全就绪。稍等几秒再重启即可。
4.3 默认页面的安全检查
启动完成后,先不要急着写业务,而是建议打开几个关键地址确认链路完整:根路径/可以正常渲染首页;/api/trpc/health或者对应的 health endpoint 返回一个 JSON 对象;尝试未鉴权访问某个需要登录的 tRPC 方法后,会得到 401 错误。这三步通过,说明数据库、服务端、前端三个环节已经全部串通。
如果健康检查不通过,最常见的排查路径也很有规律。先看server进程日志里有没有 Prisma client 的版本号,再看数据库连接串里的主机名是否可以被当前容器解析。很多人容易在DATABASE_URL里使用localhost,但在 Docker 容器内localhost指向容器自身,需要改成host.docker.internal或服务名称。我建议规则很简单:前端跑在宿主机,数据库跑在 Docker,URI 写localhost:5432;如果 t3code 的 server 也跑在 Docker,那么 URI 里应该写 compose 服务的名称。
5. t3code 典型业务场景:做一个带登录的前端表单
5.1 鉴权机制的选择与落地
登录鉴权是几乎所有 Web 项目都无法绕开的部分。t3code 并没有强行绑定某一家鉴权服务,而是把 NextAuth.js 作为默认方案,同时兼容 JWT 和 session 两种策略。我的选择逻辑是:NextAuth 在 Next.js 生态里集成最平滑,而通过 tRPC middleware 解码 session 也属于常规操作。
实际落地时,你在server里只需要新增一个getSession的上下文方法,然后把 tRPC router 中需要保护的方法都改用protectedProcedure。这段逻辑大概几十行,重点是不要在每个 router 里重复复制 session 判断代码。t3code 把鉴权中间件提升到上下文层,任何受保护的方法都能从ctx.session.user里拿到当前用户信息。
5.2 完整的前后端表单校验流程
假设我们要做“发表评论”功能。第一步,在packages/validators中定义一个 schema:
import { z } from 'zod'; export const createCommentSchema = z.object({ postId: z.string().cuid(), content: z.string().min(1).max(500), });第二步,在postRouter中新增一个 mutation:
createComment: protectedProcedure .input(createCommentSchema) .mutation(async ({ ctx, input }) => { return ctx.prisma.comment.create({ data: { postId: input.postId, content: input.content, authorId: ctx.session.user.id, }, }); }),第三步,前端组件里使用同一个 schema 接入 react-hook-form:
const form = useForm({ resolver: zodResolver(createCommentSchema), }); const createComment = trpc.post.createComment.useMutation({ onSuccess: () => utils.comment.list.invalidate(), });这套流程之所以顺畅,核心在于前端的input和后端的入参都受同一个 schema 约束,不会出现“前端说必填、后端说可选”的尴尬。尤其当你的表单有十几个字段,还包含嵌套对象时,靠手写类型和接口文档根本无法避免细微偏差。
5.3 缓存失效设计
tRPC 自带invalidate机制,但很多初用者并不清楚它到底刷新了哪些数据。utils.post.list.invalidate()的作用范围是通知所有使用post.list这个 query 的组件重新拉取数据。在 t3code 中,我建议按照业务资源来组织 query 的关键字,避免一个页面里同时存在list、listByUser、listFeed三套语义相近但没有关联的 key。
如果列表缓存更新不及时,最常见的做法不是去手动清空整个缓存,而是使用setData方法做乐观更新。比如在提交评论后,先把评论插入到本地缓存里,再调用invalidate拉取最终数据。这样避免页面闪烁,同时保证数据最终一致。这个技巧在做实时性要求比较高的评论、点赞、消息列表时非常好用。
6. t3code 实战中的 6 个高频问题和排查清单
6.1 类型报错:不能将 string | undefined 赋给 string
这是 t3code 里最常见的新手报错,原因大多是启用了noUncheckedIndexedAccess或严格模式后,ctx.session.user.id可能为 undefined。解决方案不是用!强制断言,而是先判断 user 是否存在,或者让业务逻辑接受“当前登录用户可能为空”的边界。从我在项目中看到的情况,凡是早期用一堆非空断言解决掉的报错,后期都会在某次重构中突然爆发。
6.2 数据库连接池耗尽
t3code 默认使用 Prisma 连接池,但如果你在 serverless 平台上运行,需要格外注意连接池大小配置。很多项目把DATABASE_URL直接指向生产库,同时又没设置connection_limit,在流量突增时数据库服务器会拒绝新连接。我的建议是在本地开发时保持 5 到 10 个连接足够,在服务端部署时按实例规格调整。同时要养成随手检查慢查询的习惯,尤其注意 tRPC 查询里有没有 N+1 问题。
6.3 Tailwind 类名覆盖不生效
使用 Tailwind 时,经常出现写了新类名但样式没有变化的情况。我遇到多半是因为 Tailwind 配置里的content字段没有覆盖新页面路径。t3code 的 tailwind.config.ts 会把./apps/**/*.{ts,tsx}都纳入扫描范围,如果你新增了一个packages/ui,必须把它的路径也加进去,否则类名会被 Tree Shaking 优化掉,实际编译出的 CSS 里根本没有对应样式。
6.4 tRPC mutation 后页面不刷新
先检查 mutation 是否真正执行成功,再看invalidate的 query key 是否和当前页面使用的 query key 完全一致。t3code 推荐在 router 层面命名都遵循“资源名.操作名”的格式,比如post.list、post.detail,不要随意取成getPostsXxx。否则前端需要维护一套无形的对应关系,时间久了就会漏掉一两个 key,产生“数据已经提交成功,但列表迟迟不刷新”的诡异问题。
6.5 Zod 在浏览器端和 Node 端版本不一致
Zod 4 发布后,很多项目出现版本撕裂,特别是服务器和前端各自安装了不同版本的 zod,导致同一个 schema 的序列化结果有差异。t3code 在根目录package.json中使用resolutions字段强制统一 zod 版本。建议不要轻易依赖某个间接依赖中传递的 zod,因为一旦几个包依赖不同的 major 版本,运行时校验很容易出现“instance of Z zod object”错误。
6.6 部署到 Vercel 但 Prisma 连接失败
这个问题多半和 Vercel 的 serverless 环境有关。Prisma 客户端在冷启动时需要下载连接引擎,如果没有正确配置二进制目标或使用 JavaScript 引擎,就会出现 “Query engine library” 的相关错误。t3code 的部署方案里专门提供了binaryTargets的示例,包含native和linux-musl-openssl-3.0.x两项目标。如果你使用 Docker 部署,也要检查基础镜像和 openssl 版本。
7. 我对 t3code 后续的一些实际建议
初期搭建时,我一度把 t3code 当作一个“装满各种库的工具箱”,什么好用就加什么。但真正维护之后才发现,一个代码库最重要的不是依赖数量,而是变更的确定性。现在 t3code 的依赖列表比早期精简了很多,很多功能都可以用 TypeScript 自带能力完成。比如事件发布订阅不再引入 event-emitter,用 Node 原生EventTarget就行;配置校验也不再依赖复杂的配置库,一个 z.object 加 parse 足够了。
如果你准备把它用到生产环境,我建议先不要照搬我已经写好的业务模块,而是保留基础目录和校验链路,把你自己的表结构替换进去。这样三个月后你回顾项目时,会发现改动最少的反而是那些“约定”本身:目录边界、schema 统一、query key 规范。
后续我还要考虑两个扩展方向:一是增加开发者工具端的代码片段,让 VSCode 用户能直接输入tcode-router快速生成一套 tRPC router;二是把 t3code 里的数据库模型生成器独立成一个命令行工具,比如读取 JSON Schema 就能生成 Prisma 模型和对应 Zod schema。不过这两件事都不急,先把基础代码块打磨稳定,比堆更多新功能更重要。
如果你也在维护类似的脚手架项目,或者刚把 tRPC 引入团队,我建议你从最小的功能模块开始,不要一上来就追求完美的“全家桶”。项目里最值得保留的,其实是那套不依赖具体业务的类型推导链路——它才是 t3code 能被长期用下去的核心。