资讯详情

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

📅 2026/10/28 15:26:00 | 华诺云谱 👁 阅读
T3 Stack 实战:Next.js + tRPC + Prisma 全栈类型安全
1. 为什么是 T3 Stackt3code 的整体设计思路1.1 T3 Stack 到底解决了什么问题先聊个实际感受。这两年全栈项目最让人头疼的不是某个框架学不会而是技术栈之间的“缝隙”太多。前端写接口请求要手拼类型后端写 API 要单独维护一套 interface数据库表结构变了前端代码里还留着一堆已经失效的字段跑起来全靠运行时碰运气。团队协作时前后端联调的时间成本甚至比写功能本身还高。t3code 这个项目核心就是冲着解决这类问题去的。T3 Stack 本身不是一个新框架而是一套经过验证的组合Next.js 做应用框架TypeScript 做类型体系tRPC 做端到端类型安全接口Prisma 做数据库访问NextAuth.js 做鉴权Tailwind CSS 做样式。这套组合最大的特点就是把“类型安全”从数据库一路贯通到页面组件。我在 t3code 里所有的代码都遵循一个原则数据从 PostgreSQL 里出来时是什么类型前端组件拿到的就是什么类型中间不需要任何人手写胶水代码。用生活里的例子类比过去做项目像是两个部门之间靠传真机沟通这边发一份文档那边照着敲一遍录入系统敲错了就出错T3 的方式是两边直接共用同一份电子表格改动实时同步格式永远一致。t3code 想要展示的就是这套“无传真机”协作模式在实际项目里的完整落地情况。1.2 t3code 的项目定位与模块划分我最初定 t3code 的时候就给它设了一个明确的定位本质上是一个带真实业务逻辑的全栈应用模板。它不是一个只跑通 hello world 的 demo也不是一个塞了几十个页面、看似丰富实则每个功能都只写了两三行代码的空壳。t3code 是一个可以拿去做二次开发起点的中台雏形里面包含用户注册登录、项目空间、API 密钥管理、仪表盘统计这几个高度贴近真实场景的模块。模块划分上我参考了领域驱动的思路但没上全套 DDD 那套复杂度。顶层只分了四个领域identity用户与鉴权、project核心业务实体、billing计费与配额、dashboard统计聚合。每个领域内再按“路由层 - 服务层 - 数据层”三层组织。为什么刻意分层因为 T3 Stack 生态里最容易出现的问题就是把所有逻辑写在 tRPC router 里。短时间看是爽一个文件搞定全部但项目超过两万行后这种写法会带来灾难性的可维护性负担。t3code 里的 router 只做参数校验和调用编排实际业务规则全部下沉到 service 层Prisma 的查询统一封装在 repository 层。这样每一层职责单一出问题时不用翻遍整个项目猜逻辑。这个设计也带来一个实际好处测试变得容易。service 层不依赖 Next.js 的请求上下文可以脱离 tRPC 直接做单元测试。我在项目里给 service 层写了大概 40 个测试用例覆盖了配额计算、密钥校验、项目权限判定等核心逻辑跑一次全量测试用不了几秒。2. 核心细节拆解t3code 的关键实现2.1 类型全链路从数据库到前端的类型传递t3code 最核心的体验就是你在后端定义好一个查询前端立刻能拿到完整的类型提示。这套能力的关键链路是Prisma Schema 定义表结构 - Prisma Client 生成数据库类型 - tRPC router 暴露查询 - 前端 useQuery 自动推断返回类型。拿 project 模块的“获取项目详情”功能举例。先在schema.prisma里定义模型model Project { id String id default(cuid()) name String slug String unique description String? ownerId String owner User relation(fields: [ownerId], references: [id]) apiKeys ApiKey[] createdAt DateTime default(now()) updatedAt DateTime updatedAt }然后 tRPC router 里写import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const projectRouter createTRPCRouter({ getBySlug: protectedProcedure .input(z.object({ slug: z.string().min(1) })) .query(async ({ ctx, input }) { const project await ctx.db.project.findUnique({ where: { slug: input.slug }, include: { apiKeys: { select: { id: true, name: true, lastUsedAt: true } } }, }); if (!project || project.ownerId ! ctx.session.user.id) { throw new Error(PROJECT_NOT_FOUND); } return project; }), });这里有个容易被忽略的细节include里用select限制了返回字段。很多人写 Prisma 查询时不注意字段裁剪把整个关联表全部查出来前端根本用不到那些数据白白增加网络负载和序列化时间。t3code 里每个查询都做了字段级裁剪这既是性能习惯也是类型收窄的好办法。前端使用时一切自动衔接const { data } api.project.getBySlug.useQuery({ slug: router.query.slug as string }); if (data) { console.log(data.name); // 类型推断为 string }不需要定义任何 API 响应类型也不需要在 API 层写返回值的 interface。tRPC 通过类型引用直接从 procedure 的返回类型推导出前端 hook 的泛型这个过程在编译期完成运行时零成本。实际开发时最大的感受是我几乎不需要再打开后端代码去看某个接口返回了什么字段编辑器里的自动补全直接告诉我答案。要做到这一点有几个前置条件必须满足严格使用 TypeScript 的strict模式、tRPC 的/(http|server)/路由必须与前端指向同一个类型源、.env里的DATABASE_URL必须在跑prisma generate前正确配置。任何一个环节断了类型链路就会变成断头路前端拿到的就会是 any 或者报错。2.2 鉴权与用户体系NextAuth 的集成细节用户体系是每个真实项目绕不开的环节。t3code 选了 NextAuth.js因为它与 Next.js 的集成最平滑并且 tRPC 有官方的适配方案。但 NextAuth 的默认配置只能解决“能登录”的问题真实项目还需要解决“登录后怎么知道是谁”的问题。t3code 里使用 CredentialsProvider 加 JWT 会话策略。注意一个关键设计JWT 的session.strategy设为jwt然后在callbacks.jwt里把数据库里的用户信息合并进 token接着在callbacks.session里把 token 里的用户对象暴露给 session。这一步很重要因为默认情况下 session 里只有用户 ID 和邮箱而你往往还需要头像、昵称、角色这些字段。callbacks: { jwt({ token, user }) { if (user) { token.id user.id; token.role user.role; } return token; }, session({ session, token }) { if (session.user) { session.user.id token.id as string; session.user.role token.role as UserRole; } return session; }, }另一个关键点是类型拓展。NextAuth 自带类型定义里没有session.user.id和role直接使用会报错。t3code 在src/types/next-auth.d.ts里声明了类型覆盖declare module next-auth { interface Session { user: { id: string; role: USER | ADMIN; } DefaultSession[user]; } interface User { role: UserRole; } }不写这个类型声明后续在 tRPC 的getServerSession里访问用户角色就全是类型错误模板项目最容易坑在这里。权限控制方面t3code 实现了两层守卫。第一层在路由层middleware.ts 里通过正则匹配受保护路径未登录用户直接重定向到登录页。第二层在业务层tRPC 里用protectedProcedure保证必须登录才能访问。两层缺一不可middleware 管页面访问体验tRPC 管数据接口安全。只做页面守卫而接口裸奔等于把你家大门锁了但窗户全开着。2.3 数据层设计Prisma Schema 的几个关键点Prisma 的 Schema 是后端的“宪法”它同时决定了数据库结构和生成的类型。t3code 里我在 Schema 设计上花的时间最多因为后期改表结构是成本最高的事。第一个观点是主键用 CUID 而不是自增 ID。自增 ID 有几种问题——一是暴露业务体量别人看你的订单号就知道今天卖了多少单二是分布式生成不方便三是合并数据库时容易冲突。CUID 没有这些烦恼生成也不需要等待数据库自增。第二个观点是关系字段的 onDelete 行为一定要显式声明。我见过很多次线上事故都是删除用户时没有级联删除关联数据导致外键约束报错或者脏数据残留。t3code 里这样处理model User { id String id default(cuid()) projects Project[] apiKeys ApiKey[] } model ApiKey { id String id default(cuid()) name String keyHash String unique projectId String project Project relation(fields: [projectId], references: [id], onDelete: Cascade) }关键在最后的onDelete: Cascade。这样删除 Project 时关联的 ApiKey 会自动清理。虽然 Prisma 默认对必填关系是Restrict会阻止删除有依赖的记录但在业务上用户删项目通常就是希望删干净而不是被报错挡住。第三个观点是把多租户隔离做在数据结构层面。t3code 里几乎所有业务表都有ownerId或projectId外键查询时强制带上归属条件。这不是数据库层面的强制约束但通过封装的 repository 层实现了“默认必带”的开发习惯从源头上避免越权读取。3. 从零到上线t3code 的实操过程3.1 初始化与目录结构规划创建项目用官方脚手架是最省事的。执行pnpm create t3-applatest t3code交互式选择时我勾选了 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth。架构选项我选了默认的 App Routerpages router 虽然代码示例多但 Next.js 新特性全在 app router 上现在新建项目用 pages router 属于给自己挖坑。目录规划是决定项目后期维护体验的第一件事。t3code 在脚手架默认结构上做了几处改造src/ components/ # 纯展示组件不碰数据和路由 features/ # 按业务域划分的功能模块 identity/ server/ # 登录注册相关 service components/ # 用户信息展示组件 project/ server/ # 项目 CRUD 的 service 与 repository components/ # 项目卡片、创建表单 server/ api/ # tRPC 根配置 db/ # Prisma client 实例 styles/features目录是额外加的。脚手架默认把所有组件平铺在components下业务多了以后非常混乱。按业务域聚合之后改一个功能时所有相关文件都在同一块区域心智负担小很多。server目录继续保留放跨域共享的服务代码。3.2 第一个完整功能从 schema 到页面的完整链路拿“创建项目”功能走一遍链路。不要跳过任何一步每一步都是后续项目的标准模板。第一步定义 Schema 并迁移。把上面那段 Project 模型写进schema.prisma然后执行pnpm prisma migrate dev --name init_project pnpm prisma generatemigrate dev会生成 SQL 迁移文件并执行generate会更新 Prisma Client 的类型这两条命令在执行顺序上有硬性依赖不能反过来。实际项目中我见过有人改完 schema 直接跑 generate 骗过类型检查但数据库里根本还没有表部署时直接崩。第二步写 service。在src/features/project/server/project.service.ts里定义 createProjectexport async function createProjectForUser(userId: string, data: { name: string; slug: string }) { // 检查 slug 是否唯一 const existing await db.project.findUnique({ where: { slug: data.slug } }); if (existing) { throw new ProjectError(SLUG_TAKEN); } const project await db.project.create({ data: { ...data, ownerId: userId, }, }); return project; }slug 唯一性检查这里有个细节即使数据库里有unique约束也建议在业务层做提前检查。数据库约束是最后防线业务层检查的意义在于能返回更友好的错误信息而不是直接抛出一个 500 数据库唯一约束错误。第三步接 tRPC router。把 service 包进 procedurecreate: protectedProcedure .input(z.object({ name: z.string().min(2).max(40), slug: z.string().regex(/^[a-z0-9-]$/), })) .mutation(async ({ ctx, input }) { return createProjectForUser(ctx.session.user.id, input); }),zod 的 input 校验和 service 的参数校验组成两层防护。zod 这层负责把不合法请求挡在业务逻辑之外比如 name 太短、slug 含有非法字符等service 这层负责处理业务规则比如 slug 重复。两者互不替代。第四步前端页面。创建表单组件用 react-hook-form 收集字段提交时调用 mutationconst utils api.useUtils(); const createProject api.project.create.useMutation({ onSuccess: () { utils.project.getMyProjects.invalidate(); router.push(/dashboard); }, });这里必须强调invalidate的用法。tRPC 的数据缓存是基于 key 的mutation 成功之后如果不清除或标记相关 key 的缓存刚创建的数据不会自动出现在列表页。很多人第一次用 tRPC 都在这卡过创建完项目回到列表页发现数据没更新刷新之后才出现。invalidate就是用来解决这个问题的。3.3 打包部署与性能优化要点部署平台我选了 Vercel不是因为它是唯一选择而是它和 Next.js 的兼容性最省心。但在部署前有几件事必须做。第一件环境变量与迁移策略。默认情况下next build不会自动跑数据库迁移。我的 CI 流程里加了一行pnpm prisma migrate deploy注意这里必须是migrate deploy而不是migrate dev。migrate dev会在迁移过程中生成新的迁移文件并提示你输入名称这在 CI 环境里会挂死。deploy只负责将现有迁移文件应用到目标数据库不会生成新文件这才是生产环境该用的命令。第二件数据库连接池。直接使用DATABASE_URL建立连接在 Serverless 环境下容易耗尽连接数。t3code 的 Prisma Client 初始化时使用 Prisma 官方推荐的连接池方式const globalForPrisma globalThis as unknown as { prismadb: PrismaClient }; export const db globalForPrisma.prismadb ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error] : [error], }); if (process.env.NODE_ENV ! production) globalForPrisma.prismadb db;核心思想是开发环境热更新时会反复重新执行模块初始化如果不把 Prisma Client 挂到 globalThis 上每次 hot reload 都会创建多个新连接最终把本地数据库的连接数打满。生产环境则不需要担心这个问题但还是要控制实例数量。第三件静态页面生成策略。不是所有页面都需要实时数据。t3code 的营销页和文档页用了export const dynamic force-static让 Next.js 在构建时直接生成静态 HTML访问时走 CDN 缓存后端零计算。只有真正依赖用户数据的页面dashboard、项目详情才保留动态渲染。这一策略组合下来页面首次加载速度有明显提升。实际测试中静态页 TTFB 从 200ms 左右降到 20ms 以下用户体验感知非常明显。4. 实战中的坑与排查心得4.1 类型报错里最常见的三个根因tRPC 写完代码一编译就报一堆类型错误这种场景我几乎天天遇到。排查下来大部分问题就三个原因。根因一Prisma 更新类型没重新生成。改了 schema但忘了跑prisma generate。这会导致ctx.db.project对应的类型还是旧结构你新加的字段在类型层不存在。解法很粗暴先跑pnpm prisma generate再重开 IDE 的 TS Server。VS Code 经常不会自动加载新生成的类型CtrlShiftP执行 “TypeScript: Restart TS Server” 可以解决。根因二tRPC procedure 的 input 类型和实际入参不匹配。我踩过一个很隐蔽的坑zod schema 里字段是coerce.number()前端传的是字符串虽然运行时能过校验但 TS 类型推导出来是 number前端传 string 时编辑器会哗哗报错。凡是遇到 z.input 和传参类型不一致先去看有没有被z.coerce或者z.preprocess改变类型。根因三NextAuth 的 session 类型覆盖没生效。这个前面提过了不写next-auth.d.ts的类型声明所有使用session.user.id的地方全是报错。而且 NextAuth 类型拓展的文件必须被 tsconfig include 到否则声明文件就是一张废纸。4.2 鉴权失效与 session 过期排查线上收到“用户明明登录了却显示未登录”的反馈是个比较经典的问题。排查路径通常是有先后顺序的。第一步确认 session 是否真的存在。在受保护路由里加日志打印getServerSession的结果。如果这里返回 null说明浏览器侧的 session cookie 没有带到服务器。看看是不是前后端域名不匹配或者 cookie 的 secure 属性在 http 环境下被浏览器拦截。第二步确认 JWT 是否过期。token 的 session 策略下JWT 默认有效期是 30 天。但如果用户长期不访问token 过期之后第一次访问会重定向登录页体验不好。t3code 里我做了滑动过期处理只要用户有活跃请求就刷新 token 的过期时间。实现思路是在 JWT callback 里检查剩余过期时间如果低于阈值就重新签发。第三步查 middleware 和 tRPC 的 session 来源是否一致。这是个容易忽略的点。Next.js 的 middleware 运行在 Edge RuntimetRPC 里的getServerSession运行在 Node Runtime两者读 cookie 的方式并不完全一样。如果你在 middleware 和 tRPC 里用了不同的 session 校验逻辑很容易出现“页面能进但接口 401”的问题。t3code 里两边统一走 NextAuth 的getToken保证判断口径一致。4.3 部署后的数据库连接与迁移问题本地一切正常部署到线上却疯狂报数据库连接错误这是新手最容易破防的场景。排查方向集中在两块。连接串问题。本地开发用的localhost:5432在云端肯定不通。上生产之前在服务器上先跑一次pnpm prisma migrate status如果连不上会直接报错这样能快速确认网络链路和账号权限有没有问题。另外连接串里不要带?schemapublic以外的乱七八糟参数有些参数在 Prisma 里会造成诡异的连接失败。迁移状态问题。部署后如果发现数据库表结构不对最常见的原因是你新加的 migration 没提交到仓库或者 CI 里根本没有执行migrate deploy。建议在 CI 流程里把迁移和构建串成一条链先migrate deploy成功后再next build。这样因为表结构缺失导致的构建错误会在 CI 阶段暴露而不是用户访问时才炸。连接池大小问题。线上流量稍微上来一点数据库连接数就开始告警。除了前面说的 global 实例方案还可以在 Prisma Client 初始化时限制连接数new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL, }, }, })如果跑在 Vercel 的函数环境里每个函数实例都会创建自己的 Prisma Client连接数会随着实例数叠加。有些团队会选择把 Prisma Client 放到核心包或者改用连接池代理但这会引入额外运维复杂度。t3code 在项目初期没有用代理只通过限制 Vercel 函数并发数来控制峰值连接数在量级没上来之前够用。5. 一点实际体会把 t3code 从想法到落地整个过程走完后我最大的感受是T3 Stack 的价值不是帮你少写几行代码而是帮你少操几类心。类型系统把接口对接的事从“运行时调试”提前到“编译期检查”tRPC 把前后端沟通成本压到最低Prisma 让数据库操作既有类型提示又有迁移管理。这套组合的威力要在超过一定体量的项目里才能体现出来小项目反而不容易感受到差异。最后分享一个细节习惯。我在开发 t3code 时给所有 tRPC router 的 procedure 加了统一的错误处理包装自定义错误类会映射成自定义 code前端根据 code 决定弹什么提示。否则默认情况下tRPC 会把所有异常都变成INTERNAL_SERVER_ERROR用户看到的永远是“Something went wrong”根本没法做精细化错误提示。这个习惯后来带到了所有项目里协作效率提升了一个档次。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑