T3 Stack 全栈项目 t3code:选型、踩坑与部署
那天同事路过我工位,看到我终端里开着一堆项目窗口,随口问了句:t3code 是什么新东西?我说这不是一个开源项目名,是我手头一个全栈应用的内部代号。他接着问,这名字有什么讲究吗?我想了想,还真有。t3code 这个代号里,"t3"对应的是 T3 Stack 这套全栈 TypeScript 技术栈的约定,而 "code" 既是"代码"也是我当时给这个项目起的主题词——用一套类型贯穿到底的方式把代码写明白。
这篇文章就把 t3code 从命名到落地、从踩坑到沉淀的完整过程写出来。如果你正在纠结全栈项目技术选型,或者已经在用 Next.js 和 tRPC 但总觉得哪里不对劲,这篇应该能帮你在动手前少走几步弯路。文章里涉及的具体版本号、目录规划和排查思路,都是我实际跑过的,可以直接参考。
1. 为什么这个项目最终叫 t3code:技术选型背后的三层考虑
1.1 T3 三个字母分别负责什么
T3 Stack 这名字不是官方文档里硬造出来的概念,它来自社区里一种比较直白的约定:TypeScript、Tailwind、tRPC,三个都以 T 开头,合起来就是 T3。再加上 Next.js 作为应用框架底座,以及 Prisma、NextAuth 这些常驻伙伴,形成了一套"全栈都是 TypeScript、从数据库到浏览器按钮之间没有一条裂缝"的开发方式。
我在定 t3code 这个代号之前,其实反复问过自己一个问题:我到底需要什么样的全栈架构?我的答案落在几个很具体的痛点上:
- 前后端各写一套类型,接口一改就要两头同步,漏一处就是线上事故;
- 团队规模不大,没有专职的 API 网关团队,接口契约靠文档维护根本不现实;
- 业务迭代快,经常要在一两天内把"数据库表 + 页面 + 接口"整条链路打通。
T3 Stack 恰好把这几件事压缩得很小。tRPC 让 API 调用像调用本地函数,类型自动从服务端推导到客户端;Prisma 让数据库模型成为唯一的"真相源";zod 在接口入口做运行时校验。这套组合对中小型全栈项目来说,效率优势非常明显。
1.2 这套技术栈适合谁,不适合谁
我见过不少人把 TypeScript 全栈当成万能解药,其实它有自己的边界。如果你遇到的是下面这些情况,T3 这套方案未必合适:
- 需要对接大量外部系统,接口契约必须严格按照某个团队制定的 OpenAPI 文档来写,这种情况下 tRPC 的自由风格反而会成为负担;
- 团队里前端和后端是完全独立的两拨人,各自部署、各自发版,那前后端分离的 REST 或 GraphQL 反而是更清晰的边界;
- 项目要求极致的首屏性能,且大部分页面是强静态内容,Next.js 的 SSR/SSG 虽然是优势,但如果你根本用不上这些能力,引入它反而增加复杂度。
反过来,如果你做的是内部工具、MVP、B 端后台,或者一个"一个人能 hold 住整个链路"的产品,t3code 这种选型逻辑会让你非常舒服。我的判断标准很简单:当"改一个字段"这件事需要动的文件数越少,这套技术栈就越值。
1.3 我在 t3code 里实际落地的依赖清单
先给我的这份实测版本组合,这是我跑通 t3code 核心功能时的状态。你要在 2025 年新建项目,create-t3-app 生成的版本大概率会更新,但版本匹配的思路是一样的。
{ "dependencies": { "@t3-oss/env-nextjs": "^0.11.1", "@tanstack/react-query": "^4.36.1", "@trpc/client": "^10.45.2", "@trpc/next": "^10.45.2", "@trpc/react-query": "^10.45.2", "@trpc/server": "^10.45.2", "next": "^14.2.15", "next-auth": "^4.24.7", "react": "^18.3.1", "tailwindcss": "^3.4.10", "zod": "^3.23.8" }, "devDependencies": { "prisma": "^5.19.1", "typescript": "^5.5.4" } }这里有个重要的版本匹配细节:tRPC v10 和 react-query v5 之间曾有过一段兼容期问题。如果你直接用 create-t3-app 最新版,它一般会帮你选好配套版本;如果你是手动升级某个依赖,一定要去看 tRPC 的 release notes,别盲目把 react-query 升到 v5 后才发现 tRPC 的useQuery封装不兼容。
2. 从脚手架到首个接口:t3code 初始化阶段的关键选择
2.1 create-t3-app 交互式选项怎么选
t3code 初始化的第一步很简单:pnpm create t3-app@latest。这里我用 pnpm 而不是 npm,主要原因是 pnpm 的依赖隔离机制能避免很多幽灵依赖问题,尤其在 Next.js 和 tRPC 这种依赖关系复杂的项目里,pnpm 的安装速度和磁盘占用也很友好。
交互式询问里,有几个值得认真想的勾选项:
- NextAuth:如果你的应用需要登录,建议一开始就勾上。后面再补会涉及 middleware、session 类型声明、API 路由拆分,改动面更大;
- Prisma:只要你的数据不是存在内存或纯 JSON 文件里的,就勾;
- tRPC:这是 T3 的核心,不勾就失去意义了;
- Tailwind:看你的样式偏好,不习惯原子化 CSS 可以不勾,但勾了以后改 UI 确实快很多。
我当时还额外做了一件事:创建完成后立刻把生成的.env文件补齐,因为 t3-app 默认生成的环境变量模板里,DATABASE_URL、NEXTAUTH_SECRET这些都是空的,不填的话 dev server 一启动就开始报错。
2.2 目录不是按类型分,而是按业务分
这是我从 t3code 里学到的最重要的一条工程经验。很多人刚接触 tRPC 时,喜欢把所有路由写在一个trpc.ts文件里,或者按"controllers / services / models"分层。但 t3code 的目录我最终是按业务域拆的:
src/ ├─ server/ │ ├─ api/ │ │ ├─ routers/ │ │ │ ├─ post.ts │ │ │ └─ user.ts │ │ ├─ root.ts │ │ └─ trpc.ts │ ├─ auth.ts │ └─ db.ts ├─ trpc/ │ ├─ server.ts │ └─ react.tsx └─ app/ ├─ api/ ├─ (routes)/ └─ layout.tsxrouters/post.ts里只放帖子相关的查询和变更,routers/user.ts里只放用户相关逻辑,root.ts 把它们合并成根 router。这样的好处是:当你需要定位一个问题时,按业务名找文件比按层级找文件快得多。对全栈类型化项目来说,"按业务聚合"比"按技术分层"更符合直觉。
src/trpc/react.tsx这个文件很容易被忽略,它是 tRPC 客户端和 React Query 绑定起来的桥梁,包含useUtils()这类关键 hook。改代码时记得把这一层当成 API 门面来维护。
2.3 数据库和环境变量两个启动前必查项
本地开发数据库,我的选择是直接用 Docker 起一个 PostgreSQL 容器:
docker run --name t3code-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=t3code -p 5432:5432 -d postgres:16这比依赖某个托管数据库服务更可控,CI 环境里也能轻松复现。连接串写在.env里:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="这里填一段随机长字符串" NEXTAUTH_URL="http://localhost:3000"NEXTAUTH_SECRET我踩过一次坑:本地把它写成一个固定短字符串,上线前忘记换,结果生产环境的 session 用的还是同一个弱密钥。现在我会用openssl rand -base64 32生成一段,放到本地和线上各一份,保证不一样。
数据库结构同步分两个命令:
prisma db push:直接同步 schema 到数据库,适合开发中频繁改模型、不关心迁移记录的场景;prisma migrate dev:生成迁移文件,适合记录每次变更,上线前用migrate deploy应用。
t3code 前期我大量用db push,因为模型还没稳定,不想积累一摞没意义的迁移文件。到临近上线才切换到migrate dev,保持迁移历史的干净。
3. 类型安全不等于不会出问题:t3code 里三个联动陷阱
3.1 tRPC 编译期类型与运行时校验的差距
tRPC 最吸引人的一点是"类型自动推导"。客户端调用api.post.list()时,返回类型不用手写,也不用生成 SDK。但你要清楚一个边界:类型安全是编译期的承诺,运行时数据仍然是不可信的。
我在 t3code 里写过一个创建帖子的接口:
// src/server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "@/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: input }); }), });input 用了 zod 做运行时校验,这很重要。因为客户端如果是一个老版本页面、或者有人绕过前端直接调接口,传入的title就可能不是字符串。没有 zod 这一层,Prisma 会抛一个很难看的错误;有了 zod,错误信息会清晰得多。
我的建议是:tRPC 的每个 mutation 都要带 zod input,query 如果带参数也一样。哪怕 input 只是一个 id,也写上z.string().min(1),这习惯能帮你挡掉大量"明明类型没问题但线上报错"的诡异问题。
3.2 Prisma model 变了,zod 没跟着变的连锁反应
这是 t3code 里最典型的一类割裂问题。你改了 Prisma schema:
model Post { id String @id @default(cuid()) title String content String? published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }然后对应的 zod schema 还停留在旧状态,比如漏了published字段或是把content设成了必填。前端传参时 tRPC 类型没报错,但 z 校验直接拦截了一部分请求,或者更糟——校验通过了,数据库写入失败。
我在 t3code 里的对策很朴素:改 Prisma schema 的同时必须改 zod schema,两者放同一行 git diff 里。我还会在改完模型后立刻执行一次pnpm tsc --noEmit,用 TypeScript 编译器把每个引用点扫一遍。这花不了几分钟,但能让类型断层问题在提交前就暴露。
如果你项目里的 zod schema 完全是由 Prisma model 生成的,可以关注 zod-prisma-types 这类工具,它能自动产出 zod schema。不过要注意,自动生成的东西通常不支持自定义校验逻辑,比如"标题必须以字母开头"这类业务规则,还是得手工写在生成的 schema 之上。
3.3 NextAuth 会话在 tRPC 中间件里的边界
t3code 里protectedProcedure是这样写的:
// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import { getServerSession } from "next-auth"; export const createTRPCContext = async (opts: CreateNextContextOptions) => { const session = await getServerSession(); return { db, session, ...opts }; }; const t = initTRPC.context<typeof createTRPCContext>().create(); export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, user: ctx.session.user } }); });这里有一个边界问题很容易被忽略:getServerSession()的返回值是存在 cookies 里的 session 数据,它不会自动验证用户是否还存在于数据库里。如果用户被管理员封禁,或者账号被删除,只要 session 没过期,tRPC 中间件仍然会放行。
所以我后来在 t3code 里加了一条规则:涉及敏感操作的protectedProcedure内部,要再查一次数据库确认用户当前状态。这会让每个请求多一次查询,但对正确性来说值得。如果你有性能顾虑,可以用 redis 缓存用户状态,而不是省掉这次查询。
4. 真正拖慢进度的不是写代码:t3code 踩坑排查实录
4.1 列表不刷新:invalidateQueries 的 key 精确匹配问题
这是个看起来很小、但特别耽误时间的 bug。现象是:提交创建帖子的 mutation 后,列表页不自动出现新数据,手动刷新浏览器才有了。
我的排查链路是这样的:
- 先看 Network 面板,确认 mutation 请求是成功的,状态码 200,数据库里也有新数据;
- 既然数据库没问题,那就是客户端缓存没有失效,问题在 React Query 的失效逻辑;
- 打开代码看 mutation 成功后的处理,发现写的是:
await utils.post.list.invalidate();看起来没问题。但是再看list查询的实际定义,发现我调用时传了参数:
const list = api.post.list.useQuery({ status: "published" });而 invalidate 没有传任何 filter。在 tRPC v10 里,invalidate默认会清除所有同 key 的查询,但如果你用了自定义的queryKey,或者把post.list和post.listUnpublished分开命名,就会出现"名字对不上"的静默失败。
最终解决方式是改用 React Query 提供的精确过滤:
await utils.post.list.invalidate(undefined, { predicate: (query) => query.queryKey[1]?.status === "published", });或者更简单一点:把查询 key 统一管理,而不是随手就写useQuery()。在 t3code 里我后来做了一个小工具函数,把所有查询 key 收敛到一个文件里,这个问题就没再出现过。
4.2 NextAuth 页面适配器与 server-only 缺失引发的运行时报错
另一个让我印象深刻的 bug 发生在上线前。本地pnpm dev一切正常,构建也通过,但部署后一到登录回调就抛 "PrismaClient is not defined" 之类的运行时错误。
排查过程分三步:
- 先怀疑环境变量没配好,但检查了生产环境的 DATABASE_URL、NEXTAUTH_URL 都没问题;
- 再看构建日志,发现 Next.js 把
server/db.ts里的 PrismaClient 实例部分打包进了客户端 bundle,因为next-auth的配置文件同时被服务端和客户端引用,而里面 import 了 db 模块; - 解法是在 db.ts 和 auth 相关文件顶部显式加上:
import "server-only";server-only是 Next.js 提供的一个空包,专门用来标记"这个模块只能在服务端使用"。加了这一行后,如果构建时有任何客户端代码试图 import 它,构建就会直接报错,而不是留到运行时才炸。这是 Next.js 全栈项目里非常值得养成的一个习惯。
4.3 动态路由在静态部署后的 404 与 fallback 策略
t3code 里有一个帖子详情页/post/[id]。开发环境一切正常,build 后部署上去,直接访问老的帖子链接偶尔会 404。
原因是 Next.js 的 App Router 默认会对动态路由做静态优化,构建时会根据generateStaticParams生成一部分页面。如果generateStaticParams没写全,或者路由参数是运行时才从数据库里查出来的,构建时没有的路径就会直接 fallback 成 404。
我当时的处理是:彻底放弃为这个高频动态页面做静态生成,改成强制服务端动态渲染:
// app/post/[id]/page.tsx export const dynamic = "force-dynamic";这个取舍的逻辑很简单:帖子的访问次数多、内容需要实时性,静态生成的收益很小,反而要承担"新帖子无法访问"的风险。如果你的动态页面内容几乎不变,才适合用generateStaticParams+dynamicParams = false来做全量静态化。选哪种方案取决于你的内容变更频率,而不是"静态化听起来更快"。
5. 从 t3code 里沉淀下来的版本组合与部署细节
5.1 实测稳定的依赖版本组合
前面列过 package.json 的完整版本,这里补充一个我刚升级到 Next.js 14 + tRPC v10 时的截图式记录。如果你现在跑pnpm create t3-app@latest,生成的项目大概率已经上了 tRPC v11 和 Next 15,但核心的"创建 context、定义 procedure、客户端调用"三步式结构没变。
要特别注意的版本匹配关系:
- tRPC v10 与 react-query v4 是官方长期验证组合;
- 如果你把 react-query 升到 v5,需要同步把
@trpc/react-query也升到支持 v5 的版本; - Next.js 15 对异步请求 API 有改动,
getServerSession这类旧写法在新版本里的调用方式可能会变。
我不建议在生产项目里同时把 Next.js 和 tRPC 都升大版本,一次只升一个,升完跑完整业务流程再动下一个。
5.2 部署时最关键的环境变量与构建参数
t3code 部署用的是最主流的方案:Vercel 托管,PostgreSQL 用托管数据库。部署前我列了一个检查清单,帮你少走弯路:
| 检查项 | 说明 |
|---|---|
| DATABASE_URL | 必须是生产库的连接串,且允许 Vercel 的 IP 访问 |
| NEXTAUTH_SECRET | 生产环境单独生成,不要和本地共用 |
| NEXTAUTH_URL | 必须填正式域名,否则 oauth 回调地址会指向 localhost |
| 构建命令 | 默认next build,确认数据库已迁移完成再 build,否则构建期访问数据库会失败 |
| 迁移时机 | 在部署流程里加一步prisma migrate deploy,避免代码比表结构新 |
还有一个容易踩的坑是:Vercel 环境变量在构建期和运行时是分开生效的。如果prisma generate需要读取 DATABASE_URL,而你的构建流程没设置对应的环境变量组,就会报错找不到数据库。
5.3 从 t3code 往外扩展的几个方向
t3code 跑通后,我计划中的扩展方向有三个:
- 用 tRPC 的 subscription 能力做实时通知,比如帖子收到新评论时,让列表页自动刷新,这比前端轮询优雅得多;
- 把 zod schema 作为前后端共享的"校验文档"沉淀到单独包,以后如果还要开一个新的客户端应用,可以直接复用同一套 schema;
- 考虑把 Prisma 换掉,用更轻量的 Drizzle ORM。原因是我这个项目的查询模式相对简单,Drizzle 的产物更可控,类型体验也很好。但换之前得考虑清楚:Prisma 的生态和文档资料确实更多,团队里新成员上手成本更低。
这些方向不是为了追新,而是看准了项目接下来半年会遇到什么需求再动手。
最后说一个和 t3code 绑定最深的小习惯:每次我改完 Prisma 的 schema,都不会马上埋头写业务,而是先花半分钟跑一遍pnpm prisma generate && pnpm tsc --noEmit。这个动作看起来不值一提,但挡住过我在 t3code 里好几处字段改名导致的前后端割裂问题。对我而言,"t3code" 这个名字慢慢从一个项目的代号,变成了我对待全栈开发的一种固定姿势——先让类型把话说清楚,再让代码跑起来。