T3 Stack全栈实践:从类型安全到部署的完整指南
t3code 这个名字,乍看像某个编辑器插件的代号,其实是我给自己一个全栈实验项目起的内部名称。它的核心技术栈是 T3 Stack——Next.js、TypeScript、Tailwind CSS 和 tRPC,再加上 Prisma 与 NextAuth.js 作为数据层和鉴权层。整个项目跑通下来,我最深的感受是:类型安全不是效率的敌人,反而是长期维护里最可靠的同事。这篇文章我会把 t3code 从选型理由、脚手架搭建、业务模块开发到部署上线的完整链路都摊开来讲,适合正在纠结"全栈项目到底怎么选技术栈"的人,也适合那些已经用 T3 Stack 但总在类型边界和缓存上栽跟头的人。
1. t3code 是什么:一个全栈类型安全实验项目的自白
1.1 这个项目本来想解决什么问题
我手头一直有一个内容管理类的业务想法:用户登录后可以创建短笔记,给笔记打标签,按标签筛选,还能分享给匿名访客查看。听起来简单,但真正动手时容易陷入一个经典困境——前端写接口联调时,后端说"字段名我改一下",前端拿到返回数据才发现类型对不上;数据库表加了一列,API 层忘了暴露,前端又开始猜。
t3code 就是冲着这个问题去的。我当时给项目定下三条硬性指标:第一,从数据库到页面组件的类型必须全程打通,中间任何一环都不能有"手写 interface 碰运气"的情况;第二,鉴权逻辑必须和业务代码解耦,不能每个接口都重复判断"用户是否登录";第三,项目要能一键部署,最好不用单独维护一台服务器。
T3 Stack 几乎是为这三条指标量身定做的。Next.js 负责页面和 API 路由的托管,tRPC 负责端到端类型安全的 RPC 调用,Prisma 负责数据库模型和迁移,NextAuth.js 负责 OAuth 和会话管理,Tailwind 负责样式。这套组合最大的特点不是"某个单一框架有多强",而是它们之间几乎没有类型缝隙。
1.2 T3 Stack 家族成员与分工
很多人听到 T3 Stack 第一反应是"又多了一个新框架",实际上它不是什么新东西,而是一套经过社区验证的"默认推荐组合"。这里的 T3 来自 Theo 的 T3 社区,核心哲学是"类型安全优先、约定优于配置、减少样板代码"。
具体到我 t3code 项目里的分工是这样的:
- Next.js(App Router):负责应用的整体骨架,包括页面路由、布局、服务端渲染和客户端渲染的切换。版本用 14+,App Router 模式下服务端组件和客户端组件的边界非常清晰,但这个边界也给我挖了不少坑,后面专门讲。
- TypeScript:不说了,整个 T3 的基石。严格模式从第一天就开着,
noUncheckedIndexedAccess这类严格选项也一并启用。 - Tailwind CSS:样式方案。它和 tRPC、Prisma 没有直接关系,但选它是因为开发效率高,尤其是在快速迭代业务页面的时候。
- tRPC:这是整个 T3 的灵魂。它让你在客户端直接"调用"服务端函数,不需要手写 REST 路由、不需要手写请求封装、不需要定义 DTO。类型直接从服务端函数签名传到客户端 hook。
- Prisma:ORM。提供类型安全的数据库客户端,schema 文件是唯一的数据模型来源,迁移文件可追溯。
- NextAuth.js:鉴权。负责 GitHub/Google 等 OAuth 登录,也支持 Credentials 登录。它和 tRPC 的 context 整合后,每个 procedure 都能拿到当前用户信息。
这套组合里,我最看重的其实是 Prisma 和 tRPC 之间的衔接:Prisma 返回的类型可以直接作为 tRPC 的输出类型,前端拿到数据后自动获得类型提示,根本不需要在 API 层再做一层"类型转换"。这种体验一旦适应了,再回去写传统 REST + 手写类型,会非常痛苦。
2. 为什么类型安全值得牺牲一点“自由度”
2.1 类型安全的收益边界
一定有人会说:类型安全好是好,但写起来啰嗦,小项目不值得。这话放到一年前我可能点头,但 t3code 做到中期后,我彻底改变看法了。小项目确实可以靠个人记忆维护接口结构,但项目一旦超过三四个页面、七八个接口,记忆就会开始骗你。
类型安全的真正收益不在于"写的时候少出错",而在于改的时候敢下手。比如我在 t3code 里把笔记的content字段从String?改成String @default(""),Prisma 类型变了,tRPC 的输出类型跟着变,前端使用note.content的地方如果有非空判断逻辑,TypeScript 编译器会在毫秒内告诉我哪些地方需要调整。没有类型安全的话,这种改动只能靠全局搜索加人肉测试。
当然,收益有边界。类型安全管不住业务逻辑错误,管不住缓存不一致,也管不住别人写了个any绕过检查。所以需要团队约定守住底线,而不是把一切交给编译器。t3code 里我给自己定的规矩是:禁止any,除非有极其明确的理由并写注释说明;禁止在客户端单独定义与 Prisma 模型重复的 interface,需要展示类型时用Pick、Omit或RouterOutputs派生。
2.2 一个真实的联调事故复盘
我想用一个具体例子说明类型安全在 t3code 里帮我省了多少事。有一次我调整了标签筛选逻辑,数据库里Tag表增加了一个color字段,用于前端展示标签颜色。这个改动涉及 Prisma schema、一个 tRPC procedure、三个前端组件。如果走传统开发流程,我会:先改表结构,再改后端接口返回,再跑一次 Postman 看响应,再打开前端页面验证。整个过程至少往返五六次。
用 t3code 这套链路,我只改了三处代码——schema、router、组件——然后让 TypeScript 编译器带我走一遍所有报错点。有一个组件差点漏掉color的样式绑定,编译器在switch语句的穷尽性检查里直接给了我一个红色波浪线,因为我没有处理新增的color枚举值。
所以我的结论是:类型安全的钱,不要在初期省。它改变的不只是编译器体验,而是整个项目的演进方式,从"小心弈棋"变成"大胆重构"。
3. 从 create-t3-app 到第一个业务模块的搭建步骤
3.1 脚手架初始化与项目结构拆解
t3code 的初始化没有手写配置,直接用了官方脚手架。这个选择没什么好纠结的,create-t3-app 本身就是社区维护的标准入口,能保证所有依赖版本兼容,而且可以交互式选择需要哪些模块。
npm create t3-app@latest t3code交互选项里我勾选了 Next.js、Tailwind CSS、tRPC、Prisma、NextAuth.js,数据库先用 SQLite 跑通流程,后面再切 PostgreSQL。脚手架生成后的核心目录结构是这样的:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts │ │ ├── notes/ │ │ │ ├── page.tsx │ │ │ └── new/page.tsx │ ├── pages/api/auth/[...nextauth]/route.ts │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ ├── routers/ │ │ │ │ ├── note.ts │ │ │ │ └── tag.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── react.tsx │ └── styles/ │ └── globals.css这里我建议第一次接触 T3 的人不要急着改目录,脚手架生成的_app.tsx、trpc/react.tsx和server/api/trpc.ts已经形成了一套完整的运行链路。你最初只需要往routers/目录里加文件,然后在root.ts里注册 router 即可。
3.2 用 tRPC 写第一个后端接口
在传统 REST 项目里,写一个"创建笔记"的接口要经历路由定义、请求体校验、业务逻辑、响应格式化至少四步。tRPC 把前三步压缩成了一个函数。我在 t3code 里第一个完整的 procedure 长这样:
// src/server/api/routers/note.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const noteRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ tag: z.string().optional(), limit: z.number().min(1).max(50).default(20), }) ) .query(async ({ ctx, input }) => { return ctx.db.note.findMany({ where: input.tag ? { tags: { some: { name: input.tag } } } : undefined, take: input.limit, orderBy: { createdAt: "desc" }, include: { tags: true, author: { select: { name: true } } }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().optional(), tagIds: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.note.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: { connect: input.tagIds.map((id) => ({ id })) }, }, }); }), });注意到protectedProcedure和publicProcedure的区别了吗?前者在 context 里强制要求session.user存在,未登录请求会在进入业务逻辑之前直接被拒绝。这就是我开头说的"鉴权逻辑解耦"——每个接口不再需要自己判断用户是谁,只在真正需要用户信息的地方选用受保护的过程。
3.3 Prisma 数据模型与迁移
在 t3code 里,Prisma schema 是唯一的数据模型事实来源。笔记和标签的多对多关系,加上用户的一对多关系,在 schema 里长这样:
model User { id String @id @default(cuid()) name String? email String? @unique notes Note[] accounts Account[] sessions Session[] } model Note { id String @id @default(cuid()) title String content String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User @relation(fields: [authorId], references: [id]) authorId String tags Tag[] } model Tag { id String @id @default(cuid()) name String @unique color String @default("gray") notes Note[] }改完 schema 后执行迁移:
npx prisma migrate dev --name init这里有个细节值得强调:不要在prisma migrate dev之后手动改数据库。t3code 的开发过程中我有一次为了测试临时在 SQLite 里加了条记录,导致后续迁移的 shadow database 校验失败,折腾了半小时才理清。正确做法是任何表结构变化都走 Prisma Migrate,哪怕只是个测试字段。
4. 开发中必须注意的三个边界:RSC、鉴权与缓存
4.1 服务端组件与 tRPC 的协作边界
App Router 模式下,Next.js 默认所有页面组件是服务端组件(RSC)。服务端组件里不能使用 tRPC 的 React Query hooks,因为它们依赖客户端状态和事件系统。这个限制第一次遇到时很让人困惑:明明 tRPC 是"端到端类型安全",怎么在服务端组件里就用不了?
实际上有两种解法。第一种是把组件拆成两层:外层服务端组件负责数据获取,内层客户端组件负责交互。tRPC 官方推荐的是这种"尽量在服务端拿数据"的做法,因为它能减少客户端请求次数。第二种是用 tRPC 的 server caller 在服务端直接调用 router:
// src/app/notes/page.tsx import { api } from "~/trpc/server"; export default async function NotesPage() { const notes = await api.note.list({ limit: 20 }); return <NotesList initialData={notes} />; }~/trpc/server这个入口在脚手架里已经封装好了,底层通过createCaller在服务端执行 procedure,不需要走 HTTP。客户端组件里再配合 tRPC 的 React Query hooks 做后续的分页、筛选、乐观更新。
我的建议是:首屏数据尽量用 server caller 拿,交互触发的数据变更用客户端 hooks。如果一刀切全用客户端请求,首屏会有明显的 loading 状态;如果全用服务端拿,分页这种交互体验又不好。这种分层不是 T3 特有的,但 t3code 让这个决策变得很自然,因为两端用的同一套类型。
4.2 NextAuth 会话接入 tRPC 的细节
鉴权边界是 T3 项目里最容易出问题的地方。NextAuth 的 session 默认通过 HTTP 请求的 cookie 解析,而 tRPC 的 context 创建发生在每个请求进来时,所以要把二者桥接好。
t3code 的server/api/trpc.ts里核心逻辑是:
import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; import { db } from "~/server/db"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db, session, ...opts, }; };然后在定义 procedure 时分别导出:
const t = initTRPC.context<typeof createTRPCContext>().create(); export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }); });这个中间件模式的优雅之处在于:protectedProcedure后面的所有逻辑都默认用户已登录,ctx.session.user.id可以直接用。如果哪天要加权限角色判断,只需再包一层adminProcedure就够,不需要改动任何业务 router。
一个我踩过的坑是NextAuth 的session.user类型默认不包含id。你需要在server/auth.ts里扩展类型:
declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }否则编译器会在authorId: ctx.session.user.id这里报错。
4.3 缓存失效策略与数据一致性
tRPC 本身不负责 HTTP 层缓存,它和 React Query 配合时,缓存的核心是 query key。t3code 里我遇到过典型的"创建一条笔记后,列表页不刷新"的问题。原因很简单:useMutation成功后没有让相关的 query 失效。
解法是在 mutation 的onSuccess里调用apiUtils的invalidate:
const utils = api.useUtils(); const createNote = api.note.create.useMutation({ onSuccess: async () => { await utils.note.list.invalidate(); }, });这里有个性能细节:invalidate默认会使所有note.list的 query key 失效,不管参数是什么。如果列表支持分页、筛选,这样会全部重新请求。更精细的做法是只失效具体 key,但实际项目中全量失效往往更稳妥,因为列表数据之间的关联很难被精确追踪。我的经验是:写多读少的场景可以精确失效;读多、筛选条件复杂的场景直接全量失效,配合 React Query 的staleTime控制请求频率即可。
另外,静态页面和服务端渲染的组合容易产生另一个坑:如果某个页面用generateStaticParams做了静态生成,里面用 server caller 获取的数据会被固定在构建时。内容变更后页面不会自动更新。t3code 里笔记详情页我一开始做了静态化,结果发完新笔记访问旧页面还是 404,排查半天发现是数据没更新。后来我改用动态渲染,只在确实不需要实时更新的页面使用静态化。
5. 部署和性能实测:从本地到生产环境的完整记录
5.1 部署方案选择:Vercel 与自托管的分界
t3code 这类 Next.js 全栈项目,默认首选是 Vercel,因为它对 Next.js 的适配最无缝。Prisma 在 Vercel 上需要配置好数据库连接串,部署时环境变量一定不能漏。我的.env.production里至少要有:
DATABASE_URL="postgresql://..." NEXTAUTH_SECRET="..." NEXTAUTH_URL="https://your-domain.com" GITHUB_ID="..." GITHUB_SECRET="..."选择自托管的场景我也认真考虑过:如果你需要 WebSocket 长连接、需要后台常驻任务、或者有很强的磁盘读写需求,serverless 平台的限制会很痛苦。t3code 这类笔记应用其实用不到这些,所以 Vercel 的 serverless 函数足够。
但自托管并非只是"跑起来一台 Node 服务器"那么简单。你需要处理日志聚合、进程守护、反向代理、证书轮转。如果你没有现成的运维经验,我不建议第一个全栈项目就上自托管。
5.2 性能数据与优化手段
t3code 上线后我做了几轮性能测试,重点看三块:首屏加载、接口响应、数据库查询。基于 Lighthouse 实测数据(模拟 Moto G Power 设备,Slow 4G 网络),优化前和优化后的对比如下:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| LCP | 3.2s | 1.8s |
| TBT | 0.7s | 0.2s |
| /api/trpc/note.list 响应 | 220ms | 90ms |
主要的优化手段有三个:
- Tailwind CSS 的 JIT 特性让生产环境 CSS 文件只有十几 KB,这块没什么额外工作,但要确保
NODE_ENV=production时 Tailwind 正确清除未使用样式,别在开发模式看性能数据。 - tRPC 查询的 React Query 缓存减少了重复请求:首次进入页面后,返回再进入同一页面,数据直接从客户端缓存恢复。
- Prisma 查询优化:给
Note.authorId和关联表的外键加了索引,分页查询从全表扫描变成了索引扫描。在数据量还不到一万条的前提下,响应时间下降了约 60%。
5.3 监控与错误上报
部署只是起点。t3code 上线后,我接入了 Sentry 做错误监控,这不仅因为量多,还因为未捕获的 Promise rejection在客户端很难主动发现。Sentry 的 Next.js SDK 可以自动捕获 tRPC 的报错堆栈,前提是你在 tRPC 的 onError 回调里手动上报:
import * as Sentry from "@sentry/nextjs"; const t = initTRPC.context<typeof createTRPCContext>().create({ errorFormatter({ shape, error }) { Sentry.captureException(error); return shape; }, });这个细节很多教程没提:tRPC 内部默认返回标准化的错误结构,如果不接入 errorFormatter,Sentry 拿到的是空堆栈。加了之后,Zod 校验错误、数据库异常、未知错误都能按 procedure 名聚合,定位问题非常快。
6. t3code 后续的扩展方向
项目做到这个阶段,功能本身已经很完整了,但类型安全这条路还可以继续往下走几步。我在实际开发中尝试了两个方向,都觉得挺有意思。
第一个方向是把 tRPC 路由通过trpc-openapi暴露成 OpenAPI 文档,给第三方客户端或非 TypeScript 项目提供 REST 接口。这听起来和 T3 的"类型安全优先"有点矛盾,但在真实业务里几乎一定会遇到:移动端团队用的是 Dart,或者你需要给外部合作伙伴提供 Webhook。tRPC 的本地调用体验不能寄托在别人的技术栈上,此时生成 OpenAPI 规范是最优雅的出口。
第二个方向是引入 Drizzle ORM 替代 Prisma,通过 Raw SQL 执行复杂查询。Prisma 的类型安全在简单的 CRUD 上很舒服,但遇到统计报表、多级关联聚合这类场景时,它的 API 会变得笨重。Drizzle 的 SQL 模板更贴近原生 SQL,又依然有类型推导。create-t3-app 现在已经把 Drizzle 作为可选项了,我在新的 toy project 里也尝试了这种组合,感觉在复杂查询场景下确实更顺手。
这两个方向都不是"必须做"的,但它们都指向同一个结论:T3 Stack 不是一个封闭的框架,而是一套可以按需调整的工程习惯。你不需要对每个组件都忠诚,关键是保持类型在数据链路上尽量长的流动。
最后分享一个我在 t3code 里养成的小习惯:每次修改 Prisma schema 之后,除了执行迁移,我会顺手在 t3code 目录下跑一遍npm run typecheck。这个命令是脚手架自带的,它一次性检查整个项目的 TypeScript 类型。很多人在改完数据库字段后只盯着自己刚写的那些代码看,结果漏掉了另一个文件里已经用旧字段的地方。让编译器帮你做全局扫描,比你自己瞪大眼睛找要可靠得多。类型安全的长处就在于这里——它不让你记住所有细节,而是让工具记住细节,然后在你犯错的瞬间提醒你。