如果不是上周整理本地仓库时翻到那个 RSS 订阅列表,我可能都快忘了t3code这个用 T3 Stack 攒出来的全栈实验项目。它最早只是我为了避免“每次开新项目都要重新做认证、接数据库、写接口层”而随手建的一个沙盒,后来被团队里好几个同事拿去改造成内部工具的启动模板,慢慢沉淀成一套我自己比较习惯的全栈代码基线。
这篇不打算讲什么大道理,就是把t3code从立项思考、技术选型、关键模块编写,到部署上线的完整过程复盘一遍。如果你正纠结 Next.js、tRPC、Prisma、Tailwind 这一摞东西该怎么拼在一起,或者已经看过create-t3-app的文档但不知道从哪下手,这篇内容应该能让你少走不少弯路。里面提到的坑都是我实际踩过的,不是文档里那种水过地皮湿的总结。
1. t3code 不是什么新框架,是我对 T3 全家桶的一次主动性收编
1.1 我最初想要的是什么
当时摆在我面前的真实需求是这样的:团队需要一个内部的小工具站,用来管理一些运营侧的短链和活动物料。功能不复杂,无非就是登录、录入数据、展示列表、偶尔改几条记录,但有几个硬性条件——
- 必须有账号体系,不能让公司外的访问者随便进;
- 数据要落到数据库,不能靠 JSON 文件硬扛;
- 前端页面要多,但每个页面的复杂度都不高;
- 开发周期很紧,没有时间去拆微服务,也没精力维护两套服务互相调试。
这个需求放到今天看,本质上就是“一个小而全的全栈 Web 应用”。如果按老路子做,我会用 Express 写一套 REST API,再单独搭一个 React 前端,中间靠手写接口文档维持默契。但手写接口文档这件事,恰恰是所有内耗的起点:后端改了个字段,前端可能过两天才意识到,等联调时再回头排错,时间全浪费在“同步类型”上。
所以立项时我给自己定了一个原则:必须选一套能从数据库一直贯穿到浏览器 UI 的类型安全方案。顺着这个原则往下筛,T3 Stack 几乎是最顺理成章的选择。
1.2 为什么最终选了 Next.js + tRPC + Prisma 这套组合
T3 Stack 的核心组合是 Next.js、TypeScript、Tailwind、tRPC、Prisma、NextAuth。我第一次看到这个词的时候,第一反应是“这不就是把一堆流行框架绑一起吗”,但实际用下来发现,它们之间是有咬合关系的。
- Next.js:负责应用层的渲染与路由。开箱即用的 SSR、静态生成、API 路由,让前端页面和接口可以住在同一个代码仓里;
- TypeScript:不是可有可无的类型装饰,而是整个类型链路的粘合剂。后面你会发现,tRPC 之所以能“零 API 定义文件”实现类型安全,全靠 TS 的推断能力;
- tRPC:相当于把“后端函数”变成“可以直接在浏览器里调用的方法”。没有 REST 路由规范、没有 JSON Schema 手工同步、没有 codegen 步骤,类型从 Prisma model 一路流到 React 组件;
- Prisma:负责数据库模型、迁移和查询。它的 schema 文件既是一等公民,也是文档本身;
- Tailwind CSS:写页面时的效率工具,不算核心,但减少了大量样式文件维护。
- NextAuth:做 GitHub / 邮箱等登录场景很顺手,省去自己造 Session 体系的功夫。
我见过不少人评价 T3 Stack“重”,其实它重的是概念整合,而不是工程复杂度。create-t3-app生成的初始仓库非常干净,你后续只需要往里面填业务代碼,架构骨架已经立住了。
这里也放一张我当时做选型对比时粗略梳理的思路表,方便你对照理解:
| 组合方案 | 类型贯通程度 | 初始成本 | 适合的场景 |
|---|---|---|---|
| Express + React SPA | 低,需手写 API schema | 中 | 前后端分头部署的团队 |
| NestJS + Angular | 中,有 DI 与装饰器体系 | 高 | 后端较重的企业系统 |
| Next.js API + SWR | 中,接口类型仍需手工维护 | 低 | 页面多、接口少 |
| t3code(T3 Stack) | 高,model 到 UI 自动推断 | 中低 | 全栈小团队、内部工具、原型快速产品化 |
2. 先拆开 t3code 看编排:每一层到底在解决什么
很多教程一上来就让你跑pnpm dev,跑通后什么也没学会。我建议先花点时间理解 t3code 内部是怎么编排的,再动手不迟。
2.1 Next.js 做编排层,tRPC 做数据通道
在 t3code 里,Next.js 不只是“前端框架”。它的 App Router 或 Pages Router 承担了页面路由、布局、数据请求、API 端点这几件事。tRPC 的服务端部分,其实就挂在 Next.js 的 API 路由能力之上,通过一个[...trpc]的 catch-all handler 接收所有 RPC 请求。
客户端侧则用createTRPCNext把 tRPC 的 query 和 mutation 包装成 React Hooks,比如:
import { createTRPCNext } from "@trpc/next"; import { httpBatchLink } from "@trpc/client"; import type { AppRouter } from "@/server/api/root"; export const api = createTRPCNext<AppRouter>({ config() { return { links: [ httpBatchLink({ url: "/api/trpc", }), ], }; }, ssr: false, });这段代码的核心价值在于AppRouter这个类型。只要服务端 router 导出的类型被这里引用,前端调用的每个接口、入参、返回值都会被 TypeScript 自动校验。我经常用一个比喻给新同事解释:这就像是后端直接把方法引用“递”给了前端,而不是递了一份说明书。说明书可能过期,方法引用不会。
2.2 Prisma 负责数据模型与迁移,别省掉这步
Prisma 在 t3code 里的作用不只是生成 client,更重要的是把 schema 当作团队沟通的“唯一事实源”。我在早期犯过一个错误:直接用 SQL 建表,建完再反推 Prisma schema,结果两边经常不一致。
正确做法是先写 schema,再让 Prisma 去迁移数据库:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Link { id String @id @default(cuid()) url String slug String @unique visits Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }这里的@default(cuid())是我个人比较偏好的 ID 方案。相比自增主键,cuid 在分布式写入和前端临时生成场景下更稳,不容易暴露数据规模,也不用等数据库回一个序列号才能继续操作。
2.3 NextAuth 在这套架构下只做“开门”这件事
t3code 的认证体系我用的是 NextAuth,配合env.mjs做环境变量校验,Session 存在数据库的Session表,用户信息存在User表。这套东西的好处是可以在一小时左右接入 GitHub OAuth,坏处是一旦踩到配置问题,定位链路会比较长。
比如早期我自定义了一个authOptions文件,却没有在 API 路由和 middleware 里同时引用,导致前端显示已登录、后端却始终拿不到 session。这个坑细节非常多,我在后面的部署章节会专门展开。
3. 从零跑通 t3code:初始化、环境变量与第一次迁移
3.1 用 create-t3-app 初始化项目,并做最小化勾选
如果你还没装过 t3code 这套东西,最前一步的操作是:
pnpm create t3-app@latest t3code创建过程中会有几个交互式选项,包括是否使用 NextAuth、Prisma、Tailwind、tRPC 等。我的建议是:第一次跑,按需打勾,不要全选。全选完会发现项目里多了一堆暂时用不上的示例代码,删都删半天。t3code 当时我勾的就是 NextAuth、Prisma、Tailwind、tRPC,没有勾 ESLint 以外的额外工具。
装完以后,项目结构大致是:
src/ app/ # Next.js 页面与布局 server/ api/ # tRPC router 与 context auth/ # NextAuth 配置 db/ # Prisma client 实例 trpc/ # tRPC 客户端 hooks 封装这个目录结构本身就是一种约束:服务端代码和页面代码分得清清楚楚,不会写着写着就把数据库查询糊进 React 组件里。
3.2 env.mjs:把配置错误挡在编译之前
我会特别强调环境变量校验。t3code 用 zod 定义了一个env.mjs文件,启动时如果缺了DATABASE_URL或认证相关配置,进程会直接报错退出,而不是带着错误的配置运行,等到运行时才炸。
import { z } from "zod"; const envSchema = z.object({ DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(32), NEXTAUTH_URL: z.string().url().optional(), }); export const env = envSchema.parse(process.env);这里有两个细节值得注意。NEXTAUTH_SECRET要求最少 32 个字符,这个不是随意定的,NextAuth 用这个 secret 做 session cookie 的签名,太短的话安全性形同虚设。另一个是NEXTAUTH_URL本地开发可以不设置,因为 NextAuth 会自动推断,但部署到生产环境如果不显式配置,回调地址经常会错,后面我会讲。
3.3 第一次建库:db push 还是 migrate?
第一次跑起来之前,肯定要让数据库先有表。Prisma 提供两种常用方式:
pnpm prisma db push这相当于直接基于 schema 把表结构同步到数据库,不生成迁移历史,适合开发初期快速迭代。如果项目已经正式发布,用了pnpm prisma migrate dev,每一份 schema 变更都会记成一次迁移文件,将来可以在测试库、生产库上按顺序回放。
t3code 早期我偷懒一直用db push,导致临时改动表结构时数据库里留下了一些不稳定状态。后来复盘,正确做法应该是:项目进入多人协作阶段的第一天,就从 db push 切换成 migrate。迁移文件就是数据库层面的“提交记录”,回滚、审计、环境同步全都靠它。
3.4 启动阶段最容易踩到的三个报错
我第一次跑pnpm dev时毫不意外地收到了一堆报错,按频率排名,这三类最典型:
| 报错特征 | 常见原因 | 处理方式 |
|---|---|---|
PrismaClientInitializationError: P1001 | DATABASE_URL连不上,库没启动或地址写错 | 检查本地数据库进程与连接串端口 |
Error: EROnly | 环境变量缺失导致 schema 校验失败 | 补全.env,重新启动 |
| 页面能打开但接口全部 404 | tRPC handler 没挂载,或root.ts的 router 未合并 | 确认app/api/trpc/[trpc]/route.ts存在且 appRouter 已导出 |
这些报错的信息量其实非常大。P1001 出现时,不要急着怀疑代码,先用nc -vz localhost 5432之类的命令验证端口通不通。404 则要检查是不是直接把 tRPC handler 写错路径了,Next.js 的 catch-all 路由符必须是[trpc]这个目录名,改掉一个字母都不行。
4. 让 t3code 真正干活:一个短网址模块的完整闭环演练
脚手架折腾完,接下来就是写业务。我用一个“短网址管理”模块来演示,因为它的业务链路很典型:建表、写接口、调接口、刷新页面。
4.1 数据模型:短网址不需要两张表
短网址最核心的实体就一个Link表,字段就是之前 schema 里展示的url、slug、visits。有人会想,访问计数是高频更新,要不要拆个独立计数表?就内部工具这个量级,完全没必要。拆表意味着 join 和一致性维护,收益几乎为零。
slug字段上必须加@unique,因为它是用户访问的短地址标识,重复就等于撞车。生成 slug 时可以自己用纳米级随机数,也可以让前端传一个自定义短码。我选择让前端传,这样团队同学可以指定promo2025这种有意义的码,比系统生成一串乱码好记得多。
4.2 接口层:用 tRPC 把“新增/查询/计数”包起来
在src/server/api/routers/link.ts里写一个 router:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; export const linkRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ url: z.string().url(), slug: z.string().min(3).regex(/^[a-zA-Z0-9-_]+$/), }) ) .mutation(async ({ input, ctx }) => { return ctx.db.link.create({ data: { url: input.url, slug: input.slug, }, }); }), getAll: protectedProcedure.query(async ({ ctx }) => { return ctx.db.link.findMany({ orderBy: { createdAt: "desc" }, }); }), });这段代码里有个很关键的protectedProcedure,它表示该接口必须登录后才能调用。它本质上是一个包了认证中间件的 tRPC procedure,如果 session 不存在,服务端直接抛错,前端拿到的就是未授权状态。
为什么入参用 zod 校验而不是自己写 if?我遇到过太多“格式校验散落在各接口里”的老项目。zod 把 schema 定义和校验逻辑放在接口声明的位置,错误信息统一、类型自动推断,是 tRPC 生态里最自然的搭配。
4.3 前端页面接入:把 useQuery 和 useMutation 用顺手
页面端通常长这样:
const { data: links, refetch } = api.link.getAll.useQuery(); const createLink = api.link.create.useMutation({ onSuccess() { refetch(); }, }); function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); const form = new FormData(e.currentTarget); createLink.mutate({ url: String(form.get("url")), slug: String(form.get("slug")), }); }useMutation执行成功后调用refetch(),列表区域就会自动刷新。这就是“数据流闭环”最直观的体验:没有手动管理 loading、error、success 状态的样板代码,Hooks 已经把状态机内置了。
4.4 访问计数刷新时的一个细节
短网址跳转我单独写了一个接口,访问/{slug}时先update访问计数,再redirect到原始 URL:
redirect: publicProcedure .input(z.object({ slug: z.string() })) .query(async ({ input, ctx }) => { const link = await ctx.db.link.findUnique({ where: { slug: input.slug }, }); if (!link) { return { ok: false as const }; } await ctx.db.link.update({ where: { id: link.id }, data: { visits: { increment: 1 } }, }); return { ok: true as const, url: link.url }; })这个查询接口包了一次写操作,严格说是温度变量。但这里有一个小权衡:如果跳转接口只读不更新计数,就得另做一个事件上报接口,多一次请求,对重定向场景来说反而更复杂。更新频率高的话,建议后续引入队列或 Batched writes,而不是每次都直接碰 Postgres。三千五千的日访问量,直接增量的开销完全可接受,别过度设计。
5. 部署到生产环境后的现场排错记录
5.1 Prisma 客户端在构建机上“消失”了
t3code 第一次部署到 Vercel 时,构建日志给了我一个典型的教训:
Error: @prisma/client did not initialize yet. Please run "prisma generate" and try again.本地跑得好好的,部署机却找不到 Prisma Client。原因很简单:本地的node_modules/.prisma/client是在建库时生成的,而构建机需要显式跑prisma generate,它不会自动出现在依赖安装流程里。
解决办法是在package.json里加一个postinstall脚本:
"scripts": { "postinstall": "prisma generate" }这样每次依赖安装完,都会自动生成客户端,不管是在哪台机器上构建。这个坑小,但代价很高,容易被一句“本地没问题啊”掩盖。
5.2 NextAuth 回调地址在线上环境对不上
部署后第二个问题更隐蔽。线上登录时,GitHub 回调地址指向了https://t3code.vercel.app/api/auth/callback/github,看起来没错,但登录成功后 NextAuth 又把用户重定向到了一个奇怪的 URL,页面直接白屏。
查完以后发现问题出在环境变量上。本地开发时 NextAuth 会自动用localhost推断地址,但线上环境如果你不显式设置NEXTAUTH_URL,它可能读取到 Vercel 注入的VERCEL_URL(这个变量通常是xxx.vercel.app,不带https://,甚至可能是临时预览域名)。
正确的习惯是:每次部署前都到环境变量面板里确认NEXTAUTH_URL指向最终对外域名,而不是依赖推断。回调地址这种事,排查半小时,配置一分钟。
5.3 serverless 冷启动与接口超时
t3code 的 tRPC handler 默认跑在 Node.js runtime 上。如果是纯 Edge runtime,Prisma 在 edge 环境下不是不能跑,但连接管理和功能限制比较多,我劝你直接把 handler 标注成 Node runtime,省得给自己找事:
export const runtime = "nodejs";serverless 平台的冷启动在低流量期确实存在,第一次请求可能要多等 1-2 秒。如果对单次响应时间特别敏感,可以给接口加缓存,或者在 Vercel 的 Fluid compute 设置里调整最小实例数。
5.4 迁移与回滚:一次数据库小事故给我的教训
我有一次在生产环境下改了 schema,新增了一个必填字段,但忽略了已有的数据行里没有这个字段,直接migrate deploy下去,数据库报错,表结构更新失败。好在 Prisma 迁移有事务保护,表没被破坏,但教训是真的。
从那以后我定了两个规矩:第一,生产环境数据库必须做定期备份;第二,任何变更 schema 的 PR,都要先在临时分支跑一遍prisma migrate dev,生成迁移文件,并确认所有已有数据能通过default或optional平滑升级。
6. 把 t3code 从“能跑”进化为“好维护”的基线
6.1 类型复用:把协议抽到独立包
当 t3code 从单项目变成团队多个工具的共用模板后,有几个 router 的输入输出类型需要在多个应用间复用。这时候可以用 pnpm workspace 建一个packages/contracts,把 zod schema 放在里面,tRPC router 直接引用这些 schema,其他应用也能 import 同一份 schema 做表单校验。类型和校验逻辑只维护一份,改动风险大幅下降。
6.2 给 tRPC 加中间件:权限、限流、审计
t3code 的protectedProcedure只做了登录校验,但实际业务往往还需要更细的权限控制。我在src/server/api/trpc.ts里自定义了一个中间件:
const isAdmin = t.middleware(async ({ ctx, next }) => { if (!ctx.session || ctx.session.user.role !== "ADMIN") { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx }); }); export const adminProcedure = t.procedure.use(isAdmin);这个中间件的好处是让权限逻辑收敛到一个位置,而不是在每个 mutation 开头都写一遍 if。等到接口数量超过二十个,这种收敛的价值会非常明显。限流和审计日志也是同样的套路,挂在中间件层,上游调用者无感知。
6.3 和主流方案横向比一下,什么时候别硬上 t3code
| 维度 | t3code(T3 Stack) | 传统 REST API + SPA | 重型后端框架 |
|---|---|---|---|
| 类型贯通 | 极强 | 弱到中 | 中 |
| 前后端部署 | 单一平台即可 | 多环境多节点 | 中 |
| 单仓库复杂度 | 低 | 中 | 高 |
| 服务端能力上限 | 适合 CRUD 与中等业务 | 高,接口粒度可控 | 高,微服务友好 |
| 典型场景 | 内部工具、MVP、中小型全栈 | 复杂接口生态、外部开放 API | 企业级分布式后端 |
如果你的核心目标是做一个对外开放的 REST API,或者客户端不止 Web 端(还有 iOS、Android、小程序),tRPC 的优势会减弱。tRPC 那套免文档、免契约文件的体验,只有在“同一个 TypeScript 前端消费同一个后端”时才最大化。这算是我用下来的一个边界认知:t3code 不是万能的救世主,但它非常适合内网工具和快速产品原型。
6.4 我个人用了大半年后的取舍建议
最后给几个最实在的提示。
第一,数据库选型直接上 PostgreSQL,MySQL 也可以,但请确保 Prisma 版本的适配没问题。本地开发用 Docker 起一个 PostgreSQL 实例比系统自带数据库干净得多,删库重来都很方便。
第二,不要在初始化时把 ESLint 和 Prettier 的规则改来改去。t3code 生成的那套配置虽然啰嗦,但它是经过验证的初始状态。先跑通业务,再根据自己的团队习惯微调,否则你会在配置地狱里消耗掉所有热情。
第三,tRPC 的 HTTP 链接尽量用httpBatchLink,它能把短时间内的多个请求合并成一个批量请求,减少网络往返。如果接口本身要对实时性要求特别高,再单独拆一个httpLink,不用为了“通用”把每个接口都保持独立连接。
第四,如果你打算和别人协作开发,最好尽早把CONTRIBUTING.md写在仓库里,把pnpm prisma migrate dev、pnpm dev、环境变量模板这三件事写清楚。t3code 之所以能慢慢变成团队基线,不是靠某个高深的架构,而是这些琐碎但必要的约定让新人能当天跑起来。
写到这里,我对 t3code 的复盘基本告一段落。这个项目本身技术含量不算高,难就难在把一堆库的边界理顺,把每个决策的理由想清楚。你完全可以把这套思路平移到自己的项目里,从一个小而精的全栈工具开始,慢慢就能摸到 T3 Stack 那条“类型安全到底能帮人省多少事”的边界。如果你在实际搭建时卡在某个报错上,多观察一下是从哪一层冒出来的:数据库层查连接串,接口层查 router 注册,页面层查 hooks 的调用方式。定位到层,问题基本就解决一半了。