资讯详情

资讯详情

t3code:T3 Stack 端到端类型安全全栈开发实践

先聊点实际的。我第一次接触t3code这个名字是在我把整整半年折腾出来的 T3 Stack 项目沉淀成模板库那会儿。当时手头同时维护三个全栈项目每个项目的前后端联调都要花掉我大量时间后端改了个字段名前端等到编译时报错才发现文档写了十几页没人看也没人更新接口路径稍微一改整个调用链跟着乱。T3 Stack 就是在这种情况下进入我视野的而 t3code 是我对这套技术组合的个人工程化落地实践。这篇文章我会把从脚手架初始化、数据库设计、tRPC 路由、鉴权接入到生产部署的全过程拆开讲记录我实际踩过的坑和最后沉淀下来的方案适合已经会用 React 和 TypeScript、但还没系统性接触过 T3 这套组合的前端或全栈开发者也适合独立开发者想快速搭一个类型安全全栈项目时参考。1. t3code 是什么T3 Stack 背后的工程哲学1.1 先搞清楚 T3 到底在说哪三件事T3 Stack 这个名字很多人听过但它的定义其实一直在变。早期的 T3 指的是 The T3 Stack由 Theo 带火核心组合是 Next.js、TypeScript、tRPC 和 Tailwind CSS后来随着生态演进Prisma 和 NextAuth.js 也几乎成了默认成员。T3 里的“T”其实源自 Tier 3意思是“足够高但又不过度复杂的三层架构”——不是简单地把技术堆在一起而是围绕一个核心诉求组织代码端到端的类型安全。t3code 这个词在社区里有不同理解有人拿它当 T3 项目的代名词也有人像我一样把它当成一套可复用的工程实践模板。我更倾向于后一种理解。T3 天生不是一个“框架”它没有规定你必须用哪个 ORM、必须把文件放在哪个目录它只给了你一个骨架和一套原则。所以你在 GitHub 上搜到的 create-t3-app 模板、各种 t3-code 仓库本质上都是对这套原则的工程化落地。我的 t3code 实践库也是这么来的——把最顺手的配置、最不容易出错的目录结构、最稳妥的部署方案固化下来避免每次从零开始重复踩坑。传统全栈项目里前后端像是两个公司靠传真机沟通后端定义 JSON 结构前端靠文档和后端约定字段名类型全靠手写 DTO 或者硬编码“接口响应就是这个形状”。一旦响应结构变了最先崩溃的不是运行着的服务而是两边的信任。T3 解决的正是这个问题它把后端接口的类型直接暴露给前端字段改了IDE 里立刻报错。你可以把 tRPC 想象成在前后端之间拉了一根类型管道数据形状在这里是同一个 TypeScript 类型系统的两个视图。1.2 类型安全为什么成了稀缺品拿我自己曾经写过的例子来说。用户列表接口的响应是{ user: { id, name, email } }后来后端把user.name改成了user.username并且 SQL 查出来的列名也改了。在传统 API 模式下除了后端自己没人知道这个字段已经变了。Swagger 文档也许有人更新过也许没有前端代码里resp.user.name依然存在直到生产环境用户反馈“名字不见了”你才开始排查。你可能会说这不是有 TypeScript 吗问题是传统 REST API 的类型边界只存在于前后端各自的代码里跨过 HTTP 边界之后类型检查就断了。T3 的做法是把这条边界彻底打通。tRPC 在服务端定义 router 时输入输出的类型就已经被推导出来前端通过trpc.post.getAll.query()调用时返回类型的推断精确到字段级别。你改 Prisma schema 里的username字段跑一次prisma generate服务端的 tRPC router 类型变化前端调用处马上跳出红色波浪线。省掉的不是“写类型定义”的时间而是排查“类型文档和实际数据不一致”的时间。在实际项目里这种不一致才是真正浪费大头的隐性成本。对于没接触过 tRPC 的朋友我打个比方。传统 REST 联调像两个部门用传真沟通你发过去的表格对方怎么填、字段名怎么定你只能在电话里反复确认。T3 里的 tRPC 更像同一个办公室共享一块白板你写上去的内容对方实时能看到而且大家用的是同一支笔、同一套格式。不是说你不需要理解白板上内容的含义至少格式不会再成为阻碍沟通的障碍了。1.3 这套东西适合谁不适合谁先说适合谁。我是做独立开发和二三十人规模小团队的全栈工程师手头项目数量多、业务变化快、没有专职 API 文档维护者。T3 特别适合这种场景它把“类型安全”往前移了一整个阶段团队里就算有人改字段忘了通知代码编译期就能暴露。其次适合已经有 React 基础和 TypeScript 基础的人你只需要补上 tRPC 和 Prisma 这两块新知识剩下的 Next.js 和 Tailwind 都是熟悉的面孔。那不适合谁呢如果你的业务需要一个被大量第三方调用的公开 REST APItRPC 并不是一个好的对外暴露方案它更适合作为应用自己的 BFF 层外部客户端跨网络调用不适用。另外如果你前端不是 TypeScript而是小程序原生 JS 或者某个老旧的 jQuery 系统端到端类型穿透就无从谈起tRPC 带来的收益会大打折扣。最后如果你的团队里没人愿意写 TypeScript那 T3 这套东西在你团队里大概率会变形最后变成“一个 Next.js 项目加了个没人用的 tRPC”。工具再好也得有会用、愿意用的人。2. 从脚手架开始初始化一个 t3code 项目2.1 环境准备与命令行细节我初始化 T3 项目用的是官方脚手架 create-t3-app它比你自己手动装 Next.js、tRPC、Prisma、Tailwind 再一个个配置要省下至少半天时间。而且难能可贵的是这个脚手架把很多容易出错的细节都处理好了tRPC 的 App Router 适配层、Prisma client 的全局单例、NextAuth 的 session 上下文接入全都帮你配置好了。你改的只是业务代码不用从零铺基础设施。环境要求很明确Node.js 18.17.0 及以上包管理器推荐 pnpm。命令长这样node -v # 小于 18.17 就去升级 pnpm create t3-applatest my-t3-code运行之后脚手架会给你一串交互式选项。我个人的选择是App Router默认、tRPC 选上、Prisma 选上、NextAuth.js 选上、Tailwind CSS 选上。注意有一个选项叫 Experimental features 或者类似的启用项比如Drizzle ORM和Valibot之类的新东西我建议新项目先不要过度追求新以稳定为主等 Drizzle 生态彻底成熟了你再切换成本也不高。脚手架跑完后它会提示你设置环境变量。别急着忽略DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL这三个变量是这个项目能不能跑起来的关键。不过更细的坑放在下一节这里只说一点你可以先照抄.env.example里的占位符但别直接把占位符当成可用的配置后面一定会出问题。2.2 生成后的目录结构拆解脚手架生成的目录结构里我重点讲四个地方。第一个是src/server/db.ts这是 Prisma client 的单例入口。它用了globalThis来缓存 client 实例避免在 Next.js 热更新时创建大量数据库连接。生产环境跑久了你会发现连接数爆掉很多时候就是没做这种单例处理。第二个是src/server/api/trpc.ts。这个文件是整个 tRPC 后端的核心工厂。它做了三件事创建 tRPC context、初始化 tRPC 实例、定义公共 procedure 和受保护 procedure。它的代码看一眼就懂const createTRPCContext async (opts) { const session await getServerSession(); return { db, session, ...opts }; }; const t initTRPC.contexttypeof createTRPCContext().create(); export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use( t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }); }) );第三个是src/app/api/trpc/[trpc]/route.ts。这是 tRPC 在 App Router 下的 HTTP 入口。如果你把这个文件放错目录或者手贱改了大括号里的[trpc]你会浪费几个小时去排查“为什么所有接口 404”。这个文件基本不需要改但你要知道它存在、负责什么。第四个是src/trpc/server.ts和src/trpc/react.tsx。前者是给服务端组件和 Server Actions 调用的 caller 封装后者是 React Query 的 Provider 和 hooks 封装。前端组件里trpc.note.getAll.useQuery()能用靠的就是react.tsx的封装。2.3 选型纠结Prisma 还是 Drizzle脚手架默认带的是 Prisma但确实有一股越来越多人转 Drizzle 的趋势。我在 t3code 里的实践是默认 Prisma但 Schema 设计要尽量摆脱 Prisma 的思维惯性。为什么这么说Prisma 的优点是对底层 SQL 做了高度抽象schema 文件就是你的数据库设计文档迁移工具也成熟团队上手快。缺点是遇到特别复杂的查询时它的 API 会写得很别扭性能调优也隔着层纱。Drizzle 更贴近原生 SQL写起来厚重但性能和可控性强TypeScript 也很完整。然而 Drizzle 在迁移工具、生态成熟度上目前还是有差距。我一般这样建议如果团队成员都有 SQL 功底又不喜欢黑盒选 Drizzle如果团队更看重开发速度、迁移安全和代码可读性选 Prisma。这个表格可以做参考对比维度PrismaDrizzle学习曲线平缓DSL 一致性好陡峭需懂 SQL查询能力强但复杂查询别扭强贴近原生迁移工具成熟有 migrate dev还在快速迭代类型安全很好极好适用场景默认推荐、中小规模团队SQL 老兵、性能敏感场景不管选哪个T3 的核心价值没变tRPC 给你端到端类型安全ORM 只是数据层的实现细节。DATABASE_URL指向同一个数据库router 结构完全一样变更的成本没有想象中那么大。3. 写一段真实的 tRPC CRUD以“用户笔记”为例3.1 数据模型先行从 schema 开始设计t3code 的开发流程我建议是“schema 优先”。先想清楚数据库表结构再写 router再写前端调用。因为类型安全的链条源头就在 Prisma schema 里schema 一变后面每一层的类型都会跟着变。拿一个用户笔记功能举例。我要一张只属于当前登录用户的笔记表。schema 写得简练一点model Note { id String id default(cuid()) title String content String? userId String createdAt DateTime default(now()) updatedAt DateTime updatedAt index([userId]) }这里有两个细节值得说。一是主键为什么用cuid()而不是自增Int。cuid 在分布式环境下不用担心并发生成冲突也避免从 URL 里暴露笔记数量的隐私问题。二是index([userId])如果你的查询里大量出现where: { userId: ... }没有这行索引数据量稍微上来一点就是全表扫描页面慢到怀疑服务器在南极。写完 schema 之后执行npx prisma migrate dev --name init生成迁移文件。如果 schema 有语法问题这一部就会暴露不用等到运行时。3.2 编写 tRPC Routerquery 和 mutation 怎么写才规范接下来是 tRPC 的 router。我一般把数据相关的 router 分文件放比如src/server/api/routers/note.ts然后在根 router 里聚合。先定义一个 query用来获取当前用户的笔记列表。这里我使用protectedProcedure因为笔记是隐私内容必须校验登录状态export const noteRouter createTRPCRouter({ getAll: protectedProcedure.query(({ ctx }) { return ctx.db.note.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1, 标题不能为空), content: z.string().optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.note.create({ data: { title: input.title, content: input.content, userId: ctx.session.user.id, }, }); }), });这里有一个新手容易犯的错误直接在 handler 里if (!ctx.session)然后throw。不是说不行但很啰嗦。protectedProcedure的 middleware 已经统一做了拦截路由里就能保证ctx.session.user一定存在。infer 出来的上下文里就带上了 session类型上它不会让你漏掉空值判断这就是类型穿透带来的安全感。mutation 里的.input()用了 Zod。T3 社区对记录保存方式有过一番纠结Zod 是最稳的选择。Zod 在这里承担两个职责运行时校验非法输入编译时推断精确类型。如果有一天你忘了给某个 mutation 加 inputTypeScript 会直接在 router 定义处报错因为你调用的那个 mutation 签名要求 input 必须存在。3.3 前端调用useQuery 与 useMutation 的联动干活前端调用邻居部分也很关键。t3code 项目里前端不会直接 fetch/api/trpc/note.getAll一切都是通过src/trpc/react.tsx里的封装调用它把 React Query 类型全部打通了。拿到笔记列表的代码是const { data: notes, isLoading } trpc.note.getAll.useQuery();新增笔记之后需要通过useUtils来刷新列表缓存const utils trpc.useUtils(); const { mutate: createNote } trpc.note.create.useMutation({ onSuccess: () { utils.note.getAll.invalidate(); }, });React Query 在 T3 里的角色是数据缓存和状态管理。invalidate()就像是你在刷新微信消息列表时下拉一下它会重新拉取getAll数据。如果不做这步新增的笔记不会出现在列表里你得手动刷新页面。这里有个经验对列表数据尽量用invalidate而不是直接操作缓存写数据。操作缓存写看似省了一次请求但数据一致性很容易在多次连续操作时出问题。invalidate虽然多一次网络开销但换来的是简单可靠。3.4 校验与错误信息打磨别让用户看到英文报错Zod 的错误信息默认是英文的而且字段路径是title: required这种不好看的格式。我建议给input的每个字段都写清楚中文提示比如上面代码里的标题不能为空。别偷懒一个团队里只要有一个人图省事不写某个深夜线上弹出一句 Invalid input 的验证错误你就得花十分钟去猜是哪个字段验证失败了。另外tRPC 的错误码和服务端 HTTP 状态不是一回事。tRPC 里UNAUTHORIZED对应 401NOT_FOUND对应 404但它并不会帮你自动处理前端提示。前端接错误的规范做法是const { mutate: createNote, error } trpc.note.create.useMutation();error.message里是服务端抛出的原始信息要展示给用户之前最好先用 Zod 的 errorMap 或自定义 message 做一层整理。你不希望用户看到内部实现细节也不希望他看到一段英文堆栈。4. 认证接入与状态管理session 是 T3 项目的命门4.1 NextAuth Prisma 的接入顺序T3 项目里认证方案一般是 NextAuth.js配合数据库 session 存储。脚手架已经配好了 NextAuth 的基本结构但真正要跑通有几个顺序问题。先设定环境变量。.env文件里必须有DATABASE_URLpostgresql://user:passwordlocalhost:5432/t3code NEXTAUTH_SECRET自己生成一串足够长的随机字符 NEXTAUTH_URLhttp://localhost:3000NEXTAUTH_SECRET特别关键。本地开发时不写NextAuth 会运行在一个开发模式但生产环境如果不写服务直接启动失败。生成一个随机 secret 的办法是openssl rand -base64 32别想着自己手打一串字符当成保险。接着如果有 OAuth 登录需求去 Google 或 GitHub 控制台申请GITHUB_CLIENT_ID和GITHUB_CLIENT_SECRET。这里要按 NextAuth 的 adapter 配置来。如果用了 Prisma 存 session你需要把 NextAuth 的PrismaAdapter接上去它会在数据库里自动建表用来存 User、Account、Session 和 VerificationToken。建表前要先执行prisma migrate dev否则运行时 Prisma 会报“表不存在”。4.2 protectedProcedure 到底保护了什么很多教程把 tRPC 的 protectedProcedure 当成“页面守卫”在讲但我更愿意把它理解成 API 守卫。它保护的是数据访问本身不是 UI 里某一条导航链接是否可见。前端页面你可以根据登录状态决定渲染什么但如果没有这一层 API 守卫任何懂一点网络请求的人都能绕过界面直接调你的接口。T3 的 protectedProcedure 是基于会话中间件的。会话不存在就会抛UNAUTHORIZED前端收到错误后自动跳转登录页。如果你只是在前端判断了登录态而接口没做保护等于把家门钥匙放在门口垫子下面运气不好就有人摸进去了。还需要注意一点服务端组件里调用 tRPC 也要走这个守卫。Next.js App Router 服务端组件里可以直接从数据库查数据但这不意味着你可以绕过 tRPC。如果你在服务端组件里直接ctx.db.note.findMany()那和 “全栈” 没关系了类型安全链条被切断后续维护会很难受。正确的做法是通过src/trpc/server.ts的createCaller调用 tRPC API让同一套中间件和类型系统在服务端也生效。4.3 中间件与 RSC 的边界Next.js 里middleware.ts经常被用来自动检查登录态比如未登录用户访问题问某个路径时直接重定向到登录页。T3 实践里建议middleware 只负责“页面级路由守卫”而且最好只处理无需数据库的场景比如检查 JWTsessioncookie 是否存在。因为 middleware 常常跑在 Edge Runtime不能访问 Prisma Client完全做不了数据库操作。在 App Router 模式下页面级别的守卫我更推荐在 Server Component 层面做。用一个protectedServerPage函数包装一下它内部调getServerSession()判断没有 session 就redirect(/login)。这样所有页面组件不用重复写守卫逻辑职责也更清晰层级职责用什么MW全局路径拦截、静态资源过滤Middleware JWTRSC页面级登录校验getServerSession redirecttRPC数据级权限控制protectedProcedureUI按角色显示不同交互组件内 useSession这个分层是 T3 项目里最难讲清楚的坑之一。很多新人把所有的拦截都放在 middleware 里结果有一天用 tRPC caller 在 RSC 里查数据库时发现 session 拿到的是空值因为 middleware 只是重定向了浏览器请求RSC 内部的数据流没走那套东西。把职责拆清楚这种问题才能从根源上避免。5. 部署与生产环境从 typecheck 到数据库迁移5.1 构建前先过类型检查T3 脚手架里的package.json默认提供了一个check脚本内容是next lint tsc --noEmit。但很多人部署时只跑next build完全忽略了这个check。Next.js 的 build 过程确实会做一部分类型检查但不是全量的很多跨文件的类型问题能漏过去。问题不大直到运行时才爆出来费时费神。我在 t3code 里的 CI 流程第一步就是跑pnpm check第二步再跑pnpm build。如果你用的托管平台支持多阶段构建把这两步拆开。如果你用 GitHub Actions参考这一段- name: Check Build run: | pnpm check pnpm build这只是一个很简单的示意实际跑起来你会发现check卡住的往往不是代码而是你写了一半的 schema 改动。这正是目的所在把问题挡在构建之前。5.2 数据库迁移策略dev 用 push生产用 deployPrisma 的迁移指令有几个容易混淆我这个坑踩得比较深。prisma db push是直接把 schema 同步到数据库适合开发前期飞快迭代。prisma migrate dev是生成迁移文件并应用适合已经进入稳定期。prisma migrate deploy是生产环境专用只执行项目的 migrations 目录下的迁移文件不会自动生成新迁移。t3code 的实践是隔离环境里用db push没问题但 CI 里也要遵守这个区分。我见过不少人在 CI 里用prisma db push结果迁移历史里记录缺失生产环境没法回滚一下把团队带进坑里。正确的生产部署流程是# 迁移步骤 npx prisma migrate deploy # 生成 client部署平台一般自动执行 npx prisma generate如果生产环境需要建表先本地或审查分支里执行npx prisma migrate dev --name your_change生成迁移文件提交到代码库CI 里跑migrate deploy应用它。这套流程是标准的“migration as code”思路避免直接手工改生产数据库。5.3 部署后的几个诡异故障部署之后我遇到过几个特别折腾的问题列出来供你对照。第一个是PrismaClientInitializationError十有八九是DATABASE_URL没设置。很多托管平台只把环境变量给到“生产环境”分组预览环境或一次性分支环境是空白的部署时就报错了。办法是在部署控制台把同一套环境变量复制全。第二个是数据库连接池耗尽。这件事在 serverless 平台上尤其明显每个请求都会创建新的 Prisma client如果没做单例缓存连接数会线性增长。即使做了单例PostgreSQL 默认连接数 100并发一旦上来了也可能爆。我在生产环境里给DATABASE_URL加上连接数限制参数DATABASE_URLpostgresql://user:passwordhost:5432/db?connection_limit5这样每个 serverless 实例最多占用 5 个连接极大降低压垮数据库的风险。第三个是 NextAuth 在生产环境提示 “Invalid check: jwt” 之类的问题。通常和 session 存储策略有关。如果你用的 Prisma 存 session检查是否把 NextAuth 的session: { strategy: database }配了。默认策略是jwt它不需要 database 表但你的PrismaAdapter又建了表两者不冲突但行为让人困惑。t3code 的默认建议是App Router 项目里用 JWT 策略能省掉一次数据库查询配合中间件也顺滑用了 database 策略并在 RSC 里读 session就会增加一次数据库查询但换来的可以是更可控的服务端 session 管理。用哪种没有绝对对错知道自己在干嘛就行。6. 常见问题排查与最终实践心得6.1 高频报错速查表报错/现象原因处理方式TS2339:Property id does not exist on type SessionNextAuth 类型声明没扩展创建types/next-auth.d.ts声明Session.user.idPrismaClientInitializationErrorDATABASE_URL未设置检查环境变量重启部署tRPC 所有接口 404[trpc]路由文件放错位置确保路径是src/app/api/trpc/[trpc]/route.tsTRPCClientError: UNAUTHORIZED未登录调用 protectedProcedure前端判断登录态后跳转登录页或检查 NextAuth 配置部署后白屏/Analytics 无数据缺少NEXTAUTH_SECRET生成并配置随机 secret数据库连接数满未做 client 单例或连接数过大用globalThis缓存 Prisma client加connection_limit6.2 我的几点取舍心得用 T3 这套组合三年多最大的感悟是不要被“必须全套使用”束缚。t3code 这个名字对我来说是一个工程实践库但它并不是一套强制的全家桶套餐。你可以在一个已有项目里只接入 tRPC把原来的 REST 接口逐步替换也可以不用 Prisma用 Drizzle 或直接写 SQL 查询。只要保留核心的“类型穿透”T3 的价值就已经体现出来了。反过来如果因为强制要求使用某个框架导致团队效率下降那就是舍本逐末了。比如我最近一个项目里并没有上全套 T3而是只用了 Next.js TypeScript tRPC因为那个项目的数据库是已有的遗留 Schema用 Prisma 重新建模反而碍手碍脚Drizzle 更贴近现有 SQL。但数据层的选择没有影响 tRPC 的端到端类型体验前端照样类型安全。这说明 T3 的骨架是解耦的你可以像拆乐高一样按需取用而不是当做一个黑盒式的全家桶。还有一个我特别想强调的点T3 项目要想保持舒服schema 改动频率必须被克制。你可以用 tRPC 做到改一个字段前端立刻爆红但如果你每天都在改字段那前端队友的体验会非常糟糕。类型安全给了我们安全感但也可以让人变得松懈觉得改字段很容易。我在 t3code 的规范里明确要求任何 schema 字段变更必须走一次“数据库迁移 tRPC 类型检查 前端调用点确认”三件套否则就不应该合入主干代码。6.3 往后还能怎么演T3 的生命力在于它始终在跟着整个 React 和 Next.js 生态演进。App Router 的 Server Actions 出来后有人觉得 tRPC 会被取代但我个人看法恰恰相反——Server Actions 更适合做表单提交这一类轻量交互而 tRPC 的 router 模式更适合做结构化、强类型的数据查询接口。实际项目里两者已经开始共存了有些团队用 Server Actions 做页面上的表单用 tRPC 做数据加载和跨组件共享状态。这张中间的接缝会越来越平滑值得持续观察。我最近也在试 Drizzle Valibot 的组合后者作为 Zod 的轻量替代在 bundle size 上的优势很有吸引力。如果你选的模板比较新create-t3-app 也已经有了 Drizzle 选项可以试试看。但不管底下的数据校验器换成谁tRPC 的核心模式“输入输出沿着 TypeScript 类型穿透到底”始终没有动摇。我之所以愿意一直跟进 T3 的生态变化恰恰是因为它的选型并不偏执。主流的方案在它这里都能有一席之地。最后分享一个小技巧。如果你在维护 t3code 类似的项目建议在每次 Prisma schema 改动后跑一遍npx prisma format npx prisma generate前者整理 schema 文件格式后者重新生成类型。很多人改完 schema 之后忘了跑generate接着就是满屏的 TS 报错。把这两条命令收进大脑肌肉记忆能帮你省下很多和一排排红色波浪线搏斗的时间。写代码这件事本质上是为了少写代码。类型安全就是帮你少写的那部分不是约束你的。T3 这套东西我用了这么久最大的收益不是“不出 bug”而是“出 bug 的时候知道去哪里查”这比什么都值。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →