尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

T3 Stack全栈实战:Next.js+tRPC+Prisma构建类型安全应用

T3 Stack全栈实战:Next.js+tRPC+Prisma构建类型安全应用 最近把手里一直在迭代的 t3code 这个项目重新梳理了一遍借这个机会把整套基于 T3 Stack 的实战经验完整记录下来。这不是一篇官方文档的翻译而是我从零搭建这个全栈项目时真实走过的路选型时的纠结、目录结构的设计、tRPC 的端到端类型体验、Prisma 和 Next.js 在服务端组件里的边界问题还有部署阶段踩过的那些坑。如果你正准备用 Next.js 搭配 TypeScript 写一个全栈应用或者已经在用 create-t3-app 起步但卡在某个环节想看看别人的做法这篇文章应该能给你一份可以直接抄作业的参考。1. 项目整体设计与思路拆解1.1 t3code 到底是什么t3code 是我基于 T3 Stack 构建的一个全栈项目仓库核心目标是验证和落地一个类型安全贯穿前后端的现代 Web 应用形态。T3 Stack 这个词最早来自 create-t3-app 这个脚手架生态标准的组合是 Next.js TypeScript Tailwind CSS tRPC如果涉及数据持久化还会加上 Prisma 作为 ORM 层。为什么要把这几个东西绑在一起因为它们的共同点是类型TypeScript 给前端变量标类型tRPC 让 API 调用和函数调用一样有完整的类型推导Prisma 让数据库表结构直接变成 type-safe 的查询模型Next.js 则负责把整条链路串成一套可以部署的完整应用。我选择用 t3code 这个名字一方面是因为它简洁好记另一方面也想表达T3 code这个组合的语义——它不是某个现成产品的名字而是这套开发范式在真实项目里的落地形态。那 t3code 解决了什么问题呢说直白一点现在很多全栈项目最大的痛点不是功能写不出来而是前后端接口的对不上后端改了个字段名前端还拿着旧字段在渲染接口文档更新了代码里还是老参数。传统项目靠人肉同步接口文档t3code 的思路是从类型层面把这条链路焊死让编译期就能发现大部分接口变更带来的问题。这一点在项目规模变大以后收益是非常明显的。1.2 为什么用 T3 Stack 而不是其他方案在定技术选型的时候我其实对比过几套常见方案。第一套是Next.js REST API 手写类型的组合这也是很多团队在用的方式。ECMAScript 层面的 fetch 调用加上 Zod 校验确实能把类型安全做到 80 分但问题在于每个接口都要手写请求函数、手动标注入参出参类型一旦接口数量上去了这部分样板代码会变得非常冗长。更难受的是当你新增一个字段时要同时改后端定义、改前端调用处的类型、可能还要改 mock 数据维护成本是成倍增长的。第二套是 GraphQL 方案类型安全确实强但服务端的 schema 设计和 resolver 拆分对团队要求比较高对小项目来说有点杀鸡用牛刀的感觉。T3 Stack 的优势在于渐进式tRPC 不需要你维护独立的 schema 文件它直接利用 TypeScript 的 typeof 推导把 router 里的输出类型传给前端。你在后端写好一个 procedure前端的 useQuery 立刻就有完整的入参和返回值提示。这个体验用一句话概括就是写接口和写本地函数没什么区别。当然这套方案也有代价tRPC 强依赖 TypeScript 的编译环境前端和后端必须在同一个 monorepo 或至少共享类型定义。但对于 t3code 这个项目来说这本来就是我想要的形态——一个整体性很强的全栈应用而不是拆成多个服务再互相调用。1.3 项目的核心模块规划t3code 的功能定位不是一个空壳范例而是一个带真实业务逻辑的参考实现。我规划了三个核心模块用户认证模块使用 NextAuth.js 接入 GitHub OAuth同时提供邮箱密码登录的备选方案会话状态通过 tRPC 的 procedure 做鉴权保护。内容管理模块一个典型的 CRUD 场景支撑笔记/链接收藏这类实体的创建、更新、删除和检索用于验证 Prisma 的模型设计和 tRPC 的 mutation 链路。数据统计模块展示 Prisma 的聚合查询能力也验证服务端渲染SSR配合 tRPC 的读取模式是否足够顺滑。模块之间通过 Prisma 的关系模型关联走的是最小完整闭环的思路认证保护数据、CRUD 操作数据、统计展示数据每个环节恰好覆盖这套技术栈最重要的能力点。2. 核心细节解析与实操要点2.1 Next.js App Router 下的目录组织在 t3code 里我采用的是 Next.js 14 的 App Router 模式目录结构没有完全照搬脚手架默认布局而是做了微调。整体结构是这样的t3code/ ├── prisma/ │ ├── schema.prisma │ └── seed.ts ├── src/ │ ├── app/ │ │ ├── (auth)/ │ │ ├── (dashboard)/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── server.ts │ │ ├── client.ts │ │ └── react.tsx │ └── styles/ └── package.jsonApp Router 的 Route Group 我用了两组(auth)存放登录注册相关页面(dashboard)存放需要登录后才可见的主体业务页面。这样做的目的不是花架子而是让路由文件的组织逻辑和权限边界保持一致放进(auth)的页面默认走公开布局放进(dashboard)的页面统一在外层 layout 里做会话校验。这里有个经验值得说一下App Router 的layout.tsx是嵌套生效的你完全可以在(dashboard)/layout.tsx里统一做鉴权判断而不需要每个页面重复写。比如用一个服务端函数读取 session如果为空就redirect(/login)这样底下的所有子路由都自动被保护。2.2 tRPC 的 router 组织和类型推导tRPC 是整个 t3code 最核心的环节它的 router 组织方式直接影响后期维护体验。我采用的方式是按业务域拆分 router再汇总到根 router。以笔记模块为例在src/server/api/routers/note.ts里定义了一组 procedureexport const noteRouter createTRPCRouter({ list: protectedProcedure .input(z.object({ cursor: z.string().optional() })) .query(async ({ ctx, input }) { // 分页查询逻辑返回带 cursor 的数据集合 }), 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, }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { // 先校验所有权再执行删除 }), });然后汇总到src/trpc/server.ts的appRouter里export const appRouter createTRPCRouter({ note: noteRouter, user: userRouter, stats: statsRouter, });前端调用时就变得非常直接const utils api.useUtils(); const { data } api.note.list.useQuery({ cursor: undefined }); const createMutation api.note.create.useMutation({ onSuccess: () utils.note.list.invalidate(), });注意我在 mutation 成功后调用了invalidate()这个动作会让 tRPC 自动重新拉取对应的 query从而刷新列表数据。这是 tRPC 相比手写 REST 的一个很实用的特性不需要手动管理缓存刷新时机数据变更后由 invalidation 机制兜底。在实际使用中我最喜欢的是前端调用处可以直接看到返回对象的所有字段提示连数据库返回的时间类型、nullable 的处理都是自动推导的。这种体验一旦习惯就很难回到打开文档查字段名字对不对的时代了。提示如果你在新增 procedure 后发现前端类型没有同步先检查tsc编译是否通过再看 IDE 的 TypeScript Server 是否需要重启。tRPC 的类型推导依赖编译期的整体检查缓存容易造成明明改了却不生效的错觉。2.3 Prisma 模型设计与数据访问模式t3code 的数据库用的是 PostgreSQLPrisma 作为 ORM。我在设计 schema 时严格遵循了最小字段 明确关系的原则没有为了展示技术而堆砌冗余字段。一个值得分享的点是Prisma 的模型关系是类型推导的基石字段名和关系名的定义要稳定。你一旦上线后改动关系字段可能连带所有关联查询都要调整所以设计阶段就要考虑清楚哪些字段是核心标识、哪些是可扩展信息。我的 User 模型和 Note 模型是这样的关系model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] notes Note[] } model Note { id String id default(cuid()) title String content String? createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String user User relation(fields: [userId], references: [id]) }在数据访问模式上我特别提醒自己避免在 tRPC 的 query 里写过于复杂的多表关联逻辑。复杂查询要么拆成多次简单查询在内存里组装要么直接用原始查询特性。Prisma 的 API 写起来很舒服但过度依赖嵌套查询会让生成的 SQL 变得低效性能问题在数据量上去后会很快暴露出来。2.4 认证体系与受保护路由的实现认证模块用的是 NextAuth.js在 t3code 里我使用了一种两步鉴权的模式。第一步是会话层认证通过getServerSession()在服务端读取当前会话判断用户是否登录。这个判断放在两个地方一个是(dashboard)/layout.tsx里做整块区域的路由保护另一个是在每个 tRPC procedure 的protectedProcedure中间件里做接口级别的保护。protectedProcedure 的实现看起来是这样的const protectedProcedure createTRPCProcedure() .use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { user: ctx.session.user }, }, }); });第二步是数据归属校验。比如删除笔记时不能只验证登录了就行还要验证这条笔记是不是当前用户的。这个逻辑放在 procedure 里因为前端任何请求都要经过这里是真正意义上的安全边界。有个坑是 NextAuth 的 API 路由和 App Router 的 Server Action 在某些版本里会互相干扰回调路径。在 Next.js 14 的项目里我建议把 auth 的配置单独拆成模块并且确认NEXTAUTH_URL这个环境变量在生产环境一定不能留空否则登录回调会莫名其妙地失败。3. 实操过程与核心环节实现3.1 从 create-t3-app 开始到项目结构定型实际搭建过程我是用一个冷启动命令初始化的npx create-t3-applatest t3code交互式选项我选了 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth.js这些组件组合在一起就是完整的 T3 Stack。注意 Next.js 的版本选项会涉及 App Router 或 Pages Router这里选 App Router因为 tRPC 的 server caller 机制在新版里对 App Router 支持更友好。初始化完成后我做的第一件事不是写业务代码而是调整目录结构和配置文件。我给 t3code 定了几条基线路径别名统一为src/前缀保证所有 import 路径简洁且不会乱飞。关闭strict以外的多余 TypeScript 配置不依赖any绕过类型。如果某个地方不得不用 any我会在代码里加一行注释说明原因方便后续有其他开发者接手时能快速理解。tsconfig.json里额外加了baseUrl和路径映射让深层次的 import 不至于变成../../../地狱。这一步看似琐碎但对后期效率影响巨大。很多人拿着脚手架直接开写写到一半发现目录乱得没法看改一个模块要翻五六个文件夹那体验非常糟糕。3.2 核心链路实现一次完整的数据创建流程我用笔记创建这个场景来展示 t3code 里一条完整的业务链路是怎么串起来的。前端页面首先通过api.note.create.useMutation()拿到 mutation 方法在表单提交时触发调用。tRPC 把它包装成一个看起来像本地函数的异步操作const createNote api.note.create.useMutation({ onSuccess: () { utils.note.list.invalidate(); router.push(/notes); }, onError: (err) { // 这里可以直接读取 err.message 展示给用户比如标题不能为空 }, });这个 mutation 请求会经过 Next.js 的 API 路由脚手架默认会在app/api/trpc/[trpc]/route.ts挂载 tRPC 的 HTTP 处理层然后进到 noteRouter 里的createprocedure。中间件先把 session 取出来确认用户身份接着 Zod 校验输入参数最后通过 Prisma 写入数据库返回新创建的笔记对象。整个过程从浏览器到数据库再到浏览器唯一需要你手写的地方就是后端 procedure 和前端调用处。没有手写 API 路由没有手写 fetch 封装没有手动设置请求头——因为 tRPC 把这些都抽象掉了。我在这个流程里还加了一个小细节返回给前端的日期字段直接用了DateTime类型前端渲染时统一用Intl.DateTimeFormat做格式化。这样保证时间显示不会因为用户时区不同而出现偏差也不用在接口层额外做字符串转换。3.3 服务端渲染与 tRPC 的搭配方式很多人会问tRPC 不是偏向客户端调用的吗那服务端渲染的页面怎么办t3code 的做法是利用 tRPC 的 server caller在服务端直接调用 routerimport { createCaller } from ~/trpc/server; export default async function DashboardPage() { const caller createCaller({ session: await getServerSession() }); const notes await caller.note.list({ cursor: undefined }); return NoteList initialNotes{notes} /; }这样做的优势是首屏数据在服务端就准备好了客户端不会经历加载中的空状态同时因为调用的是同一个 router类型不会缩水。我在实践中发现如果你的页面需要加载完成后再触发某些客户端事件Server Component 和 tRPC 的组合会比较微妙因为服务端拿到的数据无法直接传入客户端 effect 的依赖数组。一个比较省心的方案是服务端渲染负责首屏静态数据客户端交互需要的数据再通过单独的 query 获取两者互不干扰维护起来也清晰。3.4 环境变量与部署配置部署这块一直是 T3 项目最容易被忽视却最容易出问题的环节。我的 .env 文件是这样的最小集合DATABASE_URLpostgresql://user:passwordhost:5432/t3code NEXTAUTH_SECRETsome-long-random-string NEXTAUTH_URLhttps://your-domain.com注意几点第一NEXTAUTH_SECRET不能跟开发环境一样用明文方便记忆生产环境必须是足够长的随机字符串。第二如果你的部署平台支持可以考虑用AUTH_TRUST_HOST相关配置来处理反向代理场景下的回调域名校验不然登录回调容易被拦。第三Prisma 需要在部署流程里显式执行prisma migrate deploy这不是npm run build会自动完成的。我在 CI 流程里加的是这样的部署前检查步骤lint → typecheck → prisma generate → prisma migrate deploy → build。每次部署前类型检查过不了就直接阻断这比上线后出 Bug 再补救舒服太多。4. 常见问题与排查技巧实录4.1 tRPC 类型不更新或推导失败这个是我在 t3code 开发过程中遇到最频繁的问题。症状是后端新增一个 procedure前端调用处却报不存在或者旧的类型提示一直残留。排查步骤可以按这个顺序来先执行tsc --noEmit确认是不是真的类型错误。再检查src/trpc/react.tsx里是否导出了正确的 API hooks特别是createTRPCReact是否在客户端组件里正确初始化。最后看 IDE 报错的是不是缓存的 TypeScript Server。尤其是 VSCode 在 monorepo 或 pnpm 环境下特别容易吃旧缓存我一般用 Restart TypeScript Server命令解决。如果确认是缓存问题而不是代码问题后面就很少再被这种假报错耽误时间了。4.2 Next.js 服务端组件里使用 tRPC 报错如果在一个 async Server Component 里直接用api.note.list.useQuery()会直接报错因为useQuery是 React hooks 规则的一部分服务端组件根本不允许调用 hooks。解决方式就是我前面介绍的createCaller模式这也是官方推荐的服务端数据读取方案。还有一种情况是表单服务端提交时也用 caller 直接执行 mutation这其实是不推荐的。t3code 里我保持了一个边界Server Component 只读写入操作一律走客户端 mutation。这个原则帮我规避了大量找不到 useContext之类的诡异错误因为 tRPC 的 React context 只在客户端存在。4.3 Prisma 连接数过大导致查询超时这个问题是在压测统计接口时暴露出来的短时间大量并发请求下数据库连接池被打满部分查询直接超时。排查之后发现两个原因一是 Prisma 默认连接池大小需要结合部署平台的连接上限调优二是 tRPC 的 query 在客户端被频繁重复触发导致数据库请求数量被成倍放大。我的调整方式是在 Prisma client 初始化时显式设置连接池参数比如针对方针数据库连接上限是 10 的环境把connection_limit调成 5。同时在前端加上了 tRPC 的staleTime配置让短时间内的重复请求直接命中缓存不再打到数据库。双管齐下之后接口响应时间和数据库负载都降下来了。4.4 登录回调地址与代理环境的问题开发环境一切正常部署到生产后登录页跳转总是失败这是 NextAuth 在反向代理后面最典型的坑。表现是登录完成后回调地址里检查了 hostname 和端口发现和配置不一致就拒绝。我的解决思路分两层。第一层是把NEXTAUTH_URL配置成用户访问的完整域名而不是内部服务地址。第二层是在中间件层面统一处理 X-Forwarded-Proto 之类的请求头让 NextAuth 能正确识别用户是走 HTTPS 还是 HTTP 进来。还有一个小细节如果你用的是托管平台的 preview 域名记得要把那个域名也加进允许回调的列表里避免环境切换就卡登录。踩过几次坑之后我的体会是T3 Stack 并不是把问题变简单而是把繁琐的接口同步和类型维护工作提前到编译期解决让你把精力花在真正的业务逻辑上。t3code 对现在的我来说已经不只是一个项目仓库更像是验证这套全栈范式可行性的一块实验田。如果你也想试试类型安全贯穿前后端的开发体验别犹豫太久直接像这样把环境配好写一个小功能模块感受一下 tRPC 带来的直觉式开发流就会明白我为什么愿意把这么多细节摊开来讲了。
返回列表