项目名叫 t3code,熟悉 T3 Stack 的朋友第一眼应该就能猜个大概。我是先被这个名字骗进来的,以为是某个编译器或者第三方工具,结果翻了一圈源码才发现,这其实是一套把 TypeScript 全栈实践串起来的项目骨架。前后折腾了三周多,第一版因为选型不清晰被我整个推翻重写,第二版才真正跑顺。这段时间踩了不少坑,也把 tRPC、Prisma、Next.js 这些组件之间的关系理清楚了。
这篇文章不是把官方文档抄一遍,而是以 t3code 这个实战项目为线索,讲清楚这套技术栈为什么这么配、核心链路怎么打通、以及实际开发中最容易翻车的几个地方。适合那些接触过 Next.js 但还没完整跑过全栈类型安全的团队,或者刚入门 TypeScript、想找一个端到端项目练手的人。文章里的代码细节和配置文件我都会给到,你可以直接照着搭。
1. t3code 是什么:从项目名到 T3 Stack 的由来
1.1 T3 Stack 不是某个单一框架
很多刚接触的人都会问,T3 Stack 是不是一个新框架?其实不是。T3 是 TypeScript、Tailwind CSS、tRPC 这几个首字母的缩写,而“3”对应的是一个组合关系:TypeScript 管类型安全,Tailwind 管界面样式,tRPC 管 API 调用。这个组合最早由 Theo 等人在社区里推广,后来被整理成了 create-t3-app 脚手架,一条命令就能拉起一个完整项目。
t3code 这个项目名,本质上是“T3 Stack + code”的简写。我在搭建的时候没有去魔改它,而是尽量遵循官方推荐的目录约定,再在其上补充一些适合团队协作的代码边界。它默认带了 Next.js 作为应用框架,Prisma 负责数据库建模和查询,还有 NextAuth 做登录鉴权,而这些组件之间通过 TypeScript 类型系统串联起来。
1.2 t3code 这个项目到底干了什么
我做 t3code 的初衷是做一个内部用的极简工作台,包含任务记录和随笔管理两个核心模块。听起来功能不复杂,但麻雀虽小五脏俱全:要处理用户登录、数据库建表、CRUD 接口、前端页面渲染,还要保证多人协作时接口参数不写错、数据库字段不裸奔。
之所以选中 T3 Stack,是因为我想要的是一个“单仓库、单语言、类型全链路打通”的体验:前端调用某个接口时,入参和返回值的类型直接从前端代码就能推断出来,不需要像传统 REST 流程那样先翻接口文档,再在前后端各定义一遍类型。这种体验在 t3code 里跑通之后,团队新人上手的速度会明显加快,因为编译器会帮人挡掉大量低级错误。
1.3 适合哪些人参考
如果你是后端出身、平时用 Java 或 Go 写接口,对 TypeScript 还不太熟,t3code 可以帮你看到一个“全栈都站在同一套类型系统上”的具体长什么样。如果你是前端出身、对数据库操作不太熟悉,Prisma 那种声明式的模型定义方式也比较容易接受。总之,只要你想跳过“前后端各写一份类型定义再手动同步”的工序,这篇文章都值得看完。
2. 选型逻辑:为什么这组技术搭在一起特别顺
2.1 为什么是 tRPC 而不是 REST 或 GraphQL
这是我在项目启动前思考最久的问题。先说结论:对于 t3code 这种内部工具型应用,tRPC 的开发和维护成本是最低的。
传统 REST 接口的思路是“资源 + 动词”,比如 GET /tasks、POST /tasks。它很成熟,绝大部分系统都能用。但在全栈 TypeScript 项目里,REST 有一个明显的割裂点:前端不知道后端某个接口到底要什么参数、返回什么字段,需要额外维护 OpenAPI 文档或者在代码里共享类型。只要前后端类型不同步,运行期就很容易出现低级错误,比如把一个数字当字符串传过去,后端没报错但数据处理完才发现不对。
GraphQL 能解决一部分类型问题,但它带来的是另一套复杂度:你要学 schema 定义语言、写 resolver、处理缓存和数据加载器。对一个小型内部工具来说,这些负担远超收益。
tRPC 的思路完全不一样。它不引入新的协议,而是直接把后端函数变成可被前端安全调用的过程。前端调用task.getAll(),后端就是getAll()这个函数,中间的类型推导由 TypeScript 自动完成。如果后端把返回值的字段改了,前端代码的编译会直接报错,把很多运行期问题提前到写代码阶段。
| 对比项 | REST | GraphQL | tRPC |
|---|---|---|---|
| 类型共享 | 需要额外维护 | 通过 schema 相对安全 | 天然由 TypeScript 推导 |
| 学习成本 | 低 | 中高 | 低 |
| 缓存策略 | 简单直接 | 需要专门解决 | 结合 React Query |
| 适合场景 | 开放 API、多端复用 | 数据聚合复杂的大型应用 | 全栈 TypeScript 内部应用 |
2.2 Next.js 和 Prisma 在链路里的定位
Next.js 在 t3code 里承担的是“应用壳层”的角色。它的 App Router 提供了文件路由、服务端渲染和 API 路由,前端页面和后端逻辑可以在同一个仓库里共存。但注意,Next.js 本身并不知道数据库的存在,它只是把页面请求和函数调用串起来。
Prisma 负责的是让 TypeScript 直接“操作”数据库。你在 schema.prisma 里定义模型,比如model Task { id String @id @default(cuid()) },然后 Prisma 会根据这个模型生成客户端类型。这样一来,查询数据库的代码就像写 TypeScript 函数一样,字段名错了或者类型不匹配,编辑器里会直接出现红色波浪线。
这里多说一句,我见过有人抱怨 Prisma 生成的客户端太“重”,启动慢。其实对于 t3code 这种规模的应用,性能差异几乎无感。真正的好处是开发效率提升:改完模型跑一次prisma generate,类型立刻同步,省掉了很多手写 SQL 映射的辛苦。
2.3 Tailwind 和 TypeScript 的配合细节
Tailwind 在 t3code 里主要解决的是样式一致性问题。它不是一个组件库,而是一套原子化 CSS 方案。写页面的时候,直接在 className 里写flex items-center justify-between这种类名,不需要频繁切换 CSS 文件。
有人会问,Tailwind 和 TypeScript 有什么关系?关系在于编译期检查。Tailwind 的类名是字符串,如果拼错了,页面不会报错,只是样式不对。但配合 TypeScript,你可以在关键业务组件中封装一些带类型定义的工具类组件,把类名约束在有限集合内,减少手滑写错的概率。这不是强绑定,但在 t3code 里,我把它当作一种工程规范来用。
3. 从零搭建 t3code:初始化、模型与目录规范
3.1 初始化:create-t3-app 的配置项选择
搭建的第一步是拉取官方脚手架。我用的是 create-t3-app,命令如下:
npm create t3-app@latest t3code过程中会问你选哪些模块。我在 t3code 里选择了 TypeScript、Tailwind、tRPC、Prisma、NextAuth,暂时没有选打包工具配置里的独立模块。创建完成后目录大致长这样:
t3code/ prisma/ schema.prisma src/ app/ layout.tsx page.tsx server/ db.ts api/ routers/ trpc.ts styles/ globals.css这个结构的好处是“接口逻辑和服务端代码集中在 server 目录,页面组件放在 app 目录”。新人一开始可能不习惯,但按这个约定写代码,不会出现功能逻辑散落各处的情况。
3.2 数据模型设计:从一个任务表说起
t3code 的第一个核心模块是任务记录。prisma/schema.prisma里的模型我定义得比较克制:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? tasks Task[] notes Note[] } model Task { id String @id @default(cuid()) title String done Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id]) } model Note { id String @id @default(cuid()) content String createdAt DateTime @default(now()) userId String user User @relation(fields: [userId], references: [id]) }为什么用cuid()而不是自增 id?因为对于内部工具,cuid 不需要等数据库生成主键,插入时就能确定 id,后续做关联和路由跳转更顺手。真实项目如果对主键长度敏感,再换 uuid 也不麻烦。
3.3 目录结构的代码边界约定
光有官方目录还不够,我在 t3code 里加了一个约定:凡是数据库相关代码只能出现在src/server/db.ts和各个 router 文件里,页面组件不能直接访问 PrismaClient。这样做是为了让数据访问路径清晰可查,后续如果要拆分出独立 API 服务,改动范围也很明确。
路由层的命名也有约定,每个 router 对应一个业务域。比如task.ts只处理任务相关的操作,note.ts只处理随笔相关的操作,公共业务放在root.ts里做聚合。这样文件之间不会有隐式依赖,新人看代码时不需要猜。
4. 核心链路打通:从数据库到页面的完整流程
4.1 环境变量和数据库连接
初始化之后,第一件事是配置环境变量。create-t3-app 会生成一个.env.example,我直接复制成.env并填上本地数据库连接:
DATABASE_URL="postgresql://user:password@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="your-secret" NEXTAUTH_URL="http://localhost:3000"连接池和 Prisma 的关系这里提一嘴:Prisma 默认通过连接池管理数据库连接。本地开发时连接数不多,直接使用默认配置就行。如果上线后并发高,可以在连接串里加connection_limit=5之类的参数。但不要为了压测随便调大,数据库服务器撑不住的话,过早开高并发连接反而会把数据库压垮。
写完配置后,依次执行:
npx prisma migrate dev --name init npx prisma generate第一条命令会创建表并生成本地迁移记录,第二条命令会更新 Prisma Client 的类型定义。我在这一步犯过一个低级错误,就是改了 schema 但忘记跑 generate,导致编辑器里一直提示task.userId不存在。这个坑不是框架的问题,而是流程遗漏,后面会专门说。
4.2 编写第一个 tRPC 路由
以任务模块为例,router 的代码大致是:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const taskRouter = createTRPCRouter({ getAll: protectedProcedure.query(async ({ ctx }) => { return await ctx.db.task.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1) })) .mutation(async ({ ctx, input }) => { return await ctx.db.task.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), });这里有两个关键点。一是protectedProcedure,它要求请求必须携带有效登录信息,否则直接拒绝。它其实是中间件包了一层,把 session 里的用户信息挂到了ctx上。二是zod做入参校验,类型安全不只体现在编辑器提示上,运行期校验也没省。
4.3 前端调用与类型推导
页面组件里调用这个接口的写法如下:
import { api } from "~/trpc/react"; export default function TaskList() { const { data: tasks, refetch } = api.task.getAll.useQuery(); const createTask = api.task.create.useMutation({ onSuccess: () => refetch(), }); return ( <ul> {tasks?.map((task) => ( <li key={task.id}>{task.title}</li> ))} </ul> ); }表面上看起来这只是个 Hook,但背后的类型推导链条是:taskRouter的定义被传递到api.task上,前端输入task.时,编辑器会自动列出getAll、create这些可用方法,并且自动推导出入参和返回类型。如果后端的返回类型改变了,前端代码会立刻编译报错。
4.4 完整链路的验证
我第一次跑通这条链路时,最强烈的感受是“没有中间环节可以被遗忘”:不需要写接口 mock、不需要手工维护接口文档、不需要在前后端同步字段名。整个链路从数据库表、后端函数到前端调用,类型是一致的。这也正是 t3code 这类项目的价值所在。
验证方式很简单,起一个本地服务:
npm run dev然后用浏览器登录,在页面上创建一个任务,再去数据库里确认记录存在。如果一切顺利,你会发现整条链路涉及的代码量比传统 REST 方式少了不少。
5. 实际开发中最容易翻车的几个地方
5.1 Date 序列化:瞬时对象和响应类型的断层
这是我在 t3code 里遇到的第一个“隐藏坑”。Prisma 模型里的createdAt是DateTime类型,在 Node.js 里对应的是 JavaScript 的Date对象。但当你把它返回给前端时,经过 JSON 序列化后它变成了字符串。
如果前端代码里直接用了task.createdAt.toLocaleDateString(),开发环境可能没问题,因为 Next.js 服务端渲染时它确实还是 Date 对象。但等到前端做纯客户端渲染、或者数据被缓存重新水合后,createdAt就变成了字符串,一调用.toLocaleDateString()直接报错。
解决思路有两个:一是在前端统一用String()转一下再显示;二是在 tRPC 的返回对象上用 zod 做一次输出解析,把 Date 显式转成 ISO 字符串。我推荐后者,因为更严谨,不会因为调用时机不同出现行为不一致。
5.2 鉴权中间件的注册顺序
t3code 里同时存在公共接口和登录接口。刚开始我把根 router 配置成全部需要鉴权,结果登录页面本身也调用了接口,导致死循环。查了半天发现不是逻辑问题,而是中间件结构问题。
tRPC 的做法是区分publicProcedure和protectedProcedure。我后来重新设计成:登录相关的 router 用公开过程,任务和随笔的 router 用受保护过程。同时在受保护过程中再去校验 session,这样接口之间的边界就清晰了。如果你像我一样前期图省事把整个 root 包了中间件,后面加白名单逻辑反而更绕。
5.3 Prisma migrate 的 dev 和 deploy 差异
本地跑prisma migrate dev很顺手,一执行就会自动创建迁移文件并同步数据库。但上了测试环境或生产环境后,请务必用prisma migrate deploy,它只会应用已经存在的迁移文件,不会自动创建新迁移,也不会因为 schema 和数据库不一致而尝试“修复”。
我在这上面栽过跟头:在本地多个分支间切换时,某个分支的 schema 改了,但迁移文件没建全。跑migrate dev时居然提示我“检测到漂移”,问我要不要重置数据库。如果手一抖点了 yes,本地数据就没了。后来我把所有环境统一成“先 review 迁移文件,再 deploy”,这种情况就再没出现过。
5.4 热更新时的类型残留问题
Next.js 开发模式的热更新很快,但 tRPC 的类型推导偶尔会出现“滞后”。表现为:你改了后端 router 的返回字段,前端编辑器的类型提示还是旧的,甚至报错说某个属性不存在,而重启 dev server 之后又好了。
这不是 bug,而是 TypeScript 语言服务和热更新之间的缓存问题。遇到这种情况,不用急着删 node_modules,先尝试重启 TypeScript 编辑器服务,或者直接重启 dev server。我在团队里给的建议是:改完 router 定义后,先跑一次npm run typecheck,确保类型编译确实通过了,再切回浏览器看效果。
6. 让 t3code 走得更远的工程化思考
6.1 什么时候不适合用 T3 Stack
虽然我个人很喜欢这套技术组合,但它不是银弹。如果你的项目需要开放 API 给第三方使用,或者需要面对多种客户端,tRPC 的设计就没那么合适了,毕竟外部系统不可能为了调用你的函数去引入 tRPC 客户端。同样,如果团队里前端的 TypeScript 基础比较薄弱,推行 t3code 这种模式会遇到不小的阻力,因为编译器提示报错可能比运行时错误还多,初期会感觉“到处都是红线”。
我选择 t3code 的场景是内部工作台,用户量不大,快速迭代优先,类型安全是加分项。如果你的项目是长期对外服务,需要控制 API 协议兼容性,那还是老老实实用 REST 加 OpenAPI 更稳妥。
6.2 依赖更新和小版本踩坑
T3 Stack 涉及的依赖比较多,Next.js、Prisma、NextAuth、Tailwind 每个库都有自己的发布节奏。我的建议是不要所有依赖一把梭升级,而是按模块小步更新,每次升级完都跑一遍核心流程:登录、任务创建、随笔查询。
印象比较深的一次是 Next.js 大版本升级后,App Router 的页面参数类型变了,导致一堆组件类型报错。这种升级成本和收益不成正比,尤其在项目进入维护期后。所以没有明确需求时,锁定依赖版本,按团队节奏统一升级,比追求“永远最新”更省心。
6.3 团队协作的约定和未来扩展
目前 t3code 的代码量不大,但我在代码注释里写下了一条团队约定:任何新增的业务模块都要先写 schema、再写 router、最后写页面,禁止从页面倒推去改数据库结构。这样可以避免出现“页面功能看起来正常,但数据库结构乱成一团”的情况。
如果后续要做 App 端,t3code 的架构也给你留好了退路:tRPC 可以子集化,把部分接口通过官方适配层转成 REST 或 WebSocket;Prisma 的模型定义和数据库结构是稳定的,不会因为 API 层变化而推翻重来。也就是说,t3code 不是一个只能 demo 的玩具项目,它更像一个可以持续演化的工程起点。
说了这么多,还是那句话:技术选型没有绝对的正确答案,关键是团队能在同一套模式里高效协作。t3code 对我来说最大的价值不是“用了什么新技术”,而是让“类型安全”这个原本停留在口头上的概念,真正从数据库一路贯彻到了页面。这套组合我已经用顺手了,后续如果继续扩展,大概率还是会在它的基础上做增量,而不是另起炉灶。