T3 Stack实战:tRPC与Prisma构建类型安全全栈应用

发布时间:2026/10/28 6:59:56
T3 Stack实战:tRPC与Prisma构建类型安全全栈应用 t3code这个标题我盯了好一会儿。如果你在开发者社区泡过一阵子看到t3大概率会联想到T3 Stack——Theo那套Next.js TypeScript tRPC Tailwind的组合拳。但真正上手做完整项目之后你会发现网上铺天盖地都是真香天花板这类口号真正讲清楚这四样东西怎么拧成一股绳、在哪里会卡壳的文章反而很少。这篇就把我过去几个月用t3code我给自己这套全栈模板起的代号从零搭一个中型业务系统的过程完整复盘一遍包含技术选型逻辑、目录组织、tRPC数据流设计、Prisma建模、认证集成以及部署阶段踩过的几个坑。适合已经入门Next.js但想把整套类型安全开发体验落到真实项目中的人也适合正在纠结要不要引入tRPC的团队参考。1. 为什么选T3这套组合它不是框架是一种工程纪律先把这个说清楚T3 Stack不是某个框架create-t3-app只是给你一个脚手架真正的核心是一种类型安全优先的工程理念。我当初选它不是因为好看而是被跨端类型不同步这个问题折磨够了。1.1 类型安全优先到底解决了什么痛点传统全栈开发里前端调接口是这么个流程后端写路由、定义请求响应结构、前端根据文档或者Postman里拷出来的样例手写类型、联调时发现字段名对不上、改完一处忘改另一处。CRUD一多这种隐性成本非常可观。T3的思路是既然前后端都是TypeScript那就让编译器帮我们保证两端契约一致。tRPC在这里充当了类型管道——你在服务端定义好procedure客户端import进来调用时入参类型、返回类型、错误类型全部自动推断。字段改名、函数签名变动编译期直接报错不用等联调再发现。我用t3code实际跑下来最直观的感受删掉一个数据库字段Prisma类型变了tRPC返回类型跟着变前端组件里用到这个字段的地方全部标红一次改完。这种体验一旦习惯了就很难回去写any满天飞的接口层。1.2 我最终敲定的依赖矩阵create-t3-app默认带的是Next.js TypeScript tRPC Tailwind数据库和认证会给选项。我这次的业务系统选型如下模块选型理由核心框架Next.js 14 App Router服务端组件渲染首屏快API路由与tRPC可以共存类型管道tRPC v10trpc/server、trpc/client、trpc/react-query端到端类型推断不用生成代码数据库PostgreSQL Prisma关系型业务数据迁移工具链成熟认证NextAuth.js v4与tRPC中间件配合简单支持Credentials和OAuth双模式样式Tailwind CSS v3原子类开发效率高服务端组件里也好用数据请求缓存tanstack/react-querytRPC客户端内置按key缓存、失效、乐观更新都现成依赖装完那一刻项目就能跑但真正进入业务开发你会发现脚手架给的目录只是起点还得自己做二次组织。2. 脚手架之外的目录与运行机制初始化之后才是真正的开始create-t3-app跑完项目结构基本是src/servertRPC服务端、src/trpc客户端初始化、src/server/db、src/server/auth这摊。小demo没问题业务一多就得重新规划。2.1 我对src/server目录做的二次拆分原版脚手架把所有router塞进root.ts和post.ts我接手后立刻按业务域拆开src/server/ api/ routers/ user.ts # 用户域资料、偏好、账号操作 order.ts # 订单域创建、查询、状态流转 payment.ts # 支付域对账、回调、退款 stats.ts # 统计域聚合查询、报表 root.ts # 统一汇总所有子router db.ts # Prisma client单例 auth.ts # NextAuth配置 context.ts # tRPC调用上下文创建每个域router内部再按procedure类型分块。这么拆的好处是多人协作时每个人负责一个文件merge冲突概率小同时中间件认证逻辑可以在root上统一挂细分场景再单独覆盖。2.2 环境变量每个人都在这里栽过跟头脚手架会生成.env文件里面有DATABASE_URL、NEXTAUTH_SECRET等。我踩的第一个坑是团队协作时有人本地没有.env或者从邮件里复制DATABASE_URL带了换行符导致Prisma连接报错半天查不出来。我的做法是维护一份.env.example放进仓库注释清楚每个字段的含义与获取方式并约定本地开发用统一的默认值。另外NEXTAUTH_SECRET不能用默认生成的那个部署到线上之前务必换成一个足够随机的字符串否则用户Session有被伪造的风险。生成方式随手就有openssl rand -base64 32还有一点容易被忽略tRPC在服务端组件里直接调用时可能会遇到环境变量作用域的问题。Next.js会把NEXT_PUBLIC_开头的变量暴露给浏览器非公开变量只能在服务端代码里使用。如果你的tRPC context里需要读取某个密钥注意别在代码里直接引而是通过服务端环境变量注入。3. tRPC数据流设计从后端到组件的类型管道tRPC整体链路里有三个概念是绕不开的Router、Procedure和Context。理解透了后面业务开发才会顺手。3.1 Router和Procedure到底怎么配合Router是路由集合Procedure是一次具体调用。看一段实际代码// src/server/api/routers/order.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; export const orderRouter createTRPCRouter({ // 公开查询按订单号查询 getByNo: publicProcedure .input(z.object({ orderNo: z.string().min(1) })) .query(async ({ ctx, input }) { const order await ctx.db.order.findUnique({ where: { orderNo: input.orderNo }, include: { items: true }, }); return order; }), // 受保护操作创建订单 create: protectedProcedure .input(z.object({ items: z.array(z.object({ skuId: z.string(), quantity: z.number().int().positive(), })), })) .mutation(async ({ ctx, input }) { // ctx.session 由中间件保证一定存在 const userId ctx.session.user.id; // 业务逻辑... }), });createTRPCRouter负责把一堆procedure组织起来它本身可以嵌套。root.ts里合并// src/server/api/root.ts import { createTRPCRouter } from ./trpc; import { userRouter } from ./routers/user; import { orderRouter } from ./routers/order; export const appRouter createTRPCRouter({ user: userRouter, order: orderRouter, }); export type AppRouter typeof appRouter;这样客户端调用路径就是trpc.order.create、trpc.user.profile类型随之自动推断。3.2 Context与鉴权中间件protectedProcedure的实现细节tRPC每次请求都会先创建Context里面通常塞dbPrisma实例和session当前用户。我的写法// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import superjson from superjson; import { type Context } from ../context; const t initTRPC.contextContext().create({ transformer: superjson, }); export const createTRPCRouter t.router; export const publicProcedure t.procedure; // 从Context里取session如果不存在直接抛401 const enforceUserIsAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure t.procedure.use(enforceUserIsAuthed);这个中间件逻辑很简单但它解决了一个很实际的问题你不需要在每个mutation里手写if (!session) throw了路由表里一眼就能看出哪些接口需要登录。我在t3code里还加了一层角色校验中间件基于ctx.session.user.role判断是否允许访问管理端点用法上就是在protectedProcedure之后再加use(requireAdmin)。3.3 客户端调用与React Query缓存服务端定义好了客户端这边通过生成的类型自动获得完整的IDE提示// src/trpc/react.tsx import { createTRPCReact } from trpc/react-query; import type { AppRouter } from ~/server/api/root; export const trpc createTRPCReactAppRouter();在组件里使用use client; import { trpc } from ~/trpc/react; export function OrderList() { const { data, isLoading, refetch } trpc.order.list.useQuery( { page: 1, pageSize: 20 }, { staleTime: 60_000 } // 60秒内不重新请求 ); if (isLoading) return div加载中.../div; return ( ul {data?.orders.map((order) ( li key{order.id}{order.orderNo} - {order.status}/li ))} /ul ); }这里的useQuery是React Query的封装缓存失效和自动重试也都内置。变更操作之后通常需要让相关查询失效const utils trpc.useUtils(); const createOrder trpc.order.create.useMutation({ onSuccess: () { // 让订单列表重新拉取 utils.order.list.invalidate(); }, });这个模式在所有T3项目里都通用我愿称之为类型安全的data fetching闭环。3.4 错误处理与输入校验很多教程不写错误处理但实际项目里这是必选项。tRPC Zod的组合让输入校验可以在编译期之外运行时兜底。我在每个mutation里都习惯先做业务校验再操作数据库import { TRPCError } from trpc/server; export const cancelOrder protectedProcedure .input(z.object({ orderId: z.string().uuid() })) .mutation(async ({ ctx, input }) { const order await ctx.db.order.findUnique({ where: { id: input.orderId }, }); if (!order) { throw new TRPCError({ code: NOT_FOUND, message: 订单不存在, }); } if (![PENDING, PAID].includes(order.status)) { throw new TRPCError({ code: BAD_REQUEST, message: 当前状态不可取消, }); } // 执行取消逻辑 });前端接trpc.order.cancel.useMutation()后error.message直接可以展示给用户。配合统一错误组件错误提示这种东西就完全不用维护两份了。4. 数据建模与Prisma落地从关系梳理到迁移策略tRPC只是类型管道真正的数据源还是数据库。T3默认推荐Prisma我这边直接用的PostgreSQL。4.1 schema设计按业务域拆分模型我建了几个核心模型拿订单域举例model User { id String id default(cuid()) email String unique name String? role Role default(USER) createdAt DateTime default(now()) orders Order[] } model Order { id String id default(cuid()) orderNo String unique status OrderStatus default(PENDING) userId String user User relation(fields: [userId], references: [id]) items OrderItem[] totalAmount Decimal db.Decimal(10, 2) createdAt DateTime default(now()) updatedAt DateTime updatedAt } model OrderItem { id String id default(cuid()) orderId String order Order relation(fields: [orderId], references: [id], onDelete: Cascade) skuId String skuName String quantity Int price Decimal db.Decimal(10, 2) } enum Role { USER ADMIN } enum OrderStatus { PENDING PAID SHIPPED COMPLETED CANCELLED }关键选择说明关系字段的onDelete策略一定要提前想清楚。比如删除用户时订单应该怎么处理默认Restrict会阻止删除有时候你希望级联删除有时候希望保留历史记录。金额字段一律用Decimal绝对不要用Float。Float会有精度问题对账时会哭的。主键用cuid()而不用自增id主要是避免暴露数据规模。如果你没有这个顾虑自增会更简洁。4.2 Prisma迁移的实操细节开发阶段用prisma migrate dev每次改schema后生成迁移并同步数据库。这个命令会自动触发客户端重新生成类型随之更新。注意npx prisma migrate dev --name add_order_model如果提示需要重置数据库先确认你不需要本地数据。开发环境无所谓但如果有seed脚本重置后记得重新npx prisma db seed。部署到生产环境时用prisma migrate deploy而不是dev。这个命令不会要求交互也不会重置数据只会应用尚未执行的迁移文件。还有一个经常踩的坑不要在保存了Prisma客户端的盒子里随意升级Prisma版本。不同版本生成的client类型会有差异升级前后记得跑一下测试我遇到过升级后Decimal类型从string变成对象导致前端展示金额格式混乱的情况。4.3 Prisma Studio调试数据库不可多得的工具开发期强烈推荐npx prisma studio它会起一个本地网页可以直接查看所有表的数据、手动修改字段甚至调试关联关系。我在排查订单状态机逻辑的时候就是靠着Studio手动改状态来模拟各种流转路径的效率比写SQL快得多。5. 认证与会话NextAuth和tRPC如何无缝协作T3默认集成了NextAuth.js但v4和App Router集成时有一些细节需要处理。5.1 我在src/server/auth.ts中的配置import NextAuth from next-auth; import Credentials from next-auth/providers/credentials; import { PrismaAdapter } from next-auth/prisma-adapter; import { db } from ./db; import { compare } from bcryptjs; export const { handlers, auth, signIn, signOut } NextAuth({ adapter: PrismaAdapter(db), providers: [ Credentials({ credentials: { email: { label: 邮箱, type: email }, password: { label: 密码, type: password }, }, async authorize(credentials) { const user await db.user.findUnique({ where: { email: credentials.email as string }, }); if (!user || !user.passwordHash) return null; const valid await compare( credentials.password as string, user.passwordHash ); return valid ? { id: user.id, email: user.email, name: user.name } : null; }, }), ], session: { strategy: jwt }, callbacks: { async jwt({ token, user }) { if (user) token.id user.id; return token; }, async session({ session, token }) { if (token.id) session.user.id token.id as string; return session; } }, secret: process.env.NEXTAUTH_SECRET, });这里用bcryptjs做密码哈希而不是直接存明文。Credentials模式下不要忘了给User模型加passwordHash字段并且设为可选这样OAuth用户可以没有密码。5.2 Context里注入SessiontRPC Context在服务端创建时需要拿到当前请求的Session。如果你用的是App Router注意不能用旧的getServerSession模式而是用auth()// src/server/context.ts import { auth } from ./auth; import { db } from ./db; export const createTRPCContext async (opts: { headers: Headers }) { const session await auth(); return { db, session, headers: opts.headers, }; };auth()函数在服务端组件和路由处理器里都能用。这样tRPC的protectedProcedure中间件里ctx.session.user就一定有值。5.3 一个容易搞混的点JWT里的user.id默认情况下session里只有name、email、image没有user.id。很多人在中间件里写ctx.session.user.id结果拿到undefined。上面的callback就是为解决这个问题的在jwt回调里把user.id塞进token在session回调里把它再塞回session.user.id。这个坑我印象很深因为当时调试了很久才意识到是默认行为如此。6. 实战排查链路我从t3code里挖出的三个高频坑这一节比较贴近真实开发节奏。T3项目跑demo什么都顺一旦进入复杂业务有几个问题几乎必然出现。6.1 Server Components里调tRPC别走HTTP回环App Router里服务端组件默认不能直接用trpc.order.list.useQuery()因为这是客户端hook。标准做法是在服务端直接调用tRPC的caller// src/app/orders/page.tsx import { appRouter } from ~/server/api/root; import { createTRPCContext } from ~/server/context; export const dynamic force-dynamic; export default async function OrdersPage() { const ctx await createTRPCContext({ headers: new Headers(), }); const caller appRouter.createCaller(ctx); const result await caller.order.list({ page: 1, pageSize: 20 }); return OrderTable initialData{result} /; }这样服务端直接执行procedure里的逻辑不经过网络请求类型依然完整保留。注意不要太早把页面改成客户端组件否则首屏性能会明显下降。我用t3code的首页列表就是走服务端调用 客户端组件hydration的方式首屏HTML直接带出了表格数据。6.2 部署到Vercel时遇到的构建报错第一个版本部署时build阶段报错Error: Cannot find module ./_app或者类似信息常见原因有几个Prisma client在构建时没有正确生成。解决在postinstall脚本里加prisma generate。环境变量在构建时缺失导致db连接失败。Vercel的构建环境不读取本地.env需要到项目设置的Environment Variables里逐个填入。Node版本不匹配。Next.js 14要求Node 18旧版本会有些莫名其妙的错误。我的项目构建配置大概是这样{ scripts: { postinstall: prisma generate, build: next build } }部署前跑一遍npm run build本地能过再push。我曾经因为本地没报错就提交结果云端构建失败两次后来养成的好习惯是每次部署前先本地build验证通过再推远端。6.3 类型推断中断的排查思路tRPC的魔法建立在类型推断链路上只要中间有一个环节类型变成any后续推断就断了。我在业务代码里遇到过几次排查思路基本固定检查是否在某个router里用了as any这会污染整条类型链。检查Prisma的模型类型是否和前端期望的结构匹配多查了一层include、少查了一层select都会导致类型不匹配。检查zod input的schema和实际数据结构是否严格一致。tRPC对类型一致性要求很高这里宽松一点整个链路的推断都会松掉。我习惯在关键router文件顶部显式声明export type { AppRouter }并测试引用一次这样如果类型链断了IDE会在第一时间标红而不是等到组件里再发现。7. 项目收尾与值得沉淀的做法t3code这套模板目前已经在我另外两个项目中复用过了结构基本稳定收益也逐渐体现出来。一次完整的开发周期下来我最认可的地方是tRPC Prisma TypeScript把定义-实现-消费三个环节缝成了一条线。业务逻辑改了编译错误会告诉你哪里没跟上数据库模型调整了所有关联查询的位置都会标红。这种编译器替你操心的体验节省的不只是联调时间更是心智负担。当然也有代价这套组合的学习曲线比传统的REST API方案要陡。团队里如果有人对TypeScript泛型不熟前一两周会有些吃力需要有人带一下。另外如果项目不需要前后端分离、团队也习惯服务端渲染页面很少写交互那么tRPC带来的收益就会打折扣。如果你准备尝试我的建议是先别急着改造现有项目用create-t3-app开一个demo照着本文第三节的代码把用户和订单两个域实现一遍感受一下写后端定义、写前端调用、全程不写接口文档是什么样的节奏。跑通之后再评估要不要全面迁移。我自己是在一个内部工具项目上试点成功之后才决定把核心业务系统也搬上来的。最后说一个实操小技巧tRPC返回的数据里如果包含Date类型记得配置superjson transformer脚手架默认带否则客户端收到的会是字符串而不是Date对象很多依赖日期方法的组件会出问题。这个配置在server和client两端都要同时开启缺一边都会出怪问题。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询