T3 Stack 全栈实践:tRPC + Prisma 构建端到端类型安全应用
1. t3code 到底是什么:不只是三个字母的代号
先交代背景。我去年年底开始接手一个内部工具的重构,项目代号就叫t3code。简单说,它是一个基于 T3 Stack(TypeScript + Tailwind CSS + tRPC)搭建的全栈应用骨架,核心目的只有一个:用最少的技术栈拼装,把从前端到后端再到数据库的整条链路做成“端到端类型安全”。也就是你在前端写一个变量,如果类型不对,编译期就报错,而不是等着运行时炸。
这个项目的原型需求并不复杂——需要一个能管理内部资源清单、支持多人协作编辑、带基础权限控制的小系统。但真正动工的时候我意识到,如果按传统的“Express 写接口 + React 页面 + Redux 管理状态”那一套来,光是联调接口类型就要写几百行重复代码。于是我把目光转向 T3 Stack,并且把整套实践沉淀成了 t3code 这个项目,算是给团队留下一份可复用的“配置好、能跑通、有案例”的代码资产。
适合谁来参考?如果你正在做 Next.js 全栈项目,或者你受够了前端写接口文档、后端手工维护类型定义,再或者你想尝试 Prisma + tRPC 这种组合但不知道从哪下手,那这篇笔记能帮你省掉不少试错时间。我会按实际动手的顺序写下来:从脚手架初始化、数据库建模、接口封装,到认证接入和生产部署,每一段都是我在 t3code 里真实跑过的路径,包括那些会让你卡半小时的坑。
2. 技术选型背后:为什么偏偏是这三件套加两个配角
2.1 核心思路:类型安全不是花架子,是省事故
T3 Stack 的核心理念可以压缩成一句话:所有数据边界都用 TypeScript 类型串起来,哪里类型断了,哪里就能在开发阶段暴露问题。
这里我要展开说一下,因为很多人第一次看到 tRPC 都有个疑问:我直接用 RESTful API 不好吗?为什么要在 Next.js 里再包一层 RPC 抽象?
我的理解是这样的:REST 本身没问题,但它是一种“约定”,需要靠人自觉去维护。前端调用 GET /api/posts 拿回来的数组到底长什么样?字段是 snake_case 还是 camelCase?什么时候返回 401?这些问题 REST 不强制约束,只能靠文档和接口管理工具兜底。而 tRPC 把过程变成了“像调用本地函数一样调用后端函数”,参数类型和返回类型都是从后端代码直接推断给前端的。这不是什么黑魔法,它只是利用了 TypeScript 的泛型和类型推断,把后端 Router 的输出类型“透传”给前端客户端。
用生活里的例子类比:REST 就像你给装修公司打电话说“把阳台封一下”,对面说“好”,然后你俩对“封”的理解完全靠默契;tRPC 则像你下了一个 App 点选“封闭式阳台-断桥铝-灰色”,系统自动校验参数可选范围和价格,不接受模糊描述。这个特性放到团队协作里,最直接的效果就是:少了很多“我看下接口返回”的对话。
2.2 技术栈对比:t3code 凭啥不选重量级方案
我在立项前做过一个粗略对比,主要争的是要不要上 NestJS、要不要用 GraphQL,或者干脆用 Next.js 自带的路由处理器。
| 方案 | 类型安全强度 | 学习成本 | 样板代码量 | 适合场景 |
|---|---|---|---|---|
| Next.js Route Handlers + 手写 API 层 | 弱,全靠自己定义类型 | 低 | 高(每加一个接口要写 handler、验证、类型映射) | 小项目、原型、已有 REST 习惯团队 |
| GraphQL + Apollo | 中强,靠 Schema 生成类型 | 高 | 中高(Schema、Resolver、Fragment 一大堆概念) | 多端消费、复杂关联查询 |
| NestJS + 手写接口 | 中,需要 DTO 和装饰器 | 高 | 高 | 大型企业级后端 |
| t3code 采用:Next.js + tRPC + Prisma | 强,类型自动推导 | 中低 | 低 | 全栈应用、内部工具、快速迭代产品 |
对比完我基本确定方向:t3code 面向的场景是“一个人或一个小团队把整个应用端到端做出来”,这时候最优解不是引入更多抽象,而是减少不同层之间的概念切换。Next.js 负责路由和渲染,tRPC 负责 API 调用,Prisma 负责数据库操作,NextAuth 负责登录,Tailwind 负责样式。每样都只干一件事,但组合起来就是一套完整闭环。
另外一个我特别在意的地方是:这个技术栈的Schema 是单一数据源。Prisma 的schema.prisma既是数据库表结构,也是 TypeScript 类型源头;tRPC 的 Router 定义既是接口清单,也是前端调用的类型来源。这意味着你不需要单独维护一份接口文档,因为代码本身就是文档,而且是不会过期的文档。
3. 初始化与目录结构:create-t3-app 之后的十二分钟
3.1 脚手架命令与选项取舍
t3code 的第一步很常规,是从create-t3-app开始的。这里有一个交互式选择环节,我直接把我建议勾选的项列出来:
npx create-t3-app@latest t3code进入交互界面后,会问你使用什么路由方式、数据库 ORM、认证方案、样式方案和要不要开启 ESLint 等。我选的是:
- Next.js 的 App Router(因为这是 Next.js 14+ 的默认方向,Server Components 对 SSR 数据获取更友好)
- Prisma 作为 ORM
- tRPC 开启
- NextAuth 开启,后续接 GitHub Provider
- Tailwind CSS 开启
- ESLint 和 Prettier 都开启
有人会问为什么不用 Pages Router?我只能说,App Router 在未来两三年内一定是 Next.js 的主线,t3code 既然要做可复用的样板,还是老老实实踩在 Next.js 官方主推的方向上。刚开始会觉得 Server Components 这套心智模型和传统 SSR 不太一样,但适应之后优势很明显——尤其当你在 RSC 里直接读取数据库的时候,脚本级别的性能提升是能直观感受到的。
初始化完成后,项目目录已经自带一套跑得通的示例代码:src/server/api/root.ts里是 tRPC 的主 Router,src/server/db.ts里是 Prisma Client 的单例,src/trpc/server.ts是服务端调用 tRPC 的封装,src/app/api/trpc/[trpc]/route.ts则是 HTTP 入口。整个骨架的意图很清楚:领域代码全部收进src/server,页面组件和客户端组件只负责消费。
3.2 目录级解剖:每层职责划的一条线
我按下单情况把 t3code 的目录结构做了精简和注释:
src/ ├── app/ // Next.js 路由层,只做页面入口和数据展示 │ ├── api/trpc/[trpc]/ // tRPC HTTP 入口,框架层代码,一般不动 │ ├── (auth)/ // 登录、注册相关路由组 │ ├── resources/ // 业务页面,比如资源清单列表 │ └── layout.tsx // 根布局,注入全局样式和 SessionProvider ├── components/ // 前端组件,区分 client 和 server 组件 ├── server/ │ ├── api/ │ │ ├── routers/ // 业务 Router:post.ts、user.ts、resource.ts │ │ ├── root.ts │ │ └── trpc.ts │ ├── auth.ts // NextAuth 配置 │ ├── db.ts // Prisma Client 单例 │ └── validations/ // Zod 输入校验 ├── trpc/ │ ├── server.ts // 服务端调用 tRPC 的工具 │ └── client.ts // 客户端 React Query 封装你注意看,src/server这层是纯 Node.js 环境,它不会被打进浏览器包;src/trpc/server.ts的出现也很关键,它让 Server Component 可以在服务端直接调用 tRPC Router,就跟调用普通函数一样,不产生额外的 HTTP 往返。这一点是 T3 Stack 最让我上头的设计——同一个 Router,在服务端是直接调用,在浏览器端是走 HTTP,但两边的类型体验完全一致。
目录结构的划分逻辑其实很简单:能进server的代码绝不放进组件里;能被 Java 人称为“业务层”的东西统一收在routers/;而app/目录只做两件事——接收 URL、渲染 UI。这样每一个新加入项目的开发都能一眼看出数据流的方向,不用翻遍所有代码找“这个接口到底在哪里”。
4. 从数据库到页面:用 Prisma + tRPC 打通一条完整链路
4.1 Prisma 数据建模与迁移的实操细节
t3code 里我建了两张表:User(用户)和Resource(资源)。User是 NextAuth 需要的标准模型扩展,Resource是业务表,记录资源名称、类别、描述、创建人。先看schema.prisma里的核心部分:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] resources Resource[] } model Resource { id String @id @default(cuid()) title String category String description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt createdById String createdBy User @relation(fields: [createdById], references: [id]) }这里有几个细节我得单独拎出来说。
第一,主键用cuid()而不是自增Int。自增 id 在内部工具里问题不大,但一旦涉及数据合并、同步或者对外暴露,数字 id 很容易被枚举和猜测。cuid 不是 UUID 那么长,但无序性够了,前端展示也不会暴露业务规模。
第二,createdById的外键约束直接写进了 Prisma 层,数据库层面就有关系约束,不会出现“User 删了但 Resource 还在”的脏数据。这比在业务代码里手动判断要可靠得多。
第三,感知一个非常重要的点:Prisma 的模型变更必须走 migration,而不是改完 schema 直接跑。
npx prisma migrate dev --name add_resource_table这条命令会做三件事:对比 schema 和当前数据库、生成 SQL 迁移文件、执行到数据库并重新生成 Prisma Client。如果你的环境里已经有其他表,迁移会同步更新而不会破坏历史数据。这里我强烈建议把prisma migrate dev当成唯一的 Schema 变更入口,禁用掉以往“手动去数据库改字段”的习惯。否则下次跑迁移时,Prisma 会检测到 drift(漂移),然后报一个让你怀疑人生的 schema 不一致错误。
4.2 tRPC Router:把接口写成本地函数
有了数据库模型,下一层就是定义 tRPC Router。这层是 t3code 的业务核心,也是全栈类型安全的源头。我贴一段Resource路由的实际代码:
// src/server/api/routers/resource.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const resourceRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ category: z.string().optional() })) .query(async ({ ctx }) => { const resources = await ctx.prisma.resource.findMany({ orderBy: { createdAt: "desc" }, }); return resources; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), category: z.string().min(1), description: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.resource.create({ data: { title: input.title, category: input.category, description: input.description, createdById: ctx.session.user.id, }, }); }), });看到这里你可能会想:这跟直接在 Next.js 的 route handler 里写 Prisma 不是差不多吗?表面上很像,但有三处本质区别:
一是protectedProcedure已经内置了登录校验。如果 Session 不存在,tRPC 会直接抛 401,根本不会走到你的业务函数内部。这天然规避了“忘了检查登录态”的安全漏洞。
二是ctx.prisma是通过依赖注入挂载到上下文里的。测试的时候可以换成 mock 对象,模块耦合度比“import 一个全局 Prisma 单例”低很多。
三是前端调用时不需要写 URL、查文档、拼参数。在 React Server Component 里直接这样写:
// src/app/resources/page.tsx import { api } from "~/trpc/server"; export default async function ResourcesPage() { const resources = await api.resource.list({ category: "internal" }); return ( <ul> {resources.map((r) => ( <li key={r.id}>{r.title}</li> ))} </ul> ); }这段代码里api.resource.list的入参类型、返回类型都是从后端 Router 自动推断的。假如我在后端把返回值字段从title改成name,前端这边只要一保存,r.title就会立刻标红报错。这种感觉用习惯之后,真的不想回去手写接口联调了。
4.3 前端可变数据操作:React Query 带来的默认收益
t3code 的客户端交互部分,我用了@tanstack/react-query配合 tRPC 的客户端封装。这里不展开讲 React Query 的全部用法,只说我踩过的一个价值很大的坑:不要在页面组件里直接调用 mutation,要包一层useUtils()来做缓存失效。
举个实际例子。用户在前端创建一条资源记录之后,如果不做任何处理,列表数据是不会自动刷新的,因为 React Query 默认认为缓存的查询结果是“新鲜的”。正确的做法是在 mutation 成功后使列表失效:
const utils = api.useUtils(); const createResource = api.resource.create.useMutation({ onSuccess: async () => { await utils.resource.list.invalidate(); }, });这段代码的作用是:mutation成功后,让所有resource.list查询重新跑一遍。你可以理解为把浏览器里这一页数据标为过期,下次渲染时自动重新获取。实际体验上,创建完资源后列表基本是“秒更新”,没有闪烁和手动刷新。
如果你用的是 Server Component 做数据获取,还要额外注意一点:服务端组件里直接用api.*查数据,它不受 React Query 缓存管理,而是每次都直接查数据库。所以对于那些高频读、不常变的数据(比如下拉框选项、配置字典),我建议放在客户端组件里配合staleTime做缓存,否则每次页面切换都会打一次数据库。我在 t3code 早期就吃过这个亏,页面加载看起来很快,但数据库压力一直居高不下,加了staleTime之后 QPS 直接降下来了。
5. 认证接入与权限控制:NextAuth 怎么和 tRPC 协作
5.1 GitHub Provider 接入和 Session 处理
t3code 的认证模块用的是 NextAuth(Auth.js),我先接了 GitHub Provider,把代码托管平台作为身份源。这个选择纯粹是为了省事:团队里每个工程师都有 GitHub 账号,不需要单独建用户体系。
配置文件的路径在src/server/auth.ts:
import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { db } from "~/server/db"; export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(db), providers: [GitHub], callbacks: { session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }, }, });有一个细节我要画个重点:默认情况下列表里的session.user只有 name、email、image 三个字段,没有 userId。但 t3code 很多业务逻辑(比如“创建资源”时记录createdById)都需要知道当前登录用户是谁。所以我在session回调里把user.id塞进session.user.id。很多新手照着教程做,页面能登录,但一写 mutation 发现ctx.session.user.id是 undefined,就是这个回调没配。
PrismaAdapter 的作用是把 NextAuth 的 User、Account、Session 等数据模型映射到数据库。前面schema.prisma里我写过的accounts、sessions关系字段,就是配合这个 Adapter 用的。它做的事情是:用户点 GitHub 登录后被重定向回网站,服务器用 code 换取 GitHub 用户信息,然后通过 Adapter 在数据库里创建或更新 User 记录。
5.2 两种路由保护方式:中间件和 protectedProcedure
权限控制不只“登录用户”这一层,t3code 里我还区分了“页面访问权限”和“接口访问权限”。这两个场景的解决方案完全不同:
- 页面访问权限:用 Next.js 中间件实现。用户没登录就访问
/resources,直接重定向到登录页。 - 接口访问权限:用 tRPC 的
protectedProcedure实现。页面加载后客户端调接口,如果 Session 失效,会直接得到 401。
中间件代码在项目根目录的middleware.ts:
export { auth as middleware } from "@/server/auth"; export const config = { matcher: ["/resources/:path*", "/admin/:path*"], };这里你看到export { auth as middleware }这种写法,是 NextAuth 官方提供的语法糖,它会让auth()实例检查每个匹配路径的请求里有没有合法的 Session Cookie。没登录就直接重定向到/api/auth/signin,不需要你自己写一堆 next/header 的判断逻辑。
虽然中间件很方便,但我要提醒一句:中间件只保护页面路由,不保护 API 路由。前端发起的请求如果是跳到 Next.js API route(不是 tRPC),中间件不会自动拦截。所以更稳妥的兜底思路是:涉及数据操作的内容,永远在protectedProcedure层做校验,而不是指望页面中间件“挡住一切”。我做 t3code 时有一次就是因为在页面级别加了中间件,但一个 NextAuth 回调接口没有保护,被一个同事用 URL 直接访问到了,还好只是测试数据。自那以后我遵循一个原则:页面访问可以用中间件优化体验,数据安全必须下沉到调用层。
6. 生产部署与性能调优:从本地到线上的一次稳健起飞
6.1 Vercel 与数据库独立部署的组合拳
t3code 的部署架构很简单:应用前端和 API 层统一跑在 Vercel 上,数据库不托管在 Vercel,而是单独挂在托管 Postgres(我用的是 Neon 的免费档)上。这个分离很关键,因为你不可能把数据库跟 Next.js 一起打包成无状态函数,最好的姿势就是让应用层“无状态”,数据库专注持久化。
部署前必须做的一件事是设置环境变量。DATABASE_URL、NEXTAUTH_SECRET、GITHUB_CLIENT_ID、GITHUB_CLIENT_SECRET,缺一不可。我在这里踩过一个印象很深的坑:本地一切正常,推到 Vercel 构建也没报错,但一访问首页就 500。排查了一圈,结果是DATABASE_URL在 Vercel 环境变量里被配置成了剪贴板复制时带了换行符,Prisma 初始化直接因为 URL 解析失败崩了。所以我的建议是:环境变量配置完,在 Vercel 项目的 Storage 日志里先打一条测试记录,确认能正确读取再跑应用。
构建命令不需要特殊改,t3code 沿用 Next.js 默认的next build即可。但有一点要注意,Prisma Client 在构建时会上传 engine 到函数环境,如果你用的是国内机器或者网络受限环境,这一步可能会很慢甚至失败。我的应对方式是:把 Prisma 的二进制文件缓存目录添加进.npmrc或者设置PRISMA_ENGINES_MIRROR镜像地址,这能省不少时间。
6.2 性能调优:我这几个“无脑能抄”的配置
部署之后我在 t3code 上做了一些性能侧的实际调优,主要是三类操作:
第一,把首页从动态渲染改为静态生成。如果首页只是展示营销内容,不依赖用户登录态,完全可以在构建时生成静态 HTML。t3code 的做法是:在页面的布局里不调用任何auth()或cookies(),保持组件不依赖动态 API。Next.js 会自动判定为静态页面,构建输出里能看到○ (Static)标记。
第二,数据层面的预取和缓存。客户端的列表查询我给 React Query 设置了staleTime: 60_000,让用户在 60 秒内访问同一列表不会重复发请求。服务端的 tRPC 调用我则在 API 层接了一个自定义的Cache-Control头,允许 CDN 缓存公共数据。
第三,Supabase/Neon 这类 Serverless Postgres 的连接并发问题。Next.js 跑在 serverless 环境时,每个函数实例都会尝试建立新的数据库连接,如果连接数打满,页面就会报 “Too many connections”。t3code 用了 Prisma 官方推荐的连接池配置:
// src/server/db.ts import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient({ log: ["error"], }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;这个“global 变量缓存单例”的写法,在 next dev 热重载频繁的环境下能避免重复实例化导致连接泄漏。我一开始跳过了这个细节,导致本地热更新半小时后PrismaClient疯狂报 Already running 错误,后来才知道这是 Next.js + Prisma 的经典问题,没有捷径,就是照抄上面这段。
7. 日常开发里的常见问题与避坑手册
7.1 我遇到最多的 5 个报错和解决路径
这节我整理一份高频问题速查表,都是实际运行时大概率碰到的。排查思路比答案更重要,但这里我把两件事都说清楚。
| 报错信息 | 原因 | 解决路径 |
|---|---|---|
Type '"xxx"' has no properties in common with type 'QueryOptions' | tRPC 的query和mutation参数没写对,可能是查的数据类型不匹配 | 检查.input()是否用了 Zod 校验,且参数名和前端调用时一致 |
Error: PrismaClientInitializationError | 数据库连不上或 URL 配置错误 | 在服务器上手动跑psql $DATABASE_URL验证;检查.env是否加载 |
NEXT_REDIRECT页面一直跳登录 | NextAuth 中间件干扰了公共页面 | 调整matcher配置,只在受保护路径生效 |
React 19: useMemo is not a function | tRPC 客户端版本和 React 版本不匹配 | 升级@trpc/*和@tanstack/react-query到兼容版本 |
Typeerror: ctx.session.user is undefined | session回调没把 user.id 挂上去 | 在next-auth.d.ts里扩展 Session 类型,并在 callback 里手动赋值 |
第一类报错是新手重灾区。tRPC 的类型系统非常严格,query方法如果指定了input,前端调用时必须要传入匹配的 Zod 类型对象。有一次我把input定义为{ id: z.string() },前端组件里传了{ id: number },编辑器红了一片,TypeScript 直接把“类型不匹配”的所有坑全亮出来了。这种体验一开始有点烦,但确实能拦下很多低级的联调 bug。
第三类问题我在两个项目里都遇到过。症状是:某个页面明明在matcher之外,但访问时还是会跳到登录页。后来发现是middleware.ts里还 import 了auth的完整配置,导致 NextAuth 实例对每个请求都做了 session 解析,而 session 的 secret 没配一致,于是把请求当成了未登录。解决方法是:中间件里尽量用轻量的auth导出,不要把整个getServerSession逻辑塞进去。
7.2 几个避免的“优雅写法”陷阱
除了报错,还有几类写法表面很优雅,实则会坑你:
第一个是过度使用any。很多人为了让 tRPC 类型“闭嘴”,会在前端把 response 强制断言成any。这相当于把整个类型安全的价值清零。不要这么做,如果类型不对,就花十分钟追根溯源,通常问题出在 Schema 设计或 Router 入参上。
第二个是在 Server Component 里调用signIn。NextAuth 的signIn是客户端函数,你在 RSC 里直接调用会报 “Server Component cannot use client-only function”。正确姿势是创建一个带有"use client"的按钮组件,把signIn("github")放进去。
第三个是每个页面都调db.query而不是走 tRPC。T3 Stack 的设计初衷是都走 tRPC 这层管道,如果直接在每个页面里import { db } from "~/server/db"然后写 Prisma 查询,你会发现代码确实简洁了,但缓存、鉴权、日志审计这些横切逻辑全部丢失。t3code 里我定了一条规矩:只有极少数零鉴权的静态数据才允许在 RSC 里直接查询,其余一切走 Router。
7.3 关于团队规范:t3code 沉淀下来的一条开发流程
这个部分不是代码问题,但我觉得比五个报错都重要。t3code 跑顺之后,我总结出一条适合小团队的开发流程,现在写在我们项目的 README 第一页:
- 先在
schema.prisma里设计数据模型,跑prisma migrate dev。 - 在
routers/下新增 Router,用 Zod 定义输入,用query和mutation暴露能力。 - 在
src/trpc/server.ts注册 Router,同时给app目录里的 Server Component 直接调用。 - 前端页面只负责接收数据、渲染交互;写 mutation 时一定配
onSuccess的缓存失效。 - 提交前跑一次
prisma generate和tsc --noEmit,确保类型全部通过。
这样一套流程走下来,基本每一层都是“先有类型,再写页面”,而不是“页面写完了再回头补类型”。新同事只要照着顺序走,不需要先啃完所有框架文档,也能在一天内上手开发。
写在最后的个人体会
t3code 这个项目做到现在,我的体会是:T3 Stack 不是那种“看一眼就惊艳”的框架,它的爽点需要在完整跑完一个业务需求后才会浮现。当你习惯了“前端改一个字段类型,全链路立刻报错提示”这种反馈速度,再回头接手传统的 REST 项目,会有一种裸奔的错觉。
如果你照着这篇文章的思路搭一个自己的项目,我建议第一版别追求完美目录结构,先把“数据库 → Router → 页面”这条最小闭环跑通,哪怕是做一个只有两张表的 To-Do List。跑通之后,再逐步加入认证、缓存和部署层面的优化。这比第一次就尝试设计一个完美模型要实际得多。
最后分享一个从 t3code 里顺手攒下的小技巧:当你准备提交代码前,花十秒钟看一眼tsc --noEmit的输出,如果它通过,你的开发体验就会有质的提升。这句话我每条 PR 的描述里都会写,也是这个项目和代码层级结构给我最大的回馈。