在团队里聊到全栈技术选型时,我经常会提起一个代号“t3code”。这是我的一个真实项目仓库名,也是不少人第一次接触 T3 Stack 时的入门样本。很多人一听“t3”就以为是 Three.js 的 3D 场景,其实完全不是;也有人以为它是某个新框架,上手之后才发现它更像一套“默认偏好集”。这篇文章我会把 t3code 从初始化到部署的完整过程拆开讲:t3code 的命名来源、技术栈组成、脚手架每个选项背后的取舍,以及我在 tRPC 路由、Prisma 迁移和上生产环境时踩过的真实坑。适合正在学习 Next.js 全栈开发、或者想在项目里落地端到端类型安全的开发者参考。
1. t3code 是什么:解开 T3 缩写背后的那层误会
1.1 它不是框架,而是一套“组队式”技术栈约定
先澄清一个最常见的误解:T3 不等于 Three.js,也不是某种新出现的运行时框架。T3 Stack 是由知名开发者 Theo Browne(网名 t3.gg)提出的组合方案,T3 这个名字取自他的网名 Tyler3,而不是三个以 T 开头的技术首字母。不过恰巧这套栈里的核心成员——TypeScript、Tailwind CSS、tRPC——确实都是 T 开头,所以后来很多人也把 T3 解读成“三个 T”,这个解读不算错误,但根源还是指 Theo 本人。
t3code 项目在初始化时完全采用了这套组合方案,它的核心成员包括:
| 技术 | 职责定位 | 在 t3code 里解决什么问题 |
|---|---|---|
| Next.js | React 全栈框架 | 负责页面渲染、路由、API 处理层 |
| TypeScript | 静态类型语言 | 全链路类型安全的语言基础 |
| tRPC | 端到端类型安全 RPC | 替代传统 REST 接口的 API 调用层 |
| Prisma | ORM 与数据库工具 | 定义数据模型、迁移与查询 |
| Tailwind CSS | 原子化样式方案 | 快速构建界面,不维护零散 CSS 文件 |
| NextAuth.js | 身份认证 | 处理登录会话与权限控制 |
这套组合解决的问题非常明确:全栈开发者最头疼的不是写业务代码,而是在前后端之间维护一份永远可能过期的 API 契约。REST 接口需要手写 OpenAPI 文档或者类型声明,GraphQL 需要维护 GraphQL Schema 和前端生成类型,这些工作一旦工程变大,很容易出现“后端字段改了,前端还在用旧字段”的尴尬。T3 Stack 的答案则是用 TypeScript + tRPC,把类型定义从数据库一路推到浏览器组件里,全链路同一个类型源头,改一个字段,编译期就能把所有断点暴露出来。
1.2 为什么这套组合能成为很多团队的全栈默认选项
我说一个比较实在的观察:近几年新开的全栈项目里,T3 Stack 的出现频率高得吓人。原因不是它功能堆得多,恰恰相反,是它的克制。create-t3-app 的设计理念是“保留最少但最顺手的工具”,它不强行塞入状态管理库、不默认接 GraphQL、不强制 CSS 方案,所有选型都以“能让端到端类型安全最大化”为第一优先级。
以 t3code 的实际体验来说,前后端类型共享带来的好处是实打实的。比如在 pages/api 或者 route handler 里写传统接口时,前端要等后端返回后才知道字段叫什么、类型是什么,然后在组件里写一堆类型断言;而在 tRPC 模式里,后端定义好 procedure 的输入输出类型,前端调用时编辑器直接给我补全字段名,写错了当场报红。这种体验用过一次就回不去了。
当然它也不是万能的。t3 这套组合最适合的是中小型全栈项目、内部工具、快速原型和产品落地阶段的应用。如果是一个几十人的大团队,前后端人数对半分,API 需要同时供给第三方客户端,那 REST 或 GraphQL 的开放契约模型仍然更合适。t3code 的定位我心里一直很清楚:它是把全链路做强类型约束的方案,前提是前后端都在同一个 TypeScript 世界里。
提示:如果你所在团队的后端是 Java、Go 或者 Python,tRPC 那一套就直接没法用了。T3 Stack 的适用边界就是“前后端都在 Node/TS 生态内”。
2. 初始化 t3code 的脚手架选择:几乎每个选项都在替你省掉后续返工
2.1 创建命令与交互式选项逐项拆解
创建 t3code 用到的命令很简单,就是 create-t3-app 脚手架:
pnpm create t3-app@latest t3code脚手架会让你选择需要的模块。我第一次跑这个命令时觉得所有选项都直接勾上就行,但后来在多个项目里反复初始化之后才意识到,这里的每一个勾选都会在之后的工程里变成一层隐性的约定。我把选项和适用场景整理成了一张表:
| 选项 | 什么时候勾选 | 什么时候别勾 |
|---|---|---|
| Next.js | 必须勾,这是全栈基础 | 无 |
| Tailwind CSS | 几乎都勾,界面层效率高 | 团队有成熟组件库并且不想引入原子化样式时 |
| Prisma | 需要写数据库业务的都勾 | 纯前端静态项目 |
| tRPC | 前后端同仓库且需要数据请求 | 有独立第三方 API 客户端 |
| NextAuth.js | 需要登录、会话、权限控制 | 自己手写认证逻辑 |
| Drizzle | 团队熟悉 Drizzle 且已有相关代码 | 默认 Prisma 更省心 |
新版 create-t3-app 还有一个值得注意的变化:Drizzle 也进入了选项列表。这说明脚手架团队意识到 ORM 选择不能一家独大,T3 的组合并不是死板不变的。
2.2 我在 t3code 里的选型逻辑:App Router 与 Prisma
t3code 创建时我选择了 App Router、Tailwind、Prisma、tRPC 和 NextAuth.js,没有选 Drizzle。选 App Router 的原因很直接:Next.js 官方已经明确 App Router 是未来方向,虽然 2024 年之前 Pages Router 的生态资料更多,但新项目直接上 App Router 可以减少一次从旧范式迁移的成本。Tailwind 我选了,因为我不想起一个项目就维护一份全局 CSS 文件,Tailwind 的类名约束反而帮我减少了思考“这个 class 叫什么”的时间。
Prisma 和 Drizzle 的选择我斟酌了一阵。Drizzle 的查询写法更接近 SQL,性能也更轻,但它的 migration 流程和类型推导手感比 Prisma 硬一些。t3code 是一个有较复杂数据关联的项目,我需要快速建模和可视化管理数据库,Prisma 的 schema 文件对我来说更直观。选 Prisma 的另一个考量是生态:社区教程、招聘需求、AI 工具的参考素材,Prisma 的积累明显更多。技术选型不能只看技术先进性,还要看团队能搜到多少可参考的实践样本,这是我做决定的真实理由。
2.3 初始化后的目录结构为什么这样组织
create-t3-app 初始化完成后,t3code 的 src 目录是这样组织的:
src/ ├── app/ │ ├── api/ │ │ └── auth/ # NextAuth 专用 API 路由 │ ├── layout.tsx │ └── page.tsx ├── server/ │ ├── api/ │ │ ├── routers/ # 各业务模块的 tRPC 路由 │ │ ├── root.ts # 根 router 汇总 │ │ └── trpc.ts # procedure 类型与中间件 │ ├── auth.ts # NextAuth 配置 │ └── db.ts # Prisma 客户端实例 ├── trpc/ │ ├── server.ts # 服务端 tRPC 入口 │ └── react.tsx # 客户端 hooks 封装 └── lib/ └── utils.ts # 通用工具函数这个目录设计有两个核心意图。第一,把服务端逻辑和页面逻辑分离:server 目录下的代码严格运行在服务端,不会被打进浏览器 bundle,这避免了很多新手把含数据库连接的逻辑混进组件里导致的安全问题。第二,tRPC 的 router 集中管理:所有业务接口都以模块形式放在 routers 目录下,通过 root router 聚合导出,客户端调用时只需要引用一个统一入口。
注意:千万不要为了省事直接把 Prisma client 初始化在 app 目录下的任意文件里。t3code 在重构之前就踩过这个坑,Prisma 实例被页面组件间接导入导致打包体积异常增大。务必按照脚手架生成的 daisy chain(db.ts -> server router -> trpc server)来组织。
3. 第一个 tRPC 路由:为什么类型安全能在全栈链路里“传染”
3.1 从 procedure 说起:一个带鉴权的 hello 接口
tRPC 里最核心的概念叫 procedure,可以把它理解成“一个能让前后端共享类型的函数”。t3code 的根路由创建之后,我写的第一个带鉴权的 procedure 是 hello:
// src/server/api/routers/example.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const exampleRouter = createTRPCRouter({ hello: publicProcedure .input(z.object({ text: z.string() })) .query(({ input }) => { return { greeting: `Hello ${input.text}`, }; }), getSecretMessage: protectedProcedure.query(({ ctx }) => { return { message: "登录之后才能看到的内容", user: ctx.session.user.name, }; }), });这个文件里的 procedure 分成两种:publicProcedure 允许任何人调用,protectedProcedure 会先在中间件层面检查 session,未登录直接抛出 UNAUTHORIZED 错误。这种定义方式的价值在于:权限控制和业务逻辑在同一个地方声明,而不是散落在每个页面里写 if 判断。t3code 里几乎所有接口都默认走 protectedProcedure,只有在确实需要公开数据时才改成 publicProcedure,这种默认安全的习惯帮我们挡掉了不少疏忽。
3.2 Server Client 与输入校验的配合方式
写完了 router 还不够,要让前端调用到它,需要在根组件里挂载 Server Client。t3code 用的写法是:
// src/app/_trpc/server.ts import { createTRPCClient, httpBatchLink } from "@trpc/client"; import { type AppRouter } from "@/server/api/root"; import { headers } from "next/headers"; export const api = createTRPCClient<AppRouter>({ links: [ httpBatchLink({ url: "/api/trpc", headers() { const heads = headers(); return { cookie: heads.get("cookie"), }; }, }), ], });注意这里的两个细节:第一,服务端组件里的 tRPC 客户端使用 httpBatchLink,它会把多个请求合并成一个 HTTP 调用,减少服务端的函数并发;第二,通过 headers() 把 cookie 透传给 tRPC,这样服务端渲染时也能拿到当前登录用户的 session。很多从 REST 转过来的同学会问:请求 URL 在哪里?其实 tRPC 的调用路径不靠 URL 定位,而是靠函数对象本身。客户端引入的 api 对象已经绑定了 AppRouter 类型,所以调用时能自动提示每个 procedure 在哪个 router 下。
输入校验方面,tRPC 和 zod 是固定搭档。procedure 的 input 一旦声明了 zod schema,tRPC 会在运行时自动解析、校验并推导类型。前端输入的类型、后端接收的类型、最终返回的类型,全部来自同一段 schema 定义,不存在“前后端各写一份校验”的情况。
3.3 tRPC 相比 REST 和 GraphQL 的本质差异
我在 t3code 里同时维护过一段传统 REST 接口作为过渡,那段经历让我对 tRPC 的优势体会得更加具体。REST 的问题不在于概念,而在于类型信息无法网络传输。后端接口返回的 JSON 到浏览器里就成了一堆 any,前端同事为了保证类型正确,只能手工维护 d.ts 文件,一旦后端调整字段,缺少 CRUD 接口的地方就会出现运行时才发现问题。
GraphQL 解决了字段选择和类型定义的问题,但引入了新的复杂度:Schema 定义、resolver 拆分、客户端代码生成、缓存策略,一套下来至少多出四五个概念。tRPC 走了一条更简的思路:既然前后端都是 TS,为什么不直接调用函数?把网络边界彻底抹平。当然它牺牲了“非 TS 客户端”的兼容能力,Web 端之外的移动端、小程序就没法直接复用同一套 API 契约。t3code 的场景本来就是桌面端 Web 应用,这个代价可以接受。
| 对比项 | REST | GraphQL | tRPC |
|---|---|---|---|
| 类型共享 | 需手写维护 | 需生成代码 | 编译器自动推导 |
| 客户端限制 | 任意语言 | 任意语言 | 必须 TypeScript |
| 调试工具 | 浏览器/Postman | GraphiQL/Playground | tRPC 面板 + React Query |
| 学习曲线 | 低 | 中高 | 中低 |
| 适用边界 | 开放 API | 复杂聚合查询 | 同栈全栈应用 |
3.4 实测注意:React Query 版本差异与无限查询分页
tRPC 和 React Query 的配合也有一段容易翻车的经历。create-t3-app 默认安装的 @tanstack/react-query 版本不同,tRPC 的 hooks 会有些许差异。早期版本里 isLoading 字段在 v5 之后改成了 isPending,如果照抄老教程里的写法,会发现在请求未完成时页面渲染的是错误状态。
t3code 里做无限滚动列表时用到了 useInfiniteQuery,这里有一个关键点:tRPC 的 procedure 中如果用 infiniteQuery,它的输入必须包含 cursor 字段,并且返回格式必须满足{ items: T[], nextCursor?: string | null }。我最初直接返回了一个数组,React Query 在拼接页数据时直接报错。后来查看类型提示才意识到,infiniteQuery 的输出结构是固定的,不能按普通 query 的思维来写。这些细节都是文档里有、但初看不会注意的类型约束,真正用起来才懂。
4. Prisma 模型和迁移:t3code 实践中最容易翻车的三个场景
4.1 schema 里的一对多该怎么写才能减少后续关系名冲突
t3code 的数据模型足够复杂:用户可以有多个项目,每个项目下挂多个文档,文档有标签,标签多对多关联。第一次写 Prisma schema 时,一对多关系看起来很简单:
model User { id String @id @default(cuid()) projects Project[] } model Project { id String @id @default(cuid()) user User @relation(fields: [userId], references: [id]) userId String }问题出在当同一个模型之间出现多个关系时。比如 Project 有两个成员:owner 和 editor,它们都关联到 User,Prisma 就需要 relation name 来区分:
model Project { id String @id @default(cuid()) owner User @relation("OwnerProjects", fields: [ownerId], references: [id]) editor User @relation("EditorProjects", fields: [editorId], references: [id]) ownerId String editorId String }这个案例看起来简单,但在一个五六个模型互相引用的 schema 里,relation name 的命名规范一旦没有约定,后续 migrate 时很难一眼看出两个字段之间的关系是什么。我在 t3code 里最终制定了一条规则:relation name 一律用“主模型名 + 在对方模型里的字段名”,比如 User 这边叫 ownerProjects,Prisma 那边就叫 "OwnerProjects"。这样翻 schema 文件的时候,每个 relation 的语义一目了然。
4.2 prisma migrate dev 与数据丢失问题
这是我在 t3code 里踩过最疼的一次坑。当时为了调整一个字段类型,我执行了prisma migrate dev --name change-deadline-type,结果终端提示“Database has drift, resetting database”。我下意识输入了 y,然后整张表的数据全部清空。alert 才发现,这次的 drift 警告是因为我之前手动改过数据库列,schema 和数据库信息库记录不一致,Prisma 只能用 reset 的方式强制对齐。
从此我养成了一个固定习惯:任何可能触发 reset 的 migrate 操作之前,先备份数据库:
pg_dump "postgresql://username:password@host:5432/dbname" > backup_$(date +%Y%m%d_%H%M%S).sql另外,如果只是想快速拉通本地开发,不关心历史数据的场景下,可以直接用prisma db push同步 schema 而跳过 migration 文件。这个命令不会创建迁移记录,适合开发初期数据模型还不稳定的时候。但 t3code 走到正式环境后就很少再用 db push 了,迁移历史必须保持完整,因为生产数据库的升级要靠这些迁移文件执行。
还有一点:seed 脚本在新版本 Prisma 里的执行方式是prisma db seed,需要在 package.json 里配置prisma.seed字段。很多教程还在用旧写法,导致恢复数据库后没有基础数据可用。t3code 的 seed 脚本我一般放两类数据:一类是字典表数据,比如标签、分类;另一类是带有固定 ID 的测试账号,方便本地登录调试。
4.3 多环境数据库连接串配置的流转细节
Prisma 在多个环境之间流转时,最让人头疼的是 datasource url。默认情况下 Prisma 读的是.env里的 DATABASE_URL,但部署到生产环境时,环境变量来自云平台,本地.env文件里的值不会跟着上传。t3code 用的是 Vercel 部署,我在环境变量配置页面单独设置了 DATABASE_URL,指向云数据库的连接串,本地开发继续用本地 PostgreSQL。
这里有一个容易忽略的细节:每次改了 datasource 的 provider 或者 model 定义之后,需要重新执行prisma generate,否则 Prisma client 的类型还是旧的。我在 CI 流水线里加了一步:
npx prisma generate npx prisma migrate deploymigrate deploy 和本地用的 migrate dev 不同,它不会对你开发环境做任何修改,只是把迁移文件按顺序应用到目标数据库,专用于生产部署。如果团队里有人直接用 migrate dev 在共享数据库上跑,很容易把迁移记录搞乱,这个教训我写在团队文档里了。
5. 上生产前的检查清单:环境变量、认证密钥与边缘部署细节
5.1 NextAuth Secret 的生成与轮换机制
t3code 在上生产之前差点栽在一个细节上:NextAuth 的 AUTH_SECRET。脚手架在本地开发时会自动生成一个临时 secret,但部署之后如果你没有显式设置这个环境变量,线上 session 签名就会不一致,表现为“登录后刷新页面就掉登录”。生成一个安全的 secret 用这行命令:
openssl rand -base64 32得到的字符串至少要 32 字节,把它填到 Vercel 的环境变量里。这里有个容易被忽略的伴随问题:如果你在公司内部网络通过代理访问外部服务,环境变量里千万不能混入代理相关配置,尤其是 session 生成依赖的随机源。我见过一次线上偶发登出的案例,最后定位到是部署环境里误设了一个不可达的 HTTP_PROXY 导致 NextAuth 的随机数请求超时。这类问题排查起来特别隐蔽,如果你们团队的网络基础设施比较复杂,建议在环境变量清单里明确剔除所有代理变量。
另外,secret 的轮换也要有策略。直接删掉重新生成会让所有已登录用户的 session 失效,相当于一次全员强制下线。t3code 的轮换方式是先设置新的 NEXTAUTH_SECRET,同时保留旧 secret 在“次要 secret”字段中,让已有 session 可以平滑过渡。这个做法需要你的 Auth.js 版本支持,好在 v5 都支持多 secret 解码。
5.2 边缘运行时与 Prisma 的冲突规避
t3code 在 App Router 下遇到一个很实际的问题:某些 route handler 默认会尝试在 Edge Runtime 上运行,而 Prisma 客户端依赖 Node.js 的某些原生能力,没法直接放进 Edge。也就是说,如果你在 middleware.ts 里使用了 Prisma,或者在某一个标注了 runtime = "edge" 的 route 里 import Prisma,构建时不会报错,但运行时大概率直接挂。
我的处理方式是一条规定:middleware.ts 里绝不碰任何数据库相关逻辑。middleware 只做 session cookie 的解析和路径重定向,真正的用户信息查询全部放到 tRPC 服务端处理。如果你确实需要在 Edge 环境下读取用户信息,用 Session 本身携带的轻量字段就够,不要执着地在 Edge 查询数据库。
t3code 有一个页面需要在服务端拿数据库数据,我给它加了强制 Node.js runtime:
export const runtime = "nodejs";这个声明放在页面文件的顶部即可。测试后确认 Vercel 部署的 Node.js serverless 函数对 t3code 这个量级完全够用,冷启动时间在几百毫秒以内,没有想象中那么夸张。
5.3 我踩过的几次生产事故复盘
第一次事故:上线当天忘了prisma generate。本地开发时一切正常,因为本地 node_modules 里的 client 是旧代码,而新代码已经引用了一个新加了字段的类型,本地之所以没报错,是因为我改完 schema 依赖的 client 恰好是在命令行执行 migrate 时生成的。但 CI 流水线里只跑 build,没跑 generate,构建产物里的 client 没有新方法,页面一调用就 500。排查过程绕了很大一圈,最后看日志发现是 Prisma 报的 Unknown field,才想起是 client 版本落后。结论:CI 里必须在 build 前固定加一步 prisma generate,不要依赖开发机上的手动执行。
第二次事故:DATABASE_URL 指向了已有的生产数据库但连接串里缺少连接数参数。t3code 使用了 PgBouncer 作为连接池,本地连接串是直连 URL,生产连接串是 pgbouncer URL,两者格式稍有不同。结果我在配置环境变量时把两个环境的 URL 互换了一下,生产环境直接连到了本地的隧道地址,导致大量死锁。这个问题让我养成了在每个环境变量备注注释的习惯,如果云平台没有注释功能,就在团队 wiki 里维护一份环境变量说明书。
第三次事故:tRPC 的 protectedProcedure 覆盖情况遗漏了一个新加的 router。我新增了一个adminRouter,它创建时用的是publicProcedure,当时想着反正里面逻辑自己有权限判断,结果上线后任何人都能调用 admin 接口读取部分治理数据。排查过程不复杂,但这个失误让我意识到一个原则:默认用 protectedProcedure 和 role 中间件,并不是每个接口都重写权限逻辑,而是用工厂函数批量声明。t3code 后来封装了一个createRoleProtectedProcedure("admin")函数,所有 admin 路由都从这里创建,从源头避免遗漏。
最后再分享几个实际体验
t3code 这个项目从一行脚手架命令成长为一个跑在云端的全栈应用,整个过程中我最大的体会是:T3 Stack 的价值不只是省掉了繁琐的版本清单维护,更在于它把“类型安全”从一个口号变成了开发流程里的强制约束。以前写接口,改字段后总要到处搜索哪里用到;现在改完类型,编译器和编辑器直接在改动点旁边标红,只要类型检查过了,运行时一致性就有了九成保障。
如果你打算用 create-t3-app 开一个新项目,我的建议是:第一遍初始化时,先用最少的配置跑通全链路,然后再逐步加 Prisma、NextAuth 这些重量级依赖;第一次部署不要把目标定在生产环境,先在预览环境跑一晚,看看日志和性能指标。t3code 的方案不一定适合所有团队,但如果你也厌烦了维护 API 文档和手工类型声明,那这套栈确实值得花一个周末认真试一次。