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

发布时间:2026/10/28 6:59:52
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 v10(@trpc/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/server(tRPC服务端)、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,里面通常塞db(Prisma实例)和session(当前用户)。我的写法:

// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { type Context } from "../context"; const t = initTRPC.context<Context>().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 = createTRPCReact<AppRouter>();

在组件里使用:

"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里注入Session

tRPC 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两端都要同时开启,缺一边都会出怪问题。