T3 Stack 实战:用 tRPC + Prisma 构建类型安全全栈应用 去年团队接到一个内部业务系统的重构任务随手把仓库名定为 t3code。一开始我以为是随手的代号后来才意识到这个名字正好概括了这次技术路线的核心用 T3 Stack 把前端、后端、数据库整套链路串起来。如果你最近在关注 Next.js、tRPC、Prisma 这套组合或者正打算做一个类型安全的全栈项目这篇文章应该能帮你少走不少弯路。项目本身不复杂功能上就是一个包含用户体系、内容管理和统计报表的中型 Web 应用真正有价值的不是这些页面长什么样而是整套链路如何从零搭建如何用类型约束把接口对齐的成本降到最低。对于小团队和独立开发者来说这套实践相当值得抄作业。下面我把 t3code 从选型、初始化、核心代码到踩坑的过程完整复盘一遍。1. 一个内部项目为什么要叫 t3code1.1 项目原始的三个痛点在重构之前系统是典型的前后端分离架构。前端 React后端 Node Express数据库 MySQL技术选型看起来都是主流货但实际开发里磨人的问题一个接一个。第一个痛点是接口文档维护成本极高。后端每次改字段前端就要去翻文档但文档经常忘记更新联调时就出现页面没数据、字段对不上的情况。第二个痛点是缺少强类型的契约。DTO 和实体类之间没约束任何一端改名编译期都不会报错只能上线后靠监控发现。第三个痛点是鉴权逻辑太分散前端拿 token后端验 token两边经常因为密钥或者过期时间配置不一致出现 401。这三个问题在小团队环境里很致命。人少没有专职文档维护者也没有系统接口版本管理。t3code 这个代号就在这时候出现与其说它是功能代号不如说它是一个技术决策的产物——团队想借这次重构把前后端的数据契约用代码本身固定下来让类型定义成为唯一的接口文档。1.2 为什么品中 T3 Stack 而不是继续前后端分离这里需要先澄清一个误解T3 Stack 不是某个新框架它是一套技术组合约定。推荐组合是 Next.js 做应用框架TypeScript 做类型系统tRPC 做 API 层Tailwind CSS 做样式Prisma 做 ORMNextAuth 做认证。名字里的 T3就是 TypeScript、Tailwind、tRPC 这三个以 T 开头的核心成员。选这套组合的直接理由有几个。第一是端到端类型安全。传统 REST API 需要手写请求和响应类型两端各维护一份本质上还是在写文档。tRPC 的做法是让客户端直接引用服务端 router 的类型服务端把 procedure 定义好之后前端调用时就有完整类型提示和校验。有人会担心这种耦合会不会让前后端分不开实际用下来发现只要目录结构分离得干脆耦合只是类型层面的运行部署依然可以独立。第二是开发心智统一。项目里没有单独的 controller、service、model 分层取而代之的是 router 文件、procedure 定义、Prisma Schema。整条链路从数据库到页面几乎是垂直落下来新成员只需要理解一条主线不用学几十个概念。第三是生态成熟。T3 Stack 的成员全是这两年里社区使用率最高的工具Next.js 是生产级 React 框架Prisma 是成熟的 ORMTailwind 基本成了新项目的默认选择。它们组合起来不是实验玩具能支撑真实业务。选型时也犹豫过比如担心 tRPC 相比 REST 会失去通用性外部系统的 webhook 还是得要 REST。我的评估是核心业务走 tRPC对外回调接口单独开 REST 路由两条线可以共存。这也是 t3code 最终坚持用这套组合的原因。2. 初始化工程骨架create-t3-app 的几个关键决定2.1 项目初始化实操搭建环境很简单Node 18 以上终端执行npm create t3-applatest t3code命令行会弹出交互选项这里建议按项目实际需要勾选不要贪多。t3code 只勾了 Next.js、TypeScript、tRPC、Prisma、NextAuth 和 Tailwind CSS。没有选 Drizzle是因为当时团队对 Prisma 更熟悉而且这套系统已经稳定运行过两年迁移成本比新数据库工具带来的收益更值得在意。初始化过程中有一个重要选项App Router 还是 Pages Router。现在开始的新项目直接选 App Router因为这是 Next.js 当前迭代的主要方向。t3code 选 App Router 的另一个实际原因是React Server Component 可以在服务端完成很多读取逻辑不用再单独暴露 REST 接口这让 tRPC 的压力集中在写操作上。create-t3-app 默认会生成完整的 tRPC 结构和 Prisma 客户端配置不需要手写基础代码。生成后的核心目录结构是这样src/ app/ server/ api/ routers/ trpc.ts db.ts auth.ts trpc/ server.ts client.ts env.js2.2 目录结构背后的数据流很多新人会被 src/trpc 和 src/server 的分工搞混。简单说tRPC 的关键在于 router 需要同时被服务端和客户端引用。服务端定义好 router 之后前端靠这个类型引用拿到对应 hooks。create-t3-app 给出的约定是src/server/api/trpc.ts 里创建 tRPC 实例src/server/api/routers 放具体业务模块src/trpc/server.ts 生成服务端调用器src/trpc/client.ts 建立 React 客户端。实际写作时我习惯在 routers 目录下按业务域拆文件比如 userRouter、contentRouter、reportRouter然后在根 router 里聚合import { userRouter } from ./routers/user; import { contentRouter } from ./routers/content; import { reportRouter } from ./routers/report; import { createTRPCRouter } from ~/server/api/trpc; export const appRouter createTRPCRouter({ user: userRouter, content: contentRouter, report: reportRouter, }); export type AppRouter typeof appRouter;这层聚合文件就是整个后端 API 的类型枢纽前端通过import type { AppRouter }初始化 hooks。团队协作时这个文件不建议频繁改动新增模块的流程是先去 routers 目录建文件再来这里注册一下类型引用面始终保持收敛。要不要用文件夹嵌套我两种都试过。深度嵌套在复杂业务下有利于权限边界划分但在 t3code 这个规模下一层业务域加若干 procedure 足够。嵌套多了路径会很长而且 tRPC 路径名会直接体现在 hooks 调用里trpc.report.statistics.summary.useQuery()这种调用和report.getSummary相比可读性没有提升反而增加记忆负担。3. 核心链路落地从数据库到页面一次打通3.1 用 Prisma 设计数据模型t3code 涉及三个主要实体用户 User、内容 Content、操作日志 Log。这里给出简化后的 schema 定义model User { id String id default(cuid()) name String? email String unique emailVerified DateTime? image String? contents Content[] logs Log[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Content { id String id default(cuid()) title String body String db.Text published Boolean default(false) author User relation(fields: [authorId], references: [id]) authorId String createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Log { id String id default(cuid()) action String metadata Json? user User? relation(fields: [userId], references: [id]) userId String? createdAt DateTime default(now()) }主键用 cuid 而不是自增 ID我这里补充下判断依据。内部系统里自增 ID 看起来够用但数据一旦涉及合并、导入导出或者分库自增 ID 极易冲突。cuid 无连续性也避免了注册一个用户就暴露全站用户量的问题。字符串主键在索引性能上有小代价但这个量级完全无感。Log 表的 metadata 用了 Json 类型这是记录操作日志最方便的方式。不需要提前建几十个字段查询时直接透传 JSON 即可。PostgreSQL 和 MySQL 的 json 列都支持得很好Prisma 也原生支持。写完 schema 后执行迁移npx prisma migrate dev --name init这个命令会创建迁移文件并同步数据库。要注意如果项目里有 CI 流程应该用prisma migrate deploy而不是 migrate dev因为 dev 命令会尝试创建新迁移deploy 只是应用已有迁移文件。3.2 用 tRPC 封装业务接口内容管理是 t3code 的核心接口下面把列表、创建、删除三个 procedure 放在一起看import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; export const contentRouter createTRPCRouter({ list: publicProcedure .input( z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(50).default(10), }) ) .query(async ({ ctx, input }) { const [items, total] await Promise.all([ ctx.db.content.findMany({ where: { published: true }, orderBy: { createdAt: desc }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, }), ctx.db.content.count({ where: { published: true } }), ]); return { items, total, page: input.page, pageSize: input.pageSize }; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), body: z.string().min(1), }) ) .mutation(async ({ ctx, input }) { return ctx.db.content.create({ data: { title: input.title, body: input.body, authorId: ctx.session.user.id, }, }); }), delete: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) { const content await ctx.db.content.findUnique({ where: { id: input.id } }); if (!content || content.authorId ! ctx.session.user.id) { throw new Error(无权删除该内容); } return ctx.db.content.delete({ where: { id: input.id } }); }), });这里有一个关键点容易被忽略。createTRPCRouter 里的 protectedProcedure 是在 trpc.ts 中基于 NextAuth 的 session 封装出来的。t3code 的权限校验完全靠这层中间件完成而不是在每个接口里手动判断 sessionexport const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session } }); });这样做的好处是新增接口时只需要选择 publicProcedure 还是 protectedProcedure权限边界就不会漏。等后面需要管理员权限再在这个文件里扩展一个 adminProcedure把角色判断放在一个地方而不是散落在每个 router 里。3.3 页面端调用与服务端查询的衔接App Router 模式下有两种调用 tRPC 的方式客户端 hooks 和服务端组件直调。t3code 的做法是混合使用针对不同页面选不同方案。内容列表页需要首屏快速出数据我放在服务端组件里调用import { api } from ~/trpc/server; export default async function ContentListPage() { const { items } await api.content.list({ page: 1, pageSize: 10 }); return ContentList initialItems{items} /; }创建、删除这类需要交互的操作在客户端组件里用 hooksuse client; import { trpc } from ~/trpc/client; export function CreateContentButton() { const utils trpc.useUtils(); const createContent trpc.content.create.useMutation({ onSuccess: () { utils.content.list.invalidate(); }, }); const handleSubmit (title: string, body: string) { createContent.mutate({ title, body }); }; return button onClick{() handleSubmit(标题, 正文)}创建/button; }这种混合模式的好处是列表页不用等客户端发请求服务端直接查数据库性能更好。表单提交这类有状态交互依然享受 React Query 的缓存和失效机制。utils.content.list.invalidate()是最常用的一个 API它会把列表查询标记为过期下次访问自动重新拉取无需手动刷新页面。有人问服务端组件里直接调 tRPC和直接在页面里用 Prisma 有什么区别其实都能查数据但走 tRPC 可以保留统一的权限中间件和输入校验。如果页面直接拿 ctx.db 来查这些规则就绕过了。让所有读取都走 tRPC是 t3code 的一条硬约定。3.4 认证与权限控制的接入NextAuth 在 T3 Stack 里几乎是默认配置。create-t3-app 勾选后会自动生成一套配置代码只需要在 src/server/auth.ts 里配置 providers 和 callbacks。t3code 用了 GitHub 登录和邮箱登录两种方式。配置本身不难关键是回调里做一次用户入库和 session 补充callbacks: { async session({ session, token }) { if (session?.user) { session.user.id token.sub as string; } return session; }, async jwt({ token }) { return token; }, }最重要的是 session callback 里要把数据库用户 ID 塞进 session因为后面所有 protectedProcedure 都靠ctx.session.user.id来识别当前登录人。这里漏掉的话后续任何需要 user id 的接口都会报 undefined而且报错位置往往很隐蔽。权限方面后来 t3code 引入了简单角色字段。做法是在 Prisma User model 里加role String default(user)然后在 trpc.ts 里基于 protectedProcedure 再扩展一个 adminProcedurerole 为 admin 的用户才能访问指定接口。3.5 Tailwind 样式处理的一点经验Tailwind 没有太多高深技术最有价值的是约束。初始化后不要急着复制别人的组件库先把设计变量定义好。t3code 在 tailwind.config.ts 里扩展了一套语义化颜色theme: { extend: { colors: { brand: { 50: #eef2ff, 500: #6366f1, 600: #4f46e5, }, }, }, }页面里不直接用 blue-500、red-500 这类通用色而是用 brand-500后面品牌换色只需要改这一处不用全局替换。4. 实战中的高频报错与排查记录4.1 tRPC 类型不刷新最常见的问题是新增 router 或修改 procedure 之后前端类型提示还是旧的。原因是 create-t3-app 生成的 hooks 类型来自服务端 AppRouter 类型本地开发时 TS 有一个类型推导过程编辑器偶尔会缓存旧类型。解决办法是重启 dev server让 Next.js 重新编译。如果还不行检查 src/trpc/client.ts 里是否写了export const trpc createTRPCReactAppRouter();这里的 AppRouter 必须从服务端导入写成createTRPCReactany或本地自定义类型类型安全就全没了。另一个容易忽略的原因是路由名称冲突。两个 router 有同名 procedureTS 会报错但报错提示往往出现在调用处而不是定义处排查起来很迷惑。命名上一定要带业务域前缀比如 content.list 而不是 list。4.2 Prisma 数据库连接和迁移问题t3code 开发中遇到过典型的Connection pool timeout报错。原因通常是本地同时开了太多 Node 进程连接同一个数据库或者连接字符串里没配置合理上限。解决办法不是把超时时间调大而是正确配置连接串。例如 PostgreSQLDATABASE_URLpostgresql://user:passwordlocalhost:5432/trpc?schemapublicconnection_limit5另一个高频问题是prisma migrate dev提示 schema 与 migration 不一致。这通常出现在多人协作时有人本地直接改库却没更新迁移文件。处理方式是出问题的机器先确保迁移目录和远端一致再重新执行 migrate dev。生产库上不要用prisma db push它会跳过迁移记录让后续迁移不可控。4.3 部署后的环境变量和数据库地址部署最容易暴露问题。t3code 第一次部署到云服务器时前端能打开但登录后所有 tRPC 请求都失败浏览器面板能看到 401 或回调错误。排查后发现是 NEXTAUTH_URL 没设置准确。NextAuth 要求这个变量指向实际对外地址否则回调地址生成不正确。如果站点在子路径下运行还要同步设置 basePath。部署时的另一个坑是 Prisma client 引擎与服务器环境不匹配。构建镜像的机器若缺少对应 OpenSSL运行时会出现 PrismaClientInitializationError。解决办法是使用 Prisma 官方推荐的镜像或安装 openssl不要图省事用最小化的精简镜像除非确认引擎版本兼容。部署后我常写一个最小脚本直接调用 appRouter 的某个 procedure 来验证链路能把代码类型错误和部署环境错误快速分开不用反复在前端页面上试。4.4 团队协作下的代码规范t3code 中期加了好几个人这时代码规范比功能本身更重要。项目里用了 prettier 和 eslint 自动格式化并强制提交前跑一次类型检查npx tsc --noEmit这个命令在 T3 Stack 项目里格外重要因为 tRPC 的类型推导很复杂某个 router 返回类型变了所有调用处就会在编译期报错。把类型错误消灭在提交之前线上运行时错误会少一大截。另外我们对 procedure 的 input 做了统一约束全部使用 zod。有人一开始图省事写input(z.object({ data: z.any() }))一旦这么做接口类型防线就破了校验逻辑形同虚设。最后定的硬性规定是每个 procedure 必须有明确的 zod schema禁止 any 作为 input 类型。这条规定让后续参数变更时所有调用处同步提示类型安全才真正落地。如果你也在评估要不要引入这套组合我建议先拿一个两周左右的小功能试点重点看团队里每个人是不是需要额外文档才能理解数据流。如果发现大家都能很自然地从 routers 文件里看懂业务边界那这套方案就值得留下。反过来如果你们团队必须给外部系统开放 OpenAPI 文档那内部核心链路走 tRPC对外单独维护一个 REST 网关也是一种不冲突的折中。t3code 到最后采用的就是这种双轨结构实测下来两边都稳。