首页 / 资讯中心 / 文章详情

T3 Stack 全栈实践:从类型安全到 tRPC 与 Prisma 的落地避坑指南

T3 Stack 全栈实践:从类型安全到 tRPC 与 Prisma 的落地避坑指南 ★ FEATURED ARTICLE
一个偶然的机会我在技术社区里看到一个批处理任务“t3code”原本以为是某个工具链的缩写点进去才发现别人口中的 T3 Stack——前端圈里讨论度一直很高的那套组合方案。我顺着这个话题把自己的一个个人项目推倒重来了一遍前后花了差不多两星期把 Next.js 全栈应用从 REST API 换成了 tRPC 的端到端类型安全写法收获意外地大。写完这一轮我特别想把这套选型逻辑、实操细节和踩过的坑一次性整理清楚。如果你是一个已经在用 React 或 Next.js 写过几个项目、但还没试过全套 TypeScript 化开发的人或者你厌倦了前后端各写一遍类型定义、联调时老是对不上字段那么这套 T3 技术栈值得你认真看一遍。这篇文章我会从一个实际项目“t3code”的完整落地过程出发讲清楚 T3 Stack 每个环节为什么这么选、核心代码怎么写、上线部署会撞上哪些问题以及我个人在实操中总结出来的避坑经验。1. T3 Stack 到底是什么一套把类型安全推向极致的全栈方案1.1 不是框架是一套经过取舍的组合拳T3 Stack 并不是一个像 Next.js 或 NestJS 那样的“框架”它更像一套社区公认的组合方案。核心成员是TypeScript、Next.js、tRPC、Tailwind CSS、Prisma以及可选的 NextAuth现在叫 Auth.js。这套方案的灵魂只有一个词——类型安全。说得直白一点从数据库到 API 再到前端组件数据在每一层传递的时候TypeScript 的类型都是自动推算、紧密相连的中间不需要手写任何接口文档也不需要额外生成代码。我第一次用 tRPC 的时候最大的感受是原来前后端联调可以这么“无聊”。以前写 REST API我要先定义接口路径、请求方法、参数结构然后写一份接口文档给前端同事前端照着文档手工声明类型。中间但凡某个字段命名不一致运行时才会报错。现在用 tRPC前端直接像调用本地函数一样去调用后端方法参数类型和返回类型全都从后端函数定义里自动推导出来。前端字段写错了编译期 TypeScript 就直接标红根本到不了联调环节。当然这不是说 T3 Stack 是解决一切问题的银弹。它最适合的场景是全栈应用逻辑集中在一个代码仓库里、前后端由同一个团队甚至同一个人维护、对类型安全有较高要求。如果你的场景是需要开放 API 给第三方、或者前后端团队完全隔离各自独立部署那传统 REST 或 GraphQL 可能仍然更合适。1.2 为什么每个选型都是“少为了配置折腾”T3 Stack 的创造者 Theo 在提出这套方案时有一个非常重要的原则“不要为了技术而技术每引入一个工具都必须能解决一个真实痛点。” 在这个原则下每个成员的入选理由都非常实在。先看 Next.js它是目前 React 全栈框架里生态最成熟、部署最方便的选择。App Router 的 Server Component 和 Server Actions 让很多事情在服务端完成但 tRPC 依然保留着自己的位置因为不是所有逻辑都适合放进 Server Actions有些交互性强的数据获取和变更用 tRPC 的 hooks 更顺手。再看 Tailwind CSS它保证你不会为了 CSS 文件命名和样式隔离折腾。配合组件库如 shadcn/ui几乎可以把精力全部放在业务逻辑上。Prisma 则解决了数据库层的类型问题定义好 schema 后它会自动生成完整的 TypeScript 类型数据库表结构变更时类型跟着变误用旧字段立刻报错。最后是 tRPC 这个胶水层。它不像 REST 那样需要定义 URL 路由也不像 GraphQL 那样需要维护一份 schema。它只是把你后端写的函数暴露给前端调用而这一切的魔法来自于 TypeScript 的 import 类型推导。很多第一次接触 tRPC 的人会疑惑这算 API 吗其实算它只是用另一套机制完成了 API 的工作。1.3 我的真实看法这个组合的价值在于“减少心智负担”我有不少朋友一开始觉得 T3 Stack 太“重”了因为又要学 tRPC、又要学 Prisma、又要学 NextAuth感觉每一层都是新东西。但我自己踩过几轮之后发现前期多花的一天学习成本会在后面省下至少一周的联调时间。举一个很简单的例子以前我一个人做全栈项目后端改了一个返回字段名前端组件里可能还有两三个地方引用旧字段。搜索替换不是问题问题是有时候你忘记了还有遗漏。用 tRPC 之后这种问题彻底消失了因为类型系统会精确到每一个字段改了一处所有引用它的地方全部亮红你必须一个个改完代码才能编译通过。这种“编译器当守卫”的体验一旦适应就很难回去了。这也是为什么我后来把个人项目里的 REST 接口都重构了一遍。如果你现在正在犹豫要不要把技术栈切到 T3我的建议是先拿一个小项目练手感受一下类型安全带来的顺畅感再决定要不要全面铺开。2. 搭建 t3code 项目从 create-t3-app 到一个可上线的应用2.1 初始化与目录结构解读创建 T3 项目用的是官方脚手架命令很简单npm create t3-applatest这个命令会进入交互式命令行让你选择要启用哪些模块。我的建议是第一遍尝试时把 Prisma、tRPC、NextAuth 全部勾上Tailwind 默认勾选。哪怕你暂时用不到鉴权先让整套体系跑通后面加功能会更顺手。如果一开始就关掉某些模块等需要的时候再手动加反而要在多个配置文件里补代码更麻烦。初始化完成后你会得到一个非常清晰的目录结构。和普通的 Next.js 项目相比最显著的两个区别是src/server/目录专门存放服务端代码包括 tRPC 路由、数据库操作、鉴权配置。src/app/目录是 Next.js App Router 的应用目录组件和页面都放这里。这种按“端”划分目录的方式特别重要。它从物理层面强制你区分服务端代码和客户端代码避免把数据库操作直接写在组件里。注意App Router 的 Server Component 里不能使用 hooks也不能调用浏览器 API。如果你以前习惯在组件里直接 fetch现在要改一下思路——服务端组件可以直接调用 tRPC 的服务端方法或者通过 tRPC 的serverClient来做数据预取。初始化完成后执行npm run dev就可以在浏览器里看到启动页面。这个页面会检查你的环境变量是否配置齐全、数据库是否成功连接是一个很好的体检工具。2.2 数据层设计与 Prisma 建模t3code 这个项目的核心功能是“链接收藏夹”用户可以保存链接、添加标签、标记已读。这三张表的模型其实很有代表性基本覆盖了常见业务的一对多关系和枚举字段用法。我们先看 Prisma schema 的核心部分model Link { id String id default(cuid()) url String title String description String? read Boolean default(false) tags Tag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String user User relation(fields: [userId], references: [id]) } model Tag { id String id default(cuid()) name String links Link[] }这里有几个细节值得展开说。第一个是 ID 的生成方式我选的是cuid()而不是自增整数。在分布式部署或者未来做数据迁移时cuid 不需要中心化的自增逻辑不会出现冲突。第二个是关系字段Prisma 会自动生成Link和Tag之间的多对多关系你不用手动写中间表。写完 schema 后执行npx prisma migrate dev --name init这个命令会做两件事生成迁移文件并且自动生成 TypeScript 客户端。之后在代码里你可以这样操作数据库import { db } from ~/server/db; const links await db.link.findMany({ where: { userId }, orderBy: { createdAt: desc }, include: { tags: true }, });注意这里的查询结果类型是完全自动推导的你不需要写任何 interface 来描述links数组的结构。数据库表结构变了这里的类型就会跟着变省掉的重复类型定义非常多。新手最容易犯的错误是在开发初期反复改 schema 然后直接改数据库没有用迁移工具。这会导致本地数据库和迁移历史不一致后期部署到生产环境时迁移失败。正确做法是每次改完 schema 都执行npx prisma migrate dev它会自动帮你处理变更历史。2.3 为什么先设计 schema 再写业务代码我在 t3code 项目里吃到一个教训一开始没想清楚数据模型匆匆写了几个页面组件结果后面为了改表结构连带改了十几个文件。后来我养成了一个习惯任何全栈项目都从数据层开始设计。数据模型是整个应用的骨架。用 Prisma 的 schema 文件来设计好处是它自带可视化编辑器的支持也能在改动后即时生成类型。你可以先定义核心实体之间的关系再想想每个实体的字段和约束最后才开始写页面。这样做还有一个额外好处当你需要和 AI 编程工具或者同事协作时schema 文件是所有人理解业务逻辑的最佳切入点。它比文档更精确比代码更简洁。如果你的项目有人接手第一件事一定是打开 schema 文件而不是浏览页面。3. 核心实现让前后端像同一个程序在说话3.1 从首个 tRPC Router 开始写法与调用链路tRPC 的核心概念是 Router你可以把它理解为一组可调用的后端函数集合。在 t3code 项目里我定义了一个linkRouter用来处理链接的增删改查。看一段实际代码import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const linkRouter createTRPCRouter({ getAll: protectedProcedure.query(async ({ ctx }) { return ctx.db.link.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: desc }, include: { tags: true }, }); }), create: protectedProcedure .input(z.object({ url: z.string().url(), title: z.string().min(1) })) .mutation(async ({ ctx, input }) { return ctx.db.link.create({ data: { url: input.url, title: input.title, userId: ctx.session.user.id, }, }); }), });这段代码有几层值得拆解。首先是protectedProcedure它和普通的publicProcedure最大的区别是在执行前会检查会话状态未登录用户直接拒绝访问。这个来自 NextAuth 的会话对象同时被塞进了ctx你在后面的数据库操作里可以直接拿到ctx.session.user.id而session的类型也是经过完整定义的。其次是输入校验。tRPC 和 zod 是标配组合z.object({...})会在运行时校验前端传来的参数同时把校验后的类型自动合并到input里。前端调用的代码能直接获得参数类型和返回类型的提示完全不需要手动声明。在页面组件里调用这些接口也非常直接import { api } from ~/trpc/react; function LinkList() { const { data: links, isLoading } api.link.getAll.useQuery(); if (isLoading) return div加载中.../div; if (!links) return div暂无数据/div; return ( ul {links.map((link) ( li key{link.id}{link.title}/li ))} /ul ); }这里的api.link.getAll.useQuery()看起来像是本地函数调用但它实际执行的是网络请求。tRPC 在内部帮你处理了请求序列化、反序列化、缓存管理以及类似 React Query 的自动重新请求。你不需要配置任何请求路径、请求方法或者错误处理代码比手写 fetch 再用一堆 hooks 包裹要省事得多。3.2 把鉴权接进来NextAuth 与 tRPC 的联动t3code 的用户系统用的是 NextAuth现在叫 Auth.js。在 T3 Stack 里NextAuth 的集成被做到了“开箱即用”的程度。初始化时脚手架会生成一个完整的 auth 配置你需要做的只是往schema.prisma里加入对应的Account、Session、User、VerificationToken模型然后执行迁移。不过新手在这里最容易遇到一个隐蔽的坑TypeScript 默认的session.user类型里并没有id字段。你数据库里明明有id但代码里写ctx.session.user.id却会报错。原因是 Auth.js 默认只往 session 里塞name、email、image三个字段id需要你在配置里手动补齐。解决方案是在src/server/auth.ts里扩展类型declare module next-auth { interface Session { user: { id: string; name?: string | null; email?: string | null; image?: string | null; }; } }然后在callbacks.session里把token.sub也就是数据库里的用户 ID塞进 sessioncallbacks: { session({ session, token }) { if (session.user) { session.user.id token.sub ?? ; } return session; }, },这样扩展之后整个项目里所有引用session.user.id的地方都能正确推导类型。这个方法我是在踩了一次编译错误之后才真正理解的如果你在搭建自己的项目时遇到了类似问题优先检查一下类型扩展有没有写对。3.3 Server Components 下如何优雅地用 tRPCApp Router 时代的 Next.js 推荐使用 Server Components在服务端直接取数渲染。但 tRPC 的 hooksuseQuery只能在客户端组件里使用两者之间需要一座桥。T3 脚手架的解决方式是生成一个serverClient实例。看这段代码import { serverClient } from ~/trpc/server; export default async function HomePage() { const links await serverClient.link.getAll(); return LinkList initialData{links} /; }在服务端组件里通过serverClient直接调用 tRPC 方法拿到数据后再传给客户端组件作为初始状态。客户端组件用useQuery获取同一条数据时会立刻复用初始状态避免重复加载。这种模式和传统的 SSR 数据预取思路一致但写法简洁得多。需要注意的一点是serverClient方法调用时的ctx和客户端调用时的ctx并不完全一致。服务端调用的入口没有经过 NextAuth 的运行时包装所以你在服务端获取会话时需要手动处理。脚手架生成的createTRPCContext已经处理了大部分情况但如果遇到“服务端取不到 session”的问题优先检查headers()相关代码看看是不是请求头信息没有被正确传递。4. 实战中的常见问题与排查记录4.1 升级 App Router 后最容易踩的 5 个坑T3 项目跑起来是一回事跑到生产环境稳定运行又是另一回事。我在 t3code 项目上线前后排查了不少问题下面这 5 个是最有代表性的。第一个坑是“在 Server Component 里用了 hooks”。我没有仔细看组件默认是服务端渲染直接把api.link.getAll.useQuery()写进了服务端组件结果运行时报错说 hooks 只能在客户端组件里使用。解决办法有两种把这些调用迁到子组件并标记use client或者改用服务端组件里的serverClient。第二个坑是“客户端组件没法直接用环境变量”。如果你有NEXT_PUBLIC_前缀以外的变量在客户端代码里引用打包后会变成 undefined。这是 Next.js 的安全机制不是 bug。第三个坑是“NextAuth 的 middleware 没有配置 matcher”。如果你设置了中间件保护某些路由但忘记配置matcher中间件可能根本不会执行页面也会绕过鉴权。第四个坑是“编辑.env后没重启开发服务器”。Next.js 会把环境变量缓存起来你改了.env后必须完全停掉npm run dev再重新启动否则新变量不生效。这个坑很蠢但几乎每个开发都撞过。第五个坑是“Prisma 查询没有 select 导致返回大对象”。在getAll里我一开始直接返回整个Link对象结果数据结构里带着各种时间戳和外键前端用不到这么多字段还容易在响应里泄露内部信息。后来我显式使用了select或include控制返回字段只给前端需要的。我将这些高频问题整理成一个速查表方便你对照排查症状可能原因解决办法组件内报 hooks 相关错误在 Server Component 使用了 hooks子组件添加use client改用服务端取数session.user.id类型报错缺少 next-auth 模块类型扩展在src/server/auth.ts中添加 interface 扩展环境变量值为 undefined客户端代码引用了非NEXT_PUBLIC_前缀变量改用服务端传参或添加NEXT_PUBLIC_前缀中间件不生效未配置 matcher 或文件名位置不对确认src/middleware.ts存在并配置matcherPrisma 连接超时数据库连接池过小或网络不稳定调整连接池配置或改用代理连接数据库迁移失败schema 变更未生成迁移文件执行npx prisma migrate dev生成迁移4.2 部署到 Vercel 的实战记录t3code 最后部署在 Vercel 上数据库用的免费版 Postgres 服务。这一套组合的部署体验总体很顺但有几个细节值得注意。第一件事是环境变量。Vercel 的 Dashboard 里可以配置环境变量你需要把数据库连接串、AUTH_SECRET、AUTH_URL等逐一填进去。AUTH_SECRET是 Auth.js 用来加密会话数据的密钥你可以用npx auth secret生成一个千万别用弱口令。第二件事是数据库迁移时机。建议在本地执行npx prisma migrate deploy推到生产数据库而不是在 Vercel 构建时自动跑迁移。原因是迁移过程可能会触发连接数暴增导致免费数据库被限制。更稳妥的做法是代码里用prisma migrate deploy手动执行一次应用启动时只负责查询读写不负责改表结构。第三件事是连接串的配置。如果你直接用宿主提供的连接字符串通常会有连接池限制。Prisma 在 Vercel 上跑无服务器函数时每来一个请求都创建一个数据库连接显然不现实。这时候用连接池转发服务或者给连接字符串加上pgbouncertrueconnection_limit1这类配置就能大幅减少连接占用。部署完成后我还会用 Vercel 的日志面板检查函数运行日志。像“数据库请求超时”“会话验证失败”这类错误都能在日志里直接看到调用链比本地调试更直观。4.3 避坑技巧批量操作与事务边界最后分享一个我在 t3code 里用到的高级技巧批量导入链接时的数据库事务处理。当你需要一次性导入 100 条链接时逐条调用create虽然能跑但速度慢且中途失败会产生半截数据。Prisma 支持用transaction包裹批量操作保证要么全部成功、要么全部回滚。更实用的写法是createManyconst result await ctx.db.link.createMany({ data: importedLinks.map((link) ({ url: link.url, title: link.title, userId: ctx.session.user.id, })), });这种写法在数据量不大时性能非常可观而且逻辑清晰。要注意createMany不支持嵌套创建关联实体如果你需要在导入链接时同时创建标签关联就得退回普通的transaction循环。我在做批量导入功能时先用了createMany快速插入基础数据再单独处理标签关系两边互不影响。提示在使用createMany时Postgres 支持skipDuplicates参数可以跳过数据库里已经存在的唯一键冲突记录避免整个导入因为一条重复数据而失败。5. 开发工具与调试技巧让 t3code 项目更好维护5.1 写类型安全代码之前先给编辑器配上好装备T3 Stack 的体验 90% 依赖 TypeScript 的自动补全和类型提示。如果你还在用纯文本编辑器看代码那等于浪费了这套技术栈最强大的优势。我自己的习惯是 VS Code 加prisma-language-server扩展它会识别.prisma文件并提供 schema 级别的补全和错误提示。另一个很有用的调试手段是 tRPC 开发时的 HTTP 日志。在src/server/api/trpc.ts里可以加一个简单的onError回调把所有抛出的错误打印到服务端控制台。开发环境下前端看不到的后端异常会在终端里直接显示定位问题效率翻倍。5.2 用脚本完成数据清洗与自动化任务t3code 项目后期我加了一个数据导入脚本可以批量从浏览器书签导出文件里读取链接然后写入数据库。这种任务用 Prisma 的独立脚本模式很合适配置好tsx就能在 Node 环境直接跑 TypeScript 文件不需要启动整个 Next.js 服务器。这类脚本对项目的维护价值在于它把一次性的脏活变成了可重复执行的工具。下次想回滚数据、重新导入或者批量修改字段直接在命令行跑一下就好不用在页面上手动点。6. 一次完整的代码审查视角什么算“写得好”的 T3 项目从 t3code 项目里我总结出了评价一个 T3 项目是否“写得好”的几条标准也算是对这篇文章的收尾。第一看 Router 是不是“薄”。好的 tRPC Router 应该只做参数校验和调用服务具体业务逻辑放在独立的 service 文件里。我见过有人把几百行数据库操作全塞进 Router 函数里虽然能跑但可维护性极差。第二看 schema 设计是否合理。合理的模型边界会让你后续加功能时少改很多表结构。第三看客户端代码是否干净。如果组件里塞满了useQuery然后再手工拼接数据多半是后端查询结构没有设计好。我在做 t3code 的这轮重构里个人最深的体会是类型安全的力量不是让你“不会写错代码”而是让你“敢大胆改代码”。当你有一个完整的类型链条从数据库铺到前端组件的时候你对变更的恐惧会显著下降。重构接口、迁移字段、调整权限这些以前让人提心吊胆的操作现在变成了一个跟着编译器走的过程改完编译通过心里就有底了。如果你也想尝试一套完整、端到端类型安全的的全栈开发方案与其看一堆教程不如直接创建一个项目把 Prisma 的 schema 写完再让 tRPC 把一个接口跑通你会立刻感受到这种开发方式的与众不同。往后你再回头看那些手写 REST 接口、手工维护类型的日子大概率会和我一样不想回去了。
阅读完成 · 觉得有帮助?
咨询建站