最近整理代码仓库,翻出一个我维护了大半年的项目,代号叫 t3code。名字看着有点随意,其实就是 T3 Stack 的实战代码库。T3 Stack 是这几年全栈圈里讨论度很高的一套技术组合,核心成员是 Next.js、TypeScript、tRPC、Tailwind CSS 和 Prisma,用官方脚手架 create-t3-app 一条命令就能拉起来。这套东西解决的真正问题不是"代码能不能跑",而是"前后端之间的类型安全到底能不能贯穿到底"。如果你受够了接口文档跟不上代码、改一个字段要翻七八个文件、前后端联调全靠口头约定,那 t3code 这个项目就是一个完整的参考样本。这篇就基于我的真实维护经历,把这套技术栈从选型到部署,逐个环节拆开讲,包括那些文档里不会写的坑。
1. t3code 到底是什么:先看清 T3 Stack 的设计初衷
1.1 技术栈的来龙去脉与核心哲学
T3 Stack 最早的推动者是 Theodo 社区的开发者,后来因为 create-t3-app 这个脚手架工具的流行,整套组合开始被大量全栈项目采用。T3 这个名字有过争议,早期说法是指 TypeScript、Tailwind、tRPC,后来的官方口径更偏向 "Typesafe, Tailwind, tRPC",把"类型安全"提到了第一位。这个细节很关键,因为它直接解释了为什么这套技术栈会把 tRPC 放在核心位置——它不是让你少写代码那么简单,而是让前后端共享同一套类型定义,从数据库到组件 props 全部走 TypeScript 的静态检查。
我当初做 t3code 的时候,手头有一个比较典型的小团队项目:三个人维护,一个偏前端,一个偏后端,一个两边都沾。过去用 REST 接口,最头疼的不是写接口,而是接口返回结构变了之后,前端根本不知道。后端改个字段名,前端页面就静默崩掉,等用户反馈才排查出来。t3code 本质上就是想验证一个问题:如果"类型定义"成为前后端之间唯一的通信协议,能不能把这类问题从根上消灭。
答案是可以,但前提是你得完全接受这套技术栈的约定。T3 Stack 不是把五个框架硬拼在一起,它把 Next.js 当成应用外壳,TypeScript 是底层语言,tRPC 是前后端通信层,Prisma 管数据库建模,Tailwind 管样式。每一层都围绕"类型"这个核心展开,所以一旦你用了它,就不要想着"我某个接口单独用 REST 实现就行",混搭会把你拖入类型断裂的泥潭。
1.2 这套组合适合什么场景,不适合什么场景
做技术选型最怕"什么火用什么",所以我把 t3code 的适用边界也一并测了一遍。先说适合的:内部工具、后台管理系统、中小型 SaaS 产品、个人作品集、快速原型验证。这些场景有一个共同特征——功能迭代速度远大于团队规模,你需要在极短时间内把数据库表、接口、页面串起来,而且希望业务逻辑尽量集中在服务端,前端只管调用。t3code 的典型路径是:Prisma 定义模型,迁移数据库,tRPC 暴露查询和变更,Next.js 页面直接调用,整个链路带着类型检查,几乎没有"接口联调"这个环节。
不太适合的场景我也踩过:对外开放的大规模 API 平台、需要多端(iOS / Android / 第三方)消费的接口层、复杂微服务架构。原因很简单,tRPC 的强绑定依赖端到端 TypeScript,如果你的客户端根本不是 TS 生态,或者接口需要给公司外部的大量第三方开发者使用,那 REST 或者 GraphQL 依然是更好的契约方案。还有一点,如果你团队里有人对 TypeScript 本身比较抵触,或者数据库模型经常被非开发角色直接修改,T3 Stack 的收益会打折扣。
我在 t3code 的 README 里写过一句话:它不是银弹,它是"单应用全栈型项目"的最优解之一。你把这句话作为选型判断标准,基本不会跑偏。
2. 五块基石逐层拆解:为什么是它们五个
2.1 Next.js:不只是 React 框架,更是全栈容器
T3 Stack 把 Next.js 作为最外层骨架,看中的不只是 SSR 和静态生成这些渲染能力,更关键的是它可以同时承载前端页面和后端 API 逻辑,让我们在一个代码库里完成全栈开发。Next.js 的 App Router 模式引入 Server Components 之后,很多页面可以直接在服务端读取数据、渲染内容,不需要客户端再发一轮请求,这对性能和首屏体验的提升立竿见影。
不过这里有个容易误解的地方:Next.js 本身也能写 API Routes,为什么还要引入 tRPC?我在 t3code 里两者的分工是这样的——Next.js 的 Route Handlers 用来处理那些"非业务查询"的场景,比如 Webhook 回调、第三方 OAuth 跳转、需要单独部署的端点;而所有面向页面 UI 的数据读写,全部走 tRPC。为什么这样切?因为 Route Handlers 没有端到端类型保障,适合做边界协议,不适合做业务内部通信。分清这个边界,整个项目结构才会干净。
2.2 TypeScript:让类型检查成为团队协作的粘合剂
没有 TypeScript,T3 Stack 的另外四块基石全都失去意义。tRPC 的客户端调用依赖类型推断,Prisma Client 的查询结果依赖类型生成,Next.js 的 props 校验也依赖类型定义。所以 TypeScript 在这套技术栈里不是一个"可选语法糖",它是通信协议本身。
实际项目里,我为 t3code 定了几条规矩:禁止在服务端和客户端之间传递any,禁止用as断言绕过类型检查,禁止在 tRPC 的返回类型里使用Omit这类技巧性太强的工具类型,尽量用 Prisma 生成的类型作为唯一数据源。刚推行的时候团队觉得"太严了",但跑了一段时间后,所有人都有同一个感受:重构成本直线下降。以前改一个表结构,要手动找哪些接口字段受影响,现在 Prisma 模型一改,npx prisma generate一跑,TypeScript 编译器会把所有断点标出来,你按着错误列表改就行。
2.3 tRPC:没有 API 文档,类型就是文档
tRPC 是 T3 Stack 里最有辨识度的一块。它做的事可以这样理解:你在服务端定义一堆 procedure(可以简单理解为"可调用的函数"),然后客户端导入type Router = typeof appRouter,就会得到一个完全类型安全的调用对象。不需要手写请求函数,不需要维护接口路径,更不需要在客户端定义一套重复的接口类型。
我在 t3code 里写过一个非常典型的例子:服务端有个getProject的 procedure,接收projectId: string,返回Project对象。在客户端,代码是这样写的:
const project = trpc.project.getProject.useQuery({ projectId: "abc123" });project的类型自动就是Project对象。你如果把projectId写成数字,IDE 直接报错;如果服务端改了返回结构,客户端用到project.name的代码也会立刻标红。这就是 t3code 的核心体验:写代码的速度不会比写 REST 快太多,但改代码的安全性完全是另一量级。
这里有个设计细节值得说:tRPC 底层集成了 React Query,所以useQuery天然带缓存、重试、失效机制。但这既是优点也是坑,后面我在常见问题里会专门展开。
2.4 Tailwind CSS:样式层为什么也讲"类型"
很多人把 Tailwind 理解成"省得写 CSS"的工具,但在 T3 Stack 的语境里,它承担的作用更像"设计约束的强制器"。Tailwind 的 class 是在构建时静态扫描的,只有真正出现在源码里的类名才会被生成,这保证了最终产物体积稳定。更实际的好处是,它让你在页面里不再需要为每个元素单独维护 CSS 类名和层级关系,内联的原子类本身就是一份可见的样式文档。
我在 t3code 里使用 Tailwind 的一个心得是:一定要在项目初期把主题配置(颜色、字体、间距、圆角)定义好,做成 design token。否则团队里每个人自己定义颜色值,很快整个项目就会变成"五彩斑斓的灰"。
2.5 Prisma:从数据库到类型系统的最后一公里
Prisma 在这套技术栈里的身份是 ORM,但它的杀手锏不只是查询 API 好用,而是模型定义本身就参与类型推导。你在schema.prisma里定义一个模型,运行prisma migrate和prisma generate后,Prisma Client 的查询函数就是完全类型安全的,返回的字段、关联关系、过滤条件,全部有提示和校验。
我在 t3code 里用 PostgreSQL 配合 Prisma,写过最基础的用户和项目关系模型。定义大概是这样:
model User { id String @id @default(cuid()) email String @unique name String? projects Project[] createdAt DateTime @default(now()) } model Project { id String @id @default(cuid()) name String description String? ownerId String owner User @relation(fields: [ownerId], references: [id]) createdAt DateTime @default(now()) }这个定义的强大之处在于:当你写prisma.project.findMany({ where: { ownerId: userId } })时,TypeScript 能推断出返回的是一个Project[],并且这个类型可以直接通过 tRPC 传递到前端。从数据库字段到页面显示,中间没有任何一层需要你手动维护类型。这就是 T3 Stack"端到端类型安全"的真正含义。
3. 从零搭建 t3code:create-t3-app 全流程实操
3.1 初始化:命令与选项逐项解析
搭建 t3code 最省力的方式是用官方脚手架,命令如下:
pnpm create t3-app@latest执行后它会问你项目名字,然后弹出一系列选项。这里我逐个说下我当时的取舍。
- Next.js:必选,没有它整套框架跑不起来。
- TypeScript:必选,T3 的灵魂。
- Tailwind CSS:我选了,毕竟样式层需要快速输出。
- tRPC:必选,前端调用链路的核心。
- Prisma:我选了,数据库端到端类型安全就靠它。
- ESLint / Prettier:全部选上,团队协作时统一代码风格能省无数吵架。
- src/ 目录别名:建议选,
@/这个路径别名在很多项目里实际上是默认约定,后面写 import 能少敲不少字。
初始化完成后,项目会自带一个完整的前后端文件夹结构。我为什么推荐用脚手架而不是手动搭?因为 create-t3-app 已经把 tRPC 的初始化、Next.js 的 App Router 配置、Prisma 的客户端封装都处理好了,你手动拼装这些零零碎碎的东西,至少要多花两天,而且很容易在版本兼容性上踩坑。
3.2 目录结构约定:每一层放什么
t3code 的目录结构遵循 T3 Stack 的默认约定,我实际跑下来觉得非常顺手,核心结构大致如下:
src/ app/ # Next.js App Router 页面 pages/api/trpc/ # tRPC 的 HTTP 入口 server/ api/ routers/ # 业务路由,按模块拆分 root.ts # 根路由,汇总所有子路由 trpc.ts # tRPC 初始化配置 db/ # Prisma Client 实例 trpc/ client.ts # 客户端 tRPC 调用封装 server.ts # 服务端调用封装 query-client.ts # React Query 客户端配置这套结构的核心逻辑是"前后端都在一个仓库里但边界清晰":server/下面放所有服务端逻辑,trpc/下面放客户端连接服务端的桥接层,app/下面只负责渲染和交互。我在实际迭代中发现,只要遵守"页面组件不直接操作 Prisma、Routers 里不写页面逻辑"这条纪律,项目体积膨胀也依然可控。
3.3 第一个完整 CRUD:从数据库到页面的类型安全闭环
我从 t3code 里提炼一个最小可跑的 CRUD 流程,带你把整条链路走一遍。
第一步,在schema.prisma里定义模型。沿用上面的 User 和 Project 例子,运行:
npx prisma migrate dev --name init这会创建数据库表并生成 Prisma Client。
第二步,在server/api/routers/project.ts里写 tRPC 路由。一个简单的列表加创建:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; import { prisma } from "../../db"; export const projectRouter = createTRPCRouter({ list: publicProcedure.query(async () => { return prisma.project.findMany({ include: { owner: true } }); }), create: publicProcedure .input(z.object({ name: z.string(), description: z.string().optional() })) .mutation(async ({ input }) => { return prisma.project.create({ data: input }); }), });这里有几个点新手容易忽略:publicProcedure表示无需登录,真实项目里需要换成protectedProcedure配合鉴权中间件;输入校验用的是 zod,它不是可选项,而是防止脏数据打进数据库的第一道防线。
第三步,在根路由里挂载子路由:
export const appRouter = createTRPCRouter({ project: projectRouter, }); export type AppRouter = typeof appRouter;第四步,在客户端页面里调用:
"use client"; import { trpc } from "@/trpc/client"; export default function ProjectList() { const { data, refetch } = trpc.project.list.useQuery(); const createProject = trpc.project.create.useMutation({ onSuccess: () => refetch(), }); return ( <div> {data?.map((project) => ( <div key={project.id}>{project.name}</div> ))} <button onClick={() => createProject.mutate({ name: "新项目" })}> 创建 </button> </div> ); }注意几个关键点:组件必须用"use client"标记,因为useQuery是客户端 hooks;create调用成功后我手动refetch,因为 tRPC 的自动失效需要额外配置。如果你跳过这两个细节,很容易遇到"数据没更新"或者"页面报错 Hydration failed"。
这一步跑通之后,你就拥有了一个从 PostgreSQL 表结构到 React 组件状态全部由 TypeScript 连接起来的最小闭环。后续所有业务功能,本质上都是在这个闭环上添加更复杂的查询、变更和权限控制。
4. 深入核心:类型安全背后的关键细节
4.1 tRPC 的输入校验与错误处理
很多教程讲了 tRPC 有多爽,但对错误处理往往一笔带过。我在 t3code 里踩过最深的坑就在这里。
tRPC 默认的错误信息不会完整返回给客户端。在开发环境你可能还能看到栈信息,生产环境直接就是一串笼统的错误码。所以我在每个 mutation 里显式抛出带业务语义的TRPCError,例如:
import { TRPCError } from "@trpc/server"; if (!hasPermission) { throw new TRPCError({ code: "FORBIDDEN", message: "当前用户没有操作该项目的权限", }); }同时客户端需要做一个错误消息的解析层,把TRPCError的 code 映射成用户可读的提示。这个问题不处理好,tRPC 项目上线后排查问题会非常痛苦,因为你看到的错误码可能只有一个数字,完全不知道是哪一环出的问题。
4.2 Prisma 关联查询与 N+1 陷阱
Prisma Client 用起来确实舒服,但它的关联查询存在一个性能陷阱。我在 t3code 的列表页遇到过:查询 50 个项目,每项都include了 owner,结果数据库日志显示发出了 51 条 SQL 语句。这就是典型的 N+1 问题。
解决办法有几个思路。第一个是减少include的层级,只查需要的关联。第二个是对于列表场景,用更细粒度的字段选择代替完整对象。第三个,也是我最终采用的,是在 tRPC 的 query 函数里接受一个可选参数,前端根据场景决定是否加载嵌套数据。把"数据加载策略"的控制权放在调用方手里,而不是在服务端硬编码,这样既不牺牲类型安全,又能兼顾性能。
4.3 Server Components 与 tRPC 的边界
T3 Stack 默认生成的模板里,所有页面组件都是客户端组件。但我发现 Next.js 的 Server Components 其实很香,尤其是那些不需要交互的展示型页面,完全可以在服务端把数据取好再渲染。
不过在服务端组件里直接调用 tRPC 会出现一个问题:tRPC 客户端初始化依赖 React Context,而 Server Components 里没有这套上下文。我在 t3code 里的解决方案是:在server/trpc.ts里导出一个直接调用 procedural 的函数,或者把数据获取抽成独立的函数,在 Server Component 和 Client Component 里分别复用。这个边界需要一开始就规划好,否则写一半再改会很痛苦。
4.4 React Query 缓存带来的"幽灵数据"
tRPC 深度绑定 React Query,这意味着你查过的数据会进缓存。好处是重复查询很快,坏处是如果你在另一个页面改了数据,原来的缓存可能还停留在旧状态,页面上出现"改完没更新"的错觉。
我为此定了一套缓存更新约定:创建、编辑、删除操作成功后,优先用onSuccess回调手动更新该对象的缓存;列表查询使用稳定的input作为 key,避免因为对象引用不同导致缓存失效。这套约定一开始执行成本略高,但项目到了中期,它的价值就体现出来了——不会出现那种"刷新一下又回来了"的诡异 bug。
5. 部署与生产环境:从"本地能跑"到"线上能扛"
5.1 部署方案选型
t3code 的部署我先后试过几种方案。最简单的是 Vercel,对 Next.js 的适配几乎是零改造,环境变量、按需构建、边缘网络全给你安排好了。如果你用的数据库是 PostgreSQL 提供商,且连接数不高,Vercel + 云数据库是一个起点不错的选择。
但如果你对部署环境有私有化要求,或者后端不仅仅跑 Next.js,那 Docker + 一台云主机是更稳的路径。我在 t3code 里写过一个 Dockerfile,核心就是多阶段构建:先用 node 镜像装依赖并构建,最后用一个极简的 runtime 镜像跑next start。这里要特别留意 Prisma 的构建产物,运行时镜像里必须包含生成后的 Prisma Client,否则启动会报找不到@prisma/client的错。
5.2 环境变量与配置隔离
T3 Stack 的模板自带.env和.env.example机制,但我在多个项目里发现一个共性问题:DATABASE_URL这种关键变量,大家喜欢直接放在.env里然后一路复制,最终一个项目的数据库连接串出现在四五个地方,出了事根本没法查。
我在 t3code 里做了两件事:第一,所有环境变量只在env.js或启动入口统一读取和校验;第二,按照部署环境维护三个文件:.env.development、.env.staging、.env.production,再写脚本按环境加载。这样每个环境用哪个数据库、哪个密钥一目了然,且本地开发不会被生产库误连。
5.3 日志、监控与运行观测
本地开发可以用控制台日志定位问题,上线后就必须把日志给聚合起来。我在 t3code 里接了一层简单的结构化日志:每个 tRPC 请求进来时,输出请求路径、耗时、状态码、用户 ID。不要小看这个,它帮我定位了至少三次线上问题,比如"某个查询在高峰期耗时暴涨",靠的就是这些日志。
除此之外,Prisma 有一个log配置,可以输出慢查询。我把warn级别的日志单独拉出来,超过 500ms 的查询也会被记录。这样一个简单的观测体系,让这套技术栈在生产环境里不至于成为黑盒。
6. 常见问题速查表:你大概率也会遇到的坑
我把 t3code 开发过程中最常踩的坑整理成一张速查表,每个问题都附了解决思路,方便你直接对着排查。
| 问题现象 | 根本原因 | 解决思路 |
|---|---|---|
| 调用 useQuery 返回 undefined 但接口有数据 | 客户端组件没有加 "use client" | 在组件文件顶部加指令,确认 hooks 在客户端环境运行 |
| mutation 成功但页面数据没变 | React Query 缓存未失效 | 在 onSuccess 里调用 refetch,或配置全局 invalidate 策略 |
| Prisma 查询报 P2022 错误 | 表结构或字段与 schema 不一致 | 运行prisma migrate dev同步迁移,检查 DATABASE_URL |
| 生产环境运行找不到 Prisma Client | Docker 镜像未复制生成后的客户端 | 多阶段构建中单独复制node_modules/.prisma,或直接在运行时执行 generate |
| tRPC 错误信息不具体 | 未使用 TRPCError 显式抛错 | 在每个有业务异常可能的 procedure 中抛 TRPCError,并包含可读 message |
| App Router 页面刷新后报 Hydration failed | 客户端和服务端渲染结果不一致 | 检查是否在渲染中用了随机值、时间戳或浏览器 API,并隔离在 useEffect 中执行 |
| 查询列表数据量增大后首屏明显变慢 | 没有使用分页,全量加载 | tRPC 的 query 里增加 take / skip,配合 cursor 分页更平滑 |
| Tailwind 类名在动态拼接后不生效 | 动态字符串未被静态扫描 | 避免拼接完整类名,使用 safelist 或完整类名的三元表达式写法 |
| 连接数据库偶尔报连接数超限 | serverless 环境下连接池未复用 | 用连接池方案或单例 Prisma Client,避免每次请求新建连接 |
这张表不能替代你对每个问题的深入排查,但它能帮你把 80% 的低级问题快速定位掉。我自己在 t3code 迭代过程中,翻这些记录的频率比翻官方文档还高。
7. 维护 t3code 大半年后,我的一点真实体会
技术方案选型这件事,很多时候不是选最炫的,而是选那个能让团队"安心改代码"的。T3 Stack 带给我最大的改变是:从前端到数据库的每一层,类型系统都像一张无形的电网,你碰错了地方它会立刻报警,而不是等你上线后由用户来碰。
T3 Stack 并不完美,tRPC 在极大规模接口场景下不如 REST 灵活,Next.js 的版本迭代也会带来不少升级成本。但如果你做的正是单应用全栈型项目,这套技术栈的开发体验和类型安全感,确实值得亲身体验一次。t3code 这个项目让我把"类型安全不是口号,而是工程纪律"这句话验证了个透。