我印象非常深,第一次用 tRPC 写完博客项目里“提交评论”这个功能时,前端代码里没有写任何 fetch,也没配 axios 拦截器,就是像调用本地函数一样,把数据存进了数据库。鼠标悬停在那行代码上,后端返回的字段类型清清楚楚列在眼前。那一刻我真切感受到,全栈开发里“前后端对齐”这件事,终于有一条真正省心的路。
这篇想聊的就是承载这套体验的技术栈组合——T3 Stack。简单说,它就是 TypeScript + Next.js + tRPC + Tailwind CSS + Prisma + NextAuth 这一整套工具链,被 create-t3-app 整合成开箱即用的脚手架。很多人乍一听以为 T3 是个新框架,其实它不是框架,而是一组经过实际项目验证、彼此咬合得很紧的技术选型。如果你是一位想用 TypeScript 打通全栈、又不想被接口文档和胶水类型折磨的开发者,或者正在做个人项目、小团队 MVP,那这套方案值得花半小时认真了解一下。
1. T3 Stack 的底层逻辑:先看它为什么能凑到一起
1.1 三个“T”到底代表什么
“T3”这个名字,指的是三个以 T 开头的核心成员:TypeScript、Tailwind CSS、tRPC。它最早来自 GitHub 上的 create-t3-app 项目,在社区里由开发者 Theo Browne 推动,慢慢被整理成一套共识。如果你去翻项目的 README,会看到这段话:T3 Stack 遵循三条原则——解决实际问题,而不是追逐技术潮流;在一切可能的地方做类型安全;绝不强推我们不打算用的技术。
这三条原则看着朴素,但实际使用后会发现它们是整件事的灵魂。第一点说的是工具要为业务服务,不要因为某个框架热度高就硬塞进项目;第二点是 tRPC 和 Prisma 这类工具存在的根本理由;第三点则保证了脚手架不会捆绑一堆你根本不需要的库。create-t3-app 初始化时,NextAuth、Prisma、tRPC 这些模块都是可选勾选的,用不到就不装,项目就保持干净。
我自己对第二点“类型安全优先”深有体会。我们团队以前维护过一个前后端分离的中型项目,后端是 Java,前端是 React,两边各维护一份接口文档,字段一改,前后端就要在群里对半天消息。后来把一个内部后台迁移到 T3 Stack 上,数据库字段改了,编译阶段前端就能看到报错,那种“把问题暴露在编译期而不是上线后”的感觉,用一次就会上瘾。
1.2 每个成员解决的“老问题”
五个核心成员各司其职,每个都精准地落在传统全栈开发的一个痛点上。
- Next.js 是基座,提供前端路由、Server Component、API 路由,让同一套代码既跑浏览器又跑服务端。
- TypeScript 是整个体系的“黏合剂”,没有它,tRPC 的类型穿透就无从谈起。
- tRPC 取代的是 REST 和 GraphQL 那套繁琐的 API 通信层,让前端直接“调用”后端函数。
- Prisma 是数据库访问层,Schema 定义即类型来源,免去写 SQL 映射的重复劳动。
- Tailwind 是原子化 CSS 方案,写样式不需要再纠结 class 命名和样式文件拆分。
传统模式下,前端要写接口请求函数、手写返回类型、处理 loading 和 error 状态;后端要写路由、参数校验、联调测试。T3 Stack 把这一套流程压缩成了“后端定义函数、前端直接调用”,连参数校验都用 zod 在同一个文件里声明,前端调用的入参类型也是自动推断出来的。
这里有个观念需要转变:T3 Stack 把整个应用放在同一个仓库、同一个进程里。对于习惯了前后端分离、独立部署的团队来说,一开始会觉得“这不又退回单体应用了吗”,但实际上它的思路不是“前后端耦合”,而是“前后端共享一套类型系统”,把拆分 API 层带来的沟通成本省掉了。后面我会专门讲它的适用边界。
2. 类型安全的穿透力:从一个 fetch 到一整条链路
2.1 传统请求模式 vs tRPC 调用,差在哪
先看传统 REST 请求的前端代码长什么样:
// 传统方式:手动写请求 + 手动定义类型 interface Post { id: string; title: string; content: string; } const res = await fetch("/api/post/list", { method: "GET" }); const data: Post[] = await res.json(); // 这里的 Post 类型是手写的再看 tRPC 里的用法:
const { data, isLoading, refetch } = api.post.list.useQuery();差异不只是少了几行代码,而是api.post.list这个函数返回什么类型,完全由后端 router 的返回类型推断出来。后端改了返回结构,前端立刻出现类型错误,而不是等接口真正返回了异常数据才发现。对开发体验来说,这是质变。
2.2 类型是怎么从数据库“一路跑”到页面的
我把这条链路拆开,看类型在每个环节是如何传递的:
第一步,Prisma 的schema.prisma里定义模型,执行prisma generate后,会生成完整的 TypeScript 类型,包括Post、User这些模型及其字段类型。
第二步,在 tRPC router 里写 query 和 mutation,返回的数据直接来自 Prisma Client,所以返回类型的根基是 Prisma 生成的类型,不是你手写的 interface。
第三步,tRPC 服务端导出AppRouter类型,前端通过createTRPCReact<AppRouter>()创建带完整类型的api对象。
第四步,React Query 的useQuery拿到这个类型,渲染组件里用到的每一个字段都有自动提示。
这条链路里最妙的一点是:你永远不会出现“后端改了字段名,前端还傻傻地用旧字段”的情况。改动从数据库模型开始,触发 Prisma 类型更新,波及到 tRPC 路由,再传导到前端组件,全程编译期就能发现问题。
我见过一个真实的线上事故:后端把一个字段从status改成state,前端没跟上,结果上线后按钮状态全部异常,用户反馈才排查出来。如果是 T3 Stack 这种模式,这个问题在 CI 阶段就会因为类型不匹配而直接失败。
2.3 别把 tRPC 神化:它的适用边界
刚才夸了它很多,但必须说清楚,tRPC 不是银弹。它最大的前提是:前后端都使用 TypeScript,并且运行在同一个应用进程的“对话”里。如果你要做的是完全公开的第三方 API,比如对外提供数据接口给无数个未知的客户端使用,那 tRPC 反而不合适。外部开发者不会因为你用了 tRPC 就获得类型提示,他们更熟悉的还是 REST 或 GraphQL。
另外,如果你们团队是“前端一组、后端一组”,中间隔着明确的服务边界,那 tRPC 的类型穿透优势也无法发挥,因为类型安全只能覆盖同一套代码库。这种情况更适合把 tRPC 当作内部 BFF(Backend For Frontend)的一层,对外仍然提供标准接口。
所以我的判断是:tRPC 适合中小规模全栈项目、内部后台、个人独立开发,不适合大型多团队多服务的公共接口层。理解了这个边界,你才不会在错误的场景里骂它“不好用”。
3. 实操:用 create-t3-app 从零搭一套带鉴权的文章系统
3.1 初始化项目与核心技术选型
我先跑一次真实的初始化过程,带你看看 create-t3-app 到底给了什么。
npm create t3-app@latest命令执行后,会进入交互式配置界面,选择项目名、包管理器,还有几个可选模块:Next.js、Tailwind CSS、Prisma、tRPC、NextAuth、Docker。我这次勾选了 Next.js、Tailwind、Prisma、tRPC、NextAuth,用 pnpm 作为包管理器。
项目生成后的目录结构长这样:
my-t3-app/ ├── prisma/ │ └── schema.prisma ├── public/ ├── src/ │ ├── app/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ ├── trpc.ts │ │ │ └── routers/ │ │ └── auth.ts │ ├── styles/ │ │ └── globals.css │ ├── trpc/ │ │ ├── react.tsx │ │ └── server.ts │ └── utils/ │ └── api.ts ├── .env └── package.json几个关键文件的职责先理清楚:src/server/api/trpc.ts负责初始化 tRPC 实例、定义公共和受保护的 procedure;src/server/api/root.ts负责把各个子路由合并成appRouter,同时导出AppRouter类型;src/trpc/react.tsx是前端连接 tRPC 的 React 入口;src/utils/api.ts封装了带类型的api对象。
3.2 先定义数据模型:Prisma 里的 Post 模型
打开prisma/schema.prisma,定义一个带作者关联和发布状态的文章模型:
model Post { id String @id @default(cuid()) title String slug String @unique content String published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User? @relation(fields: [authorId], references: [id]) authorId String? } model User { id String @id @default(cuid()) name String? email String @unique passwordHash String? posts Post[] }写完模型后,执行同步命令:
npx prisma migrate dev --name init这条命令会创建迁移文件、同步数据库结构,并重新生成 Prisma Client 类型。这里有个小细节:id字段用cuid()而不是自增整数。因为在我们这种前后端同构的应用里,cuid()可以客户端生成、不需要额外一次数据库往返,而且不会暴露数据量,对需要公开 URL 的场景也友好。
3.3 定义 tRPC 路由:公开列表与登录后发布
接着在src/server/api/routers/post.ts里写文章相关的路由逻辑:
import { createTRPCRouter, publicProcedure, protectedProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { const posts = await ctx.db.post.findMany({ where: { published: true }, orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); return posts; }), create: protectedProcedure .input( z.object({ title: z.string().min(1), slug: z.string().regex(/^[a-z0-9-]+$/), content: z.string().min(1), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { ...input, published: true, authorId: ctx.session.user.id, }, }); }), });然后把它挂进root.ts:
import { createTRPCRouter } from "~/server/api/trpc"; import { postRouter } from "~/server/api/routers/post"; export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter;这里重点说下protectedProcedure是怎么来的。它是对公共 procedure 的一层中间件封装,在src/server/api/trpc.ts里:
const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session } }); }); export const protectedProcedure = t.procedure.use(isAuthed);这样凡是走protectedProcedure的接口,都会先检查登录态。代码读起来也很顺:后端接口的安全策略,直接体现在 procedure 的名字上。
3.4 前端调用:像使用本地函数一样请求 API
前端在src/app/page.tsx里调用刚才定义的路由:
"use client"; import { api } from "~/utils/api"; export default function HomePage() { const { data: posts, isLoading, refetch } = api.post.list.useQuery(); const createPost = api.post.create.useMutation({ onSuccess: () => refetch(), }); if (isLoading) return <div>正在加载文章列表…</div>; return ( <div> <h1>文章列表</h1> <ul> {posts?.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> <button onClick={() => createPost.mutate({ title: "新文章", slug: "new-post", content: "正文内容", }) } > 发布新文章 </button> </div> ); }注意createPost.mutate的参数类型,正是后端 zod 校验器要求的{ title, slug, content },多传或少传字段都会在编译期报错。这就是 tRPC 最直观的价值——不需要写 API 文档,代码本身就是文档。
4. 生产环境跑了一年,踩过的坑与排查清单
4.1 第一个坑:跨边界序列化,Date 对象变成字符串
第一次部署上线后,我发现页面里显示的“创建时间”和预期不一致,仔细排查后才发现是Date对象从服务端传到前端时,被序列化成了字符串,而我的组件代码里还在调用toLocaleDateString()方法。这是 tRPC 默认序列化方案的局限。
解决方法很明确:在初始化 tRPC 时配置 transformer。create-t3-app 默认项目里已经内置了superjson,它能把Date、BigInt、Map这些复杂类型原样还原。如果你手动搭项目,一定要加上:
const transformer = superjson; export const t = initTRPC .context<CreateContext>() .create({ transformer });这个坑提醒我:凡是跨“服务端到客户端”边界的数据,都要考虑序列化兼容。Prisma 返回的字段里有DateTime,如果不用 superjson,前后端实际拿到的类型是不一致的。
4.2 第二个坑:版本升级带来的迁移摩擦
用了一年,中间经历过 tRPC 从 v10 到 v11 的升级,也经历过 Next.js 从 Pages Router 到 App Router 的切换。这两个升级都带来了不小的迁移工作量。
tRPC v11 里,initTRPC的调用方式有变化,很多写法需要从createRouter改成createTRPCRouter,procedure 的中间件类型也变严格了。如果项目里路由多,迁移起来确实费劲。我的建议是:核心业务稳定后,不要频繁追最新版本。工具链的价值在于稳定,不是新鲜。升级前先在分支上做一次全量类型检查,把所有报错列表看一遍,评估出工时才决定是否动。
Next.js 的 App Router 是另一个话题。create-t3-app 新版本默认用 App Router,但很多老的 T3 项目还在用 Pages Router。App Router 带来了 Server Component 和更规范的数据请求方式,但 tRPC 与 Server Component 的配合仍然有一些别扭,尤其是当一个组件既可能服务端渲染又可能客户端运行时,需要自己处理好"use client"标记。
4.3 第三个坑:部署形态与服务边界
我用 Vercel 部署 T3 应用,体验确实顺畅,git push即发布,数据库迁移命令也可以集成进 CI。但用久了就会发现一个隐性问题:Serverless 函数有冷启动和超时时间限制。如果你在 tRPC procedure 里跑了超过 10 秒的复杂查询或批量任务,Vercel 的免费版和低配付费版会直接超时。
所以我的实践是:把 T3 应用定位成“交互密集型 API + 页面渲染”的组合,真正的重型任务放到队列、定时任务或者独立 Worker 里去,不要塞进 tRPC 的 mutation 里。
另一个容易忽视的是数据库连接数问题。Serverless 环境下每个请求都可能会新建一个 Prisma Client 连接,如果不做连接池管理,数据库很容易被打爆。我这里用的方案是 Prisma 官方推荐的accelerate连接池,或者在部署环境里明确设置连接池上限。
4.4 常见问题排查速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
前端调用api.*.useQuery()返回 undefined | App Router 页面忘记加"use client",组件在服务端渲染 | 在需要调用 tRPC hooks 的组件顶部添加"use client" |
| tRPC 路由 404 | root.ts里没有正确合并子路由,或前端与后端AppRouter类型不一致 | 检查appRouter中是否包含对应router实例 |
页面能访问,但ctx.session始终为 null | NextAuth 的 SessionProvider 没有包在根布局里 | 在 App Router 的layout.tsx中使用<SessionProvider>包裹 |
| Prisma 连接池或连接超时 | Serverless 并发请求创建太多连接 | 配置 Prisma Accelerate,或在连接字符串里设置连接池下限 |
| 上传的 Date 类型显示成字符串 | 没有配置 superjson transformer | 在initTRPC.create({ transformer })中传入 superjson |
| Tailwind 样式不生效 | content配置没包含用到的目录 | 检查tailwind.config.ts的content数组里是否包含./src/**/*.{ts,tsx} |
| 打包体积过大 | Next.js 全量打包了不需要依赖 | 检查next.config的transpilePackages和依赖 import 方式 |
这张表是我后来给团队新成员的地图,照着排能省很多时间。
5. 实战后的最终建议:哪些场景适合用 T3 Stack
先直接给结论:T3 Stack 特别适合三类项目——内部管理后台、个人或小团队的全栈项目、以及“快速验证想法”的 MVP。如果你需要在一个晚上从零搭出一个带数据库、带登录、带 CRUD 的 Web 应用,create-t3-app 是目前我试过的最快路径,没有之一。
更适合它发挥的场景是那些“前后端本来就不该拆分成两个仓库”的项目。比如博客、内容管理后台、电商管理端、SaaS 的内部控制台。在这种项目里,你不需要对外开放 API,也几乎没有多端复用问题,用 tRPC 反而省掉了接口层的大量重复劳动。
而如果你的场景是需要提供公共 API 给第三方开发者、需要前后端独立扩展与部署、或者已经有完全独立的移动端和后端团队,那 T3 Stack 不是最优解。这时候用传统 REST 或 GraphQL,配合专门维护的类型生成工具,可能更合适。
最后说一个我个人的习惯:即便是 T3 项目,我也会刻意把业务逻辑从 tRPC 路由里抽出来,比如单独放在src/server/service/目录。路由层只负责接收输入、调用服务、返回结果。这样做的原因很实际——当 procedure 越来越厚,测试会变得非常困难,因为要 mock 一堆上下文。分层之后,核心业务逻辑不依赖 tRPC 实例,可以单独写单元测试,API 层只留一层薄薄的壳。这个习惯是从前一个项目吃了教训才养成的。
回过头看,T3 Stack 让我最满意的地方,不是某个单一工具的性能,而是整套流程的“顺畅感”:改一个数据库字段,整个链路都跟着响应;写一个前端调用,背后自动带着校验和类型提示;部署一个全栈项目,Vercel 加 Prisma 就能搞定。这种顺畅没法用数据量化的指标来概括,但它在实际项目里节省的时间,是切切实实可以感受到的。