1. 先把 t3code 的定位弄清楚:T3 Stack 到底是一套什么方案
不知道各位是怎么搜到 t3code 这个词的。最近它在开发者热搜榜上冒出来,点开一看基本都指向同一件事:T3 Stack——就是那套由 Theo 带火的全栈 TypeScript 组合,现在被称为前端圈里“类型安全全家桶”的代表方案。
我大概是两年前开始用 create-t3-app 的,当时第一反应是“这玩意儿也太省事了”。后来在几个真实交付项目里跑了大半年,才意识到它真正的价值不是省事,而是把一整条链路上的类型安全打通了:你写一个数据库查询,改一个函数签名,前端调用处立刻报错,根本等不到联调阶段。这种体验用惯了之后,再回去写 REST API 反而觉得难受。
1.1 它的组成与真正魅力
T3 Stack 的固定搭配是:
- Next.js(App Router):负责整个应用的框架层、路由和服务端渲染
- TypeScript:全栈统一使用,没有“前后端语言割裂”这回事
- Tailwind CSS:原子化样式方案,配合组件开发效率很高
- tRPC:端到端类型安全的 API 层,很多人第一次接触就是冲着它来的
- Prisma:数据库 ORM,用 schema 定义模型,自动生成类型
- Auth.js(原 NextAuth.js):认证方案,和 Prisma Adapter 无缝对接
很多人把 T3 Stack 仅仅理解为“一个脚手架”,但实际上它是一个有明确哲学的技术选型思路。核心就一句话:默认端到端类型安全,一部分功能可以渐进式引入。它不是把一堆热门库硬塞给你,而是帮你做了一个“取舍后”的默认组合。
用生活化的类比来讲,传统开发模式像是两个部门之间靠文档对接——后端写好接口文档,前端照着文档请求,哪里字段对不上就要扯皮。tRPC 的模型更像是一个办公室里的同事,你直接喊他一声,他当场把结果递给你,不存在中间的翻译层。
1.2 什么项目适合上手
我得先说清楚:T3 Stack 不是银弹,它有自己的适用边界。如果你的项目是:
- 需要快速上线的 Web 应用
- 前后端由同一个小团队或同一个人维护
- 数据模型相对清晰,但业务逻辑可能会频繁迭代
- 团队已经接受了 TypeScript
那这套方案的性价比会非常高。反过来,如果你的项目要对外开放 API 给第三方开发者,或者需要独立部署后端服务给多端共用,那么 REST/GraphQL 方案仍然有它存在的道理。T3 更偏向“单体全栈应用”的形态,它设计的出发点就不是微服务。
1.3 与“REST API + 前端分离”方案的对比
我接触过不少团队,习惯了 Next.js 只做前端,背后再单独起一个 NestJS 或 Express 服务。这种模式没有错,但它带来一个很现实的问题:类型定义和接口文档永远滞后于代码。
你想想这个场景:后端加了一个字段,前端大概率不知道;后端改了参数结构,前端调接口时往往要在运行期才炸。而在 T3 里,这个过程完全逆转——tRPC router 里的每一个 procedure,从前端 useQuery 调用时就能拿到完整的类型推断。字段变了,编译期就报错,根本轮不到跑起来。
数据也能说明问题:我之前带的一个团队在迁移到 T3 之后,联调时间从平均 3 天压缩到 1 天内。这个时间的缩减不是因为大家变勤奋了,而是因为类型系统在编译期替我们消灭掉了一整类“接口不一致”的 Bug。
2. 初始化一个 T3 项目的完整实操
老规矩,先把手弄脏。这一节你会完整走一遍 create-t3-app 的初始化流程,我不会跳过任何一个选项,因为这些选项每个都在定义后续项目的形态。
2.1 版本选择与交互式选项
环境要求很简单:Node.js 18.17 以上版本。我建议直接上 Node 20 LTS,省心。初始化命令是:
npm create t3-app@latest my-t3-code执行之后会有一轮交互式选择,每个选项都讲一下我的实践经验。
使用 TypeScript 还是 JavaScript?无脑 TypeScript。T3 的灵魂就在类型安全,选 JavaScript 等于自废武功。
使用 Next.js 的哪个模式?如果你是在 v10 之前的版本初始化,可能还会问 App Router 还是 Pages Router。今天主流是 App Router,除非你有特殊的历史包袱,否则直接选 App Router。
是否启用 tRPC?这是核心,必选。如果你只想要 Next.js + Tailwind,那其实不需要 create-t3-app,直接用 create-next-app 就够了。
Prisma 还是 Drizzle?旧版本只会给 Prisma 选项,新版本允许你选择 Drizzle。我在生产环境中两个都试过。Prisma 的生态更成熟,文档更全,跟 Auth.js Adapter 的配合也更顺。Drizzle 更轻量、性能上限更高,但它对复杂查询的支持还处在逐步完善的阶段。第一次上手就选 Prisma,等玩熟了再考虑换。
是否启用 Auth.js?如果项目需要登录功能,建议一开始就选上。后面再加认证方案和一开始就规划好数据模型,成本完全不一样。
是否启用 Tailwind?如果你已经有成熟的组件库体系,可以跳过。但我个人的建议是:先用 Tailwind,它和组件库并不冲突,后面再按需抽离。
ESLint 和 Prettier?这是非可选项,至少选上其中一个。团队协作时,格式化分歧是最没必要吵的架。
初始化完成后进入项目目录,第一件事我建议跑一下npm run dev,看到首页能正常渲染再继续。这一步是为了确认环境没问题,别等改了一堆配置之后再排查基础问题。
2.2 生成的目录结构怎么看
很多人初始化完就急着写业务代码,结果被目录结构搞晕。T3 的目录确实有它自己的逻辑,重点看这几个位置:
src/app/:Next.js App Router 的页面和布局src/server/api/:tRPC router 的注册入口src/server/api/routers/:每个业务领域的 router 文件都放这里src/server/db.ts:Prisma Client 的单例导出src/trpc/:tRPC server 和 client 的配置,以及 React Query 的封装
一个比较典型的业务开发流程是:先在src/server/db/schema.prisma里加模型,然后npx prisma migrate dev生成迁移,再到src/server/api/routers/里写 procedure,最后在前端组件里通过api.something.query()调用。
2.3 接入数据库与推送 Prisma schema
默认情况下 Prisma 用的是 SQLite,方便你快速跑起来。改接口需要改schema.prisma里的 provider,同时调整DATABASE_URL环境变量。
我给你一个我在生产环境里常用的 PostgreSQL 配置:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }改完 schema 之后两条命令要记住:
npx prisma migrate dev --name init npx prisma generate如果数据库已经有了历史数据,建议用npx prisma migrate deploy在 CI/CD 里跑,而不是dev版本,这个坑后面单独说。
3. 核心链路拆解:tRPC、Prisma 与 Next.js 如何形成闭环
很多教程把 T3 拆成一个个工具分别讲,但恰恰忽略了它最值钱的部分:组合逻辑。这一节我们把三条主线串起来看。
3.1 类型安全的传输层 tRPC
tRPC 的思维方式和 REST 完全不同。REST 强调“资源”和“动词”,tRPC 强调“直接调用函数”。你定义一个 procedure,客户端就像调用本地函数一样调用它。
看一个最简单的例子,在routers/post.ts里定义:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "@/server/api/trpc"; export const postRouter = createTRPCRouter({ byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id }, include: { author: true }, }); return post; }), });客户端调用时,TypeScript 能直接推断出返回值的完整类型。你甚至不需要手动写 interface。这个特性的关键是 Zod 做输入校验,Prisma 做输出类型推导,两者在编译期完成静态检查。
需要注意的一点是:tRPC 不等于不用写接口文档。如果你构建的是对内应用,这张“隐形的契约”确实够用。但如果你有外部合作方,还是建议用 OpenAPI 插件把部分 router 导出成标准文档。新版 tRPC 有openapi插件,可以配合 trpc-openapi 来做。
3.2 Prisma 查询的边界
Prisma 最强大的一点是查询 API 的类型安全,但它也有边界。很多从 SQL 转过来的人容易低估 Prisma 在复杂查询上的限制:
- 多层嵌套的
include虽然写法方便,但会产生大量 SQL 查询(N+1 问题) - 复杂的聚合统计、窗口函数,Prisma 的支持并不完善
- 原生查询只能用
$queryRaw,但安全性需要自己把关
我有一次在报表页面上用了三层嵌套 include,页面直接卡了 3 秒。后来用$queryRaw重写成一条 SQL,配合合理的索引,把查询压到了 100ms 以内。
所以在实际项目中我给自己定了一条规矩:复杂统计查询走$queryRaw或直接写 SQL 视图,业务 CRUD 走 Prisma 的高层 API。两者结合,既享受类型安全,又不被框架限制。
3.3 App Router 下 server 与 client 的协作
T3 引入了一个实践细节:tRPC client 在 RSC(React Server Components)和 Client Components 之间的使用方式不同。
在 Server Component 里,你可以直接调用 tRPC 的 server caller,而不经 HTTP 请求:
import { api } from "@/trpc/server"; export default async function PostPage({ params }: { params: { id: string } }) { const post = await api.post.byId({ id: params.id }); // ... }在 Client Component 里则用原生的 query 方式:
"use client"; import { api } from "@/trpc/react"; export function PostView({ id }: { id: string }) { const { data } = api.post.byId.useQuery({ id }); return <div>{data?.title}</div>; }同样的 procedure,两种入口,类型都是完全一致的。刚开始接触时可能会觉得“同一个函数怎么有两个调用方式”,实际上 Server 端走的是直接执行函数,Client 端走的是 HTTP + React Query 缓存,各取所需。这个设计非常巧妙,Server 端避免了多余的网络请求,Client 端获得了缓存、重试、乐观更新等能力。
4. 认证、权限与多用户场景的接入
真正的业务系统一定绕不开登录用户和权限控制。T3 项目的认证默认接 Auth.js,配合 Prisma Adapter,数据模型已经定义好 User、Account、Session 等表。
初始化时如果勾选了 Auth.js,schema.prisma里会自动生成这些模型。我的建议是:先不要急着改这些表结构,Auth.js 的表之所以长那样,是为了兼容多个 OAuth Provider 的差异。往里面加业务字段可以,但别动关联关系。
4.1 Auth.js 的 T3 集成方式
配置在src/server/auth.ts里。首次接入 GitHub OAuth 时需要去 GitHub 开发者设置里申请 Client ID 和 Secret。环境变量要加在.env,注意别提交到代码仓库。
AUTH_SECRET="xxx" AUTH_GITHUB_ID="xxx" AUTH_GITHUB_SECRET="xxx"Auth.js 在 v5 版本里改了不少东西,如果你是从老项目升级,最容易踩的坑是session.token相关的类型修改。我的做法是直接锁定版本,不要跟着大版本迁移,等业务稳定了再统一升级。
4.2 在 tRPC 中间件里做权限校验
tRPC 的中间件机制非常适合做权限控制。默认生成的代码里就有一个protectedProcedure示例,它的实现逻辑是:从 session 里取用户,没有就抛错。
我一般会更进一步,写一个通用的requireRole中间件:
export const requireRole = (roles: Role[]) => t.middleware(async ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } if (!roles.includes(ctx.session.user.role)) { throw new TRPCError({ code: "FORBIDDEN" }); } return next({ ctx: { user: ctx.session.user } }); });这个写法的好处是,在 procedure 内部再也不用重复判断session.user是否存在,类型系统会保证user非空。少了非常多在业务代码里写“if 没有登录就返回 null”的样板代码。
4.3 数据隔离的实战写法
多用户场景最怕的是“用户 A 能查到用户 B 的数据”。这类问题在传统后端里靠 SQL 加WHERE user_id = ?解决,在 tRPC 里同样需要你在查询条件里带上当前用户 ID。
比如笔记功能的查询:
getMine: protectedProcedure .query(({ ctx }) => { return ctx.db.note.findMany({ where: { userId: ctx.user.id }, orderBy: { updatedAt: "desc" }, }); }),要警惕的是服务和客户端逻辑分离的写法:客户端传userId参数进来然后服务端直接用。这样等于把数据隔离的控制权交给用户,非常危险。正确写法永远是服务端从 session 拿用户 ID,而不是相信客户端传参。
5. 真实项目里绕不开的坑,和我的修复记录
踩坑不可怕,可怕的是不知道为什么踩坑。下面几个问题都是我在真实项目中遇到并解决过的,按“现象 → 排查 → 修复”的顺序写,这样你以后遇到类似问题可以直接按图索骥。
5.1 类型丢失与泛型地狱
现象:某个 tRPC procedure 里的返回值在客户端变成了any,之前好好的类型突然不推导了。
排查链路:tRPC 类型推导依赖 React Query 的 key 和 router 定义。最常见的诱因是你在某个文件里用了as any把返回值强转了。一旦出现 any,整个类型链路的末端就断了。另一个常见原因是 router 文件里循环引入,导致 TypeScript 类型推断失败。
修复:在tsconfig.json里开启strict: true,并且把noUncheckedIndexedAccess打开。这个配置能逼着类型系统暴露问题,而不是默默降级。真实经验是:在 tRPC 项目里,任何手动as强转都是危险信号,代码 review 时我会重点看这些位置。
泛型地狱:当你在 tRPC 里建可复用组件时,会发现自己被泛型卡住。比如写一个接受任意 procedure 的通用表格组件。我的解法是不要过度抽象。与其写一个“万能组件”,不如把公共逻辑抽到 hooks 里,让每个业务组件自己调用 procedure。这样类型最简单,也最好维护。
5.2 数据库迁移与 schema 演进
现象:prisma migrate dev在本地没问题,但部署到生产环境后,数据库状态和迁移记录不一致。
排查链路:后来发现是团队里有人直接改了数据库表结构,没有生成迁移文件。Prisma 迁移工具依赖_prisma_migrations表来跟踪状态,任何一次手工改库都会导致该表与 schema 不同步。
修复:我们的流程改成这样:
- 所有 schema 变更必须走
npx prisma migrate dev --name 描述 - 迁移文件必须提交到 Git
- 生产环境只允许执行
npx prisma migrate deploy - 任何人想改数据库结构,必须先拉最新代码再改 schema
还有一个小坑:prisma migrate dev在检测到漂移时会让你重置数据库,这个动作会清空数据。本地开发无所谓,但如果连的是共享开发库,一定要先备份。我吃过一次亏,重置后把同事的测试数据清了个干净,从那以后只用--skip-generate和--create-only分开执行。
5.3 性能瓶颈定位
现象:页面加载速度还可以,但某个接口在数据量上来后从 200ms 飙升到 2 秒。
排查链路:先在 tRPC 的 procedure 里加了console.time,发现大部分时间花在一个 Prisma 查询上。用prisma的日志功能把 SQL 打出来,发现查询走了全表扫描,因为关联字段没建索引。
修复:在 schema 里给外键和常用查询字段加了@index:
model Post { id String @id @default(cuid()) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) @@index([authorId, createdAt(sort: Desc)]) }迁移后查询时间降到了 50ms 左右。这个例子说明:类型安全解决的是“写错接口”的问题,但不解决“SQL 写得慢”的问题。性能问题还是要回到数据库层去分析。
另外提一嘴,tRPC 默认的 HTTP 传输是TRPCClientError,出现网络错误时排查起来比较痛苦。我习惯在开发环境开启trpc的 logger:
export const createTRPCRouter = t.router({ // ... }).transform(({ success, error }) => { if (!success) { console.error("tRPC error:", error); } return { success, error }; });当然不建议在生产环境直接打印 error,里面可能带敏感信息。更好的方案是接到 Sentry 等监控平台。
5.4 Session 过期与前端同步问题
现象:用户登录后打开页面正常,但过一段时间再操作,所有 tRPC 请求都返回 401。刷新页面又好了。
排查链路:Auth.js 的 session 默认保存在数据库(使用 Adapter 模式时),前端把 session 存到 Cookie 里。Cookie 过期时间到了,但前端没有感知到 session 失效,等下次请求时才收到 401。
修复:在 tRPC client 的links里加一层处理。当收到 UNAUTHORIZED 错误时,触发一次全局事件让前端清除本地 session,并跳转登录页:
httpBatchLink({ url: "/api/trpc", fetch(url, options) { return fetch(url, options).then((res) => { if (res.status === 401) { window.dispatchEvent(new Event("unauthorized")); } return res; }); }, });另外一个小细节是:不要把session事件监听放在每个 Component 里,我建议在 Layout 或挂载于_app的地方统一监听。否则会有多个订阅重复执行,容易出现“跳转两次”的怪问题。
6. 如果让我重新选一次:T3 的适用边界与个人体会
写到这儿,我想把视角拉远一点。t3code 或者说 T3 Stack 真正教会我的,不是某个库怎么用,而是“工具链的组合密度”能带来多大的开发体验提升。
一路用下来,我对它的定位是:绝佳的中小型全栈 Web 应用默认方案。它把类型安全、开发速度、可维护性三者平衡到了一个很舒服的位置。但我也得说几个真实的边界:
- 如果你要做一个高并发的公开 API 平台,T3 不是最优解。网关、限流、API 版本管理这些能力,它没有专门的设计。
- 如果你需要多团队并行开发前后端,T3 的“单仓全栈”模式需要额外的代码管理规范,否则会很乱。
- 如果你对数据库查询要求极其苛刻,涉及大量复杂报表,Prisma 的高层 API 会让人有点着急,需要频繁下探到
$queryRaw。
6.1 适合的团队与项目画像
我自己总结了一个“三合适”的判断标准,符合两条以上,T3 Stack 会很合适:
| 判断维度 | 合适条件 |
|---|---|
| 团队规模 | 5~10 人以内,前后端同组或同仓开发 |
| 项目形态 | 内部系统、SaaS 应用、工具类 Web 应用 |
| 时间压力 | 需要快速验证业务模型,MVP 周期以周计 |
| 技术底座 | 团队已熟悉 TypeScript,愿意把类型当文档用 |
反过来,如果你面临的是几十人协作的大型项目,前后端有明确的独立发布节奏,T3 的“全栈合一”优势就会变成约束。这时候应该拆开服务,而不是硬套 T3 的形态。
6.2 一些经验性的取舍建议
最后分享三条我在实操中沉淀下来的个人习惯,供参考:
第一,遵循 T3 的“渐进式采用”哲学。T3 的全称其实是“The T3 Stack”,其中那个“渐进”意味着不是所有功能都要一上来用满。初期只写几个 tRPC procedure,等确认业务稳定后再补优化层。一上来就追求“完美架构”往往是项目延期的主要原因。
第二,把 schema 当契约管理。数据库 schema 里的字段名、关系、默认值,是前后端都依赖的“契约”。任何改动都要像修改 API 一样谨慎。有一个小技巧是给重要的模型加@@map("snake_case"),避免 JavaScript 的 camelCase 和数据库的 snake_case 混淆。
第三,时刻关注“类型日志”。在开发过程中,如果发现某个类型变成 any,或者某个属性推断不出来,别拖,立刻修复。类型的腐化是渐进式的,等到你意识到时,整个项目已经到处都是@ts-ignore了。这条建议可以说是我在 T3 项目里最重要的经验。
我在实际交付的几个项目里,最终都验证了这套方案的价值:开发速度、维护体验、新人上手成本,三个维度都表现优秀。如果你正在犹豫要不要选择一个全栈方案,不妨用 create-t3-app 初始化一个小 Demo,从自己最熟悉的业务模型开始写起,跑一个完整的登录 + 数据列表页面。那种“前后端类型一条线贯通”的流畅感,只有亲手写过才能真正体会。