从一次前后端联调到 T3 Stack:我为啥把整个项目推倒重来
上周我刚把一个内部后台管理系统彻底重构了一遍。重构的导火索说出来有点丢人——后端同事改了接口返回的字段类型,从string改成了number,API 文档倒是更新了,可前端代码没跟上。上线当天数据全变成 NaN,用户那边差点炸锅。
这种"后端改了、前端不知道"的戏码,在传统前后端分离的开发模式下基本是宿命。我后来下定决心,把整个技术栈换成了T3 Stack——也就是 Next.js + TypeScript + tRPC + Prisma + Tailwind CSS 这套组合。这三个月的实测下来,我最直观的感受是:类型终于从数据库一路通到了页面组件,中间任何一环改了结构,编译器当场就会把我拦住,而不是等上线了再报错。
这篇东西不是官方文档的复述,而是我这一轮重构里实打实走完的流程:从为什么选这套架构,到具体怎么搭、怎么做权限、怎么部署,每一步的取舍和踩坑我都会讲清楚。如果你是个全栈开发者,或者正在带一个小团队做中后台产品,这篇应该能帮你少走不少弯路。
1. 为什么我会从"前后端各写各的"转向 T3 Stack 这辆"类型大巴"
先说说我原来的状态。以前我的项目是标准的三层结构:React 前端一个仓库,Node.js 后端一个仓库,中间通过 REST API 通信。前后端各有一套类型定义,前端手写接口类型,后端用 Swagger 生成文档。听起来挺规范对吧?但实际维护起来,类型定义经常不一致,接口文档更新滞后。
有一次我在联调时发现,后端把GET /api/products返回的price从字符串改成了数字,但响应文档里忘更新了。前端拿着旧文档解析,页面渲染时price.toFixed(2)直接崩溃。这种问题在大型项目里几乎每周都会出现,而且排查成本特别高——你要从前端调用处一路查到后端的类型定义,才能确定到底是哪一环出了岔子。
T3 Stack 的解决思路完全不同。它不把前后端当两个独立世界,而是把整个应用看作一个共享类型的整体。数据库表结构由 Prisma Schema 定义,类型自动生成;tRPC 在前后端之间传输数据时直接复用这些类型;Next.js 的页面组件拿到数据后,TypeScript 编译器会校验整个链路。后端改字段类型,前端立刻编译报错,改都改不过去。
还有一个我特别看重的点:T3 Stack 不是一个新的框架,它是一套"最佳实践组合"。每个成员都是各自领域里成熟的技术,你不用学习什么新的 DSL 或运行时,把你已经会的 Next.js、TypeScript、Prisma 拼在一起就能用。这对团队的接纳成本非常友好,我基本没花额外时间培训,组里的人看着文档就能上手。
这套组合适合谁?我个人判断是:中小型团队的全栈项目、独立开发者的 MVP 产品、以及一切"前后端都由同一拨人维护"的场景。如果你所在团队是几十人规模、前后端严格分离,那 T3 Stack 的收益会打折——它更像是一个高效工具,而不是银弹。
2. 全家桶里的每一位:T3 Stack 的核心成员各自在扛什么活
很多人第一次看到 T3 Stack 的组成,会误以为它就是又一个"大而全的框架"。其实拆开看,每个成员都只解决一个特定的问题,组合在一起才形成了完整的闭环。
2.1 Next.js 和 TypeScript:T3 的地基,但不是唯一的可选方案
T3 Stack 的默认脚手架是 Next.js,选它有两层考虑。一是服务端渲染和静态生成能力,让中后台页面也能拿到不错的首屏性能;二是它的文件路由和 API 路由让项目结构非常清晰。不过要注意,T3 Stack 的精神不是绑死 Next.js——它更强调 TypeScript 作为全栈共享的类型底座,你完全可以把 tRPC 装进一个 Vite + Express 的项目里,只是默认模板给了你更顺手的配置。
TypeScript 在整个 T3 里不是"可选的静态检查工具",而是沟通前后端的统一语言。后面你会发现,类型定义不只在编辑器里提示,还会参与到运行时逻辑里,比如 tRPC 的输入校验就是靠 zod 配合 TypeScript 推断完成的。
2.2 tRPC:整条类型安全链路里最"反直觉"的一环
tRPC 是整个 T3 Stack 里最值得单独拎出来讲的角色。传统的 API 调用方式是:前端发 HTTP 请求,后端路由处理,返回 JSON。tRPC 完全不同——它不定义 URL,不定义 HTTP 方法,你只是在后端导出一个函数,然后在前端直接调用这个函数。
听起来像 RPC(远程过程调用),但 tRPC 的独特之处在于不需要代码生成、不需要 schema 文件。它靠 TypeScript 的类型推断实现前后端类型同步:后端函数的入参和返回值类型会自动流转到前端调用处。这不是"代码生成一份类型文件",而是编辑器和编译器直接基于后端代码推断出前端能用的类型。
举一个我刚重构时的实际例子。以前我写接口要这样:
// 后端 app.get('/api/products/:id', async (req, res) => { const product = await db.product.findUnique({ where: { id: req.params.id } }) res.json(product) }) // 前端 interface Product { id: string; name: string; price: number } fetch(`/api/products/${id}`).then(r => r.json()).then((data: Product) => ...)用 tRPC 之后,后端定义:
// 后端 router getProduct: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.product.findUnique({ where: { id: input.id } }) })前端调用处直接用 infer 出来的类型:
// 前端 const { data } = trpc.product.getProduct.useQuery({ id: productId }) // data 的类型自动就是 Prisma 返回的 Product | null这是 T3 Stack 里最有魔力的体验:数据从数据库出来,经过 Prisma 类型生成,再被 tRPC 包裹传输,最后在 React Query 的useQuery里拿出来,类型一路无损。我写代码时再也不用去翻 API 文档了,编辑器里直接能看到返回字段。
2.3 Prisma 和 Tailwind CSS:一个管数据底座,一个省掉样式焦虑
Prisma 是 T3 Stack 默认的数据库工具,它解决的是"数据库表的类型如何进入 TypeScript 世界"这个问题。你写一个schema.prisma文件定义模型,然后跑一次prisma generate,编译器就自动生成了完整的 CRUD 类型。字段改了,类型跟着改,前后端同时报错。
Tailwind CSS 在 T3 Stack 里看起来最没那么"技术核心",但它的意义不可小觑。T3 的理念是:样式不应该成为全栈类型链路的破坏者。你用 Tailwind 的类名做原子化样式,不会引入新的组件类型系统,也就不会造成类型链路的中断。对我这种中后台项目,Tailwind 的迭代速度确实比写传统 CSS 快很多。
下面是五个成员在我项目里的分工一览:
| 成员 | 核心职责 | 替代方案(如果你不想用) |
|---|---|---|
| Next.js | 应用框架、路由、SSR/SSG | Vite + Express、Remix |
| TypeScript | 全栈类型底座 | 无(T3 的根基) |
| tRPC | 前后端函数调用与类型共享 | GraphQL(但需要代码生成) |
| Prisma | 数据库访问与类型生成 | Drizzle ORM、TypeORM |
| Tailwind CSS | 原子化样式 | CSS Modules、styled-components |
3. 从零搭建 T3 项目的完整流程:命令行 5 分钟,功课在后面
T3 Stack 官方提供了一个脚手架工具create-t3-app,跑一下就能生成完整项目。但脚手架只是开始,真正的理解在于知道每个文件在干嘛、哪些配置容易踩坑。
3.1 初始化命令与选项的取舍
创建项目的命令很简单:
npm create t3-app@latest my-app接下来会问你选哪些可选模块,默认提供的是:Next.js、TypeScript、tRPC、Prisma、Tailwind。额外的还有:
- NextAuth.js:认证方案。如果是需要登录的系统,默认建议勾上。
- Drizzle:另一个 ORM,可以对它说"No"(我用的 Prisma)。
- ESLint / Prettier:也可以在这里配置,或者装到 CI 里一起管。
我的建议是:第一个项目先全选默认;跑通之后你才会对各部分有实感,再考虑删减。如果你知道自己的项目根本不需要数据库(比如纯静态展示站),那 Prisma 和 tRPC 都可以不勾,但 T3 的真正价值就在数据链路,砍掉就没必要用它了。
装好之后,npm run dev就能起来,首页是脚手架自带的引导页面。下一步才是重点:初始化数据库和认证配置。
3.2 目录结构中必须搞懂的四个文件
生成的目录结构不算复杂,但有几个文件我强烈建议你认真看一遍:
src/server/api/trpc.ts:创建 tRPC 上下文(context)的核心文件。这里定义了每个请求进来时传入的数据,比如prisma实例、session会话。权限控制的根就在这。src/server/api/root.ts:路由聚合器。你写的所有子 router(比如productRouter、userRouter)最终都汇集到这个appRouter里。src/trpc/react.tsx:前端 tRPC 客户端配置。它把 tRPC 和 React Query 绑定在一起,前端所有调用都通过这个文件导出的trpc对象。prisma/schema.prisma:数据库模型定义。改表结构的第一站。
这个结构最核心的设计是:后端 router 和前端调用共享同一个AppRouter类型。定义好后端 router,前端的trpc对象就自动获取了所有 procedure 的签名。这就是没有代码生成但类型完全同步的原因。
3.3 .env 与环境变量的管理细节
脚手架生成的.env文件里默认有DATABASE_URL,如果你勾了 NextAuth,还会有NEXTAUTH_SECRET和NEXTAUTH_URL。第一次跑项目时最容易忽略的是这些变量没配齐,启动后直接黑屏。
我的习惯是:
- 本地开发时
.env直接填本地数据库连接,比如 PostgreSQL 写postgresql://user:password@localhost:5432/mydb NEXTAUTH_SECRET至少 32 位随机字符串,可以用openssl rand -base64 32生成NEXTAUTH_URL在本地填http://localhost:3000,部署到生产再改成正式域名
环境变量是第一个常见的"坑",因为 tRPC 和 NextAuth 会在服务端初始化时读取这些变量,缺一个就会报不太直观的错误(比如"Missing NEXTAUTH_SECRET")。后面我会专门讲认证部分的坑。
4. 类型安全主链路实操:从 Prisma Schema 一路通到 React 页面
理解归理解,真正上手跑一遍才算是掌握。这个章节我带你走一遍完整链路:定义数据模型、创建查询、在页面上消费。
4.1 定义 Prisma 模型并生成类型
假设我们要做一个简单的商品管理系统,先在prisma/schema.prisma里定义一个Product模型:
model Product { id String @id @default(cuid()) name String price Int stock Int @default(0) category String createdAt DateTime @default(now()) }跑一次迁移:
npx prisma migrate dev --name init npx prisma generatemigrate dev把表建到数据库里,generate生成 TypeScript 类型。如果你用的编辑器有 Prisma 插件,你会在client上直接看到所有字段的自动补全。
4.2 创建 tRPC Router 与 Procedure
在src/server/api/routers/product.ts里建立商品相关的查询:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const productRouter = createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.product.findUnique({ where: { id: input.id }, }); }), list: publicProcedure .input(z.object({ category: z.string().optional() })) .query(async ({ ctx, input }) => { return ctx.prisma.product.findMany({ where: input.category ? { category: input.category } : undefined, orderBy: { createdAt: "desc" }, }); }), updateStock: publicProcedure .input(z.object({ id: z.string(), delta: z.number() })) .mutation(async ({ ctx, input }) => { return ctx.prisma.product.update({ where: { id: input.id }, data: { stock: { increment: input.delta } }, }); }), });注意到publicProcedure这个命名——它是 tRPC 的"公开接口"标签。后面要做登录校验时,会把它换成别的受保护 procedure。
然后把productRouter挂到聚合 router 里:
// src/server/api/root.ts import { productRouter } from "~/server/api/routers/product"; export const appRouter = createTRPCRouter({ product: productRouter, });注意ctx.prisma这个对象——它是从trpc.ts里通过上下文注入的。每个数据库模型都会自动暴露为ctx.prisma上的同名属性,类型也是 Prisma 生成的,所以这里完全不需要手写接口层。
4.3 前端组件里的消费方式
在任意 React Server Component 或者客户端组件里,都可以这样:
"use client"; import { api } from "~/trpc/react"; export function ProductCard({ id }: { id: string }) { const { data, isLoading } = api.product.getById.useQuery({ id }); if (isLoading) return <div>加载中...</div>; if (!data) return <div>商品不存在</div>; return ( <div> <h2>{data.name}</h2> <p>价格:{data.price}</p> <button onClick={() => mutateStock(-1)}>减库存</button> </div> ); }data的类型是Product | null | undefined,字段全是 Prisma 生成的类型。调一个 mutation 也一样:
const utils = api.useUtils(); const { mutate } = api.product.updateStock.useMutation({ onSuccess: () => utils.product.list.invalidate(), });useUtils是 React Query 的封装,invalidate可以让列表在数据变更后自动刷新。配合 TypeScript 的类型提示,我基本没翻过文档,全靠编辑器提示。
这一整套链路跑通后,我最大的体验是:当 tRPC procedure 的输入参数或返回类型变化时,前端所有用到它的组件都会立刻爆红。有一次我想给商品加上weight字段,改了 schema、跑了 migrate,回头刷新页面,编辑器就警告我在某个组件里访问了未定义的字段。这种"编译器替我抓 bug"的感觉,是传统 REST API 给不了的。
5. 认证与权限:NextAuth 接进 tRPC 的完整踩坑记录
绝大多数业务系统都绕不开登录和权限。T3 Stack 默认推荐 NextAuth.js,我没有用别的方案,主要因为它和 Next.js 的集成是官方一级公民。但这块的坑真的一个接一个。
5.1 NextAuth 在 T3 项目里的初始化
脚手架已经把 NextAuth 的基础代码生成了,核心文件在src/server/auth.ts。你需要做的第一件事是配好 providers。因为不是所有公司内部都允许用 Google 或 GitHub OAuth,我这次用的是 Credentials Provider(账号密码登录),配合数据库里存的用户表。
Credentials Provider 的典型写法:
import Credentials from "next-auth/providers/credentials"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "~/server/db"; export const authOptions = { adapter: PrismaAdapter(db), providers: [ Credentials({ credentials: { email: { type: "email" }, password: { type: "password" } }, authorize: async (credentials) => { const user = await db.user.findUnique({ where: { email: credentials?.email }, }); const passwordOk = await bcrypt.compare( credentials?.password ?? "", user?.passwordHash ?? "", ); if (user && passwordOk) return user; return null; }, }), ], session: { strategy: "jwt" }, secret: process.env.NEXTAUTH_SECRET, } satisfies NextAuthOptions;这里有个关键选择:session.strategy用"jwt"还是默认的"database"。我用的是 JWT,因为前后端调用比较频繁,每次查数据库做 session 校验会拖慢 tRPC 的响应。不过 JWT 意味着你需要在sessioncallback 里把用户 ID 塞进 token,否则后面 tRPC 里拿不到用户信息。
5.2 在 tRPC 里写"只有登录用户才能调用"的 procedure
这一步是 T3 权限系统的精髓。在trpc.ts里,脚手架会默认生成一个protectedProcedure,它就是在publicProcedure的基础上加了一层会话检查:
export const protectedProcedure = publicProcedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { user: ctx.session.user } }, }); });注意要点:ctx.session来源是trpc.ts里定义的 createContext,它会把 NextAuth 的 session 塞进去。加了这层检查后,未登录用户调用某个 procedure 会直接拿到UNAUTHORIZED错误,前端可以用trpc的useQuery错误状态来引导跳转登录页。
举两个实际场景:
- 公开接口:商品列表、详情,用
publicProcedure - 私有接口:修改库存、更新商品信息,用
protectedProcedure
这样在同一套代码里实现了"部分接口登录可见"的粒度控制,比传统后端架构里写一堆@RequireAuth装饰器更直白。
5.3 我在 NextAuth 集成中踩过的三个典型坑
坑一:NEXTAUTH_SECRET没设成生产环境的随机值。本地开发时很多人懒得起,结果部署后 session 一会失效一会正常。我后来统一用环境变量管理,并在代码里强制检查:
if (!process.env.NEXTAUTH_SECRET) { throw new Error("Missing NEXTAUTH_SECRET"); }坑二:Credentials Provider 下PrismaAdapter的行为差异。NextAuth 的官方文档指出,Credentials Provider 配合数据库 session strategy 时有一定限制。用 JWT strategy 就没这问题。我第一次用默认的databasestrategy,登录后刷新页面,session 偶尔能保持、偶尔丢失,查了半天才发现是适配器和 provider 的兼容问题。
坑三:tRPC 的ctx.session类型和 NextAuth 的session类型不完全一致。尤其当你自定义了 session callback 里的user.id时,TypeScript 默认的Session类型并没有这个属性。需要在src/types/next-auth.d.ts里做模块类型增强:
import "@auth/core/jwt"; import type { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user?: { id: string } & DefaultSession["user"]; } }类型增强没写对的话,protectedProcedure里访问ctx.session.user.id会提示类型缺失。这类问题没有运行时报错,只有编辑器报错,很容易被忽略。
6. 部署到生产环境的那些细节:Vercel、Prisma 连接池和 React Query 缓存
本地跑通只是第一步。T3 Stack 的部署我首选 Vercel,因为它们全家都是 Next.js 生态。但有几个问题需要提前处理好,否则上线必出状况。
6.1 Prisma 在 Serverless 环境下的连接池问题
Vercel 的 Serverless 架构意味着每个请求可能运行在独立的函数实例上。如果 Prisma 每次初始化一个新连接,数据库连接数很快会被打爆。
解法是用连接池,并且保证 Prisma Client 是单例。脚手架其实已经帮忙处理了——生成的src/server/db.ts用的是全局单例:
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;但DATABASE_URL需要按数据库服务商的要求拼接,比如 Vercel Postgres 的连接串里要加?pgbouncer=true,Prisma 才能走 PgBouncer 连接池。这个细节不写清楚,上线一有流量就会一堆TimeoutError。
6.2 React Query 缓存策略对前端体验的影响
tRPC 的客户端基于 React Query,默认有缓存能力。但不接 SSR 的话,用户首次访问时会先看到 loading 状态,再拿到数据。T3 项目里可以开启服务端预取(server-side prefetch),让首屏渲染时数据就已经在页面里。做法是在 Server Component 里执行api.product.list.prefetch(...),然后把trpc的 hydration 包在页面外层。
我实测下来,开启 prefetch 之后,商品列表页的 Lighthouse 首屏性能从 72 上升到 92,效果非常直观。原因很简单:服务端在返回 HTML 时已经把数据序列化进去了,浏览器不用再额外请求一次。
6.3 环境变量和构建脚本的准备清单
上线前检查这几个地方:
NEXTAUTH_URL改成正式域名,否则回调地址会乱。- 数据库迁移脚本要在部署流程里执行。Vercel 的构建阶段可以跑
prisma migrate deploy,但要注意它不会自动处理所有问题,特别是字段变更冲突。 .env.production里的变量不要和.env混在一起。我曾在本地调试时把生产库的 URL 误填到.env,结果本地跑了一次迁移,差点污染了生产数据。
一个比较稳妥的做法是把数据库迁移放到 CI 流程里,部署前先跑prisma migrate deploy并 check 通过,再触发 Vercel 构建。这样生产环境的数据结构变更永远是前置完成的,不会出现"页面起来了但表不存在"的尴尬。
如果不用 Vercel,也可以用 Docker 打包 Next.js 应用部署到自己的服务器,但那样就要自己处理 NextAuth 的 secret、Prisma 的数据库连接和反向代理。我建议非必要不上这种复杂度,团队小的时候,托管平台的省心程度对研发效率影响很大。
7. 几件看起来不起眼却让我省下不少时间的小事
最后再分享几个我在这套工作流里得到的实际体验。
第一件是tRPC 的输入校验顺手帮我挡掉了很多脏数据。以前写接口,参数校验基本靠手写 if 判断,漏一个就等着生产环境出 bug。tRPC 用 zod 做 input 校验是声明式的,所有字段规则都写在 procedure 定义旁边,一眼能看到全部约束。前端也能通过类型推断知道哪些参数是必填的,编辑器直接提示。
第二件是Prisma 的invalidate机制改变了我的状态管理思路。以前我写前端要维护各种全局 store,数据变了还要手动同步。现在只要在 mutation 成功后调用invalidate,React Query 会自动重新拉取相关查询,UI 的更新逻辑变得非常集中。至少在我的项目里,useState + 手写事件同步的模式已经几乎绝迹了。
第三件是关于团队协作的意外收获。T3 Stack 因为类型全链路贯通,Code Review 时看 diff 也变得高效了。以前 review 接口改动要同时看后端路由、Swagger 文档、前端类型定义三处,现在只要看 tRPC router 的 diff,编辑器会同步标出所有受影响的调用点。我可以直接告诉同事"你改了Product.price类型,前端这三处组件需要一起调",配合起来顺畅很多。
在使用 T3 Stack 这几个月里,我最享受的一点是:前后端之间的"契约"是活的,不是写在任何文档里的死文字。数据库表结构变了,类型跟着变;tRPC router 改了,前端立刻报错;NextAuth 加了权限,被保护接口在编辑器里就明显不同。这种贯穿全链路的"安全感"一旦体验过,再回去写传统的前后端分离项目,真的会觉得哪里都缺点东西。
当然,T3 Stack 并不完美。如果你的项目有多个后端服务、需要对外提供一套公共 API,tRPC 这种"头对头"的调用模式就不太合适,那时你可能需要 GraphQL 或 REST 去支撑更复杂的服务边界。但只要是单体全栈应用、前后端同仓或同团队维护,我的建议是:认真试一次 T3 Stack,让编译器替你多扛一点。