如果只看“t3code”这个名字,你可能以为它是某个极客圈子的暗号,或者是一把压缩过的快捷键。事实上,这是我给自己负责的一个全栈项目起的内部代号:T3 指的是现在 React 生态里那套热度居高不下的技术组合拳——TypeScript、Tailwind CSS、tRPC,code 不用说,就是代码本身。这套组合这几年被戏称为“类型闭环全家桶”,核心卖点就一句话:从数据库表结构到前端 UI 组件,TypeScript 类型一路贯穿,没有任何断裂点。
我之所以愿意在项目代号里直接挂上 T3,是因为过去几年被“前后端类型不一致”这件事折磨得够呛。后端接口文档写的是number,前端拿到的却是string;数据库字段改了个名,API 层忘了同步,前端直接渲染出 undefined;团队新同事上手第一件事,居然是把后端返回的 interface 复制到前端目录里再手动改一遍。这些破事在 T3 这套体系里基本被消灭了。如果你也正在选型全栈开发框架,或者已经用 create-t3-app 脚手架搭过项目但没吃透原理,这篇内容应该值得你花十分钟读完。
1. 为什么放弃传统前后端分离,选 T3 Stack 这组“类型闭环”
1.1 传统模式的痛点:类型在两个世界之间断裂
先说说我过去最常用的开发模式:前端 React + 后端 Express/Spring Boot,两边各自维护一套类型定义,中间靠 REST API 通信。听起来没什么问题,但实际上每一次接口迭代都要经历一个隐性流程:
- 后端改字段,更新 Swagger 文档。
- 前端拿到文档,手动修改
types.ts。 - 联调时发现文档和实际返回不一致,开始扯皮。
这个流程有几个致命的薄弱环节。第一,文档永远滞后于代码,因为没人喜欢写文档。第二,前端维护的类型定义和后端真实返回之间没有自动校验,any满天飞。第三,联调时发现的问题通常是在运行时才暴露,而不是编译期。
T3 Stack 的核心思路是:与其把类型定义复制来复制去,不如让后端直接“长出”类型给前端用。tRPC 就是干这件事的——前端调用后端函数时,TypeScript 会自动推理出输入输出类型,根本不需要中间人。
1.2 T3 Stack 的核心构成:四个库各司其职
T3 Stack 不是某个单一框架,而是一组精选工具的整合。业内常说的 T3 其实有三种解释:Typesafe、Tailwind、TRPC,也有人说是 The Three(三层架构)。
我这里按实际项目用到的组合来说:
| 组件 | 职责 | 为什么选它 |
|---|---|---|
| Next.js | React 全栈框架 | 同时支持 SSR、SSG、API Routes,部署灵活 |
| TypeScript | 静态类型系统 | 全链路类型安全的基础 |
| Tailwind CSS | 原子化样式 | 开发效率高,不用来回切文件 |
| tRPC | 类型安全 RPC | 前后端类型零成本共享 |
| Prisma | ORM | Schema 即类型源,迁移工具完善 |
| NextAuth.js | 认证 | 内置主流 OAuth 提供商,适配 T3 生态 |
这六个组件不是随便拼出来的。Prisma 的 schema 是类型源头,tRPC 把后端函数暴露成前端可调用过程,Next.js 负责渲染和路由,NextAuth 解决会话问题,Tailwind 让界面开发不那么痛苦。整条链路环环相扣。
1.3 我最终敲定的技术选型清单
在 t3code 项目里,我实际用的是 create-t3-app 最新版本生成的基础骨架,然后做了几处定制:
- 数据库:PostgreSQL 15,本地开发用 Docker 起一个实例。
- ORM:Prisma,schema 文件作为单一事实来源。
- 认证:NextAuth.js 的 GitHub Provider + Credentials Provider 组合。
- 部署:Vercel 作为主力环境,Docker 自托管作为备选。
这套选型不是拍脑袋定的,核心考量是类型安全和降低认知负担。把类型源头收敛到 Prisma schema,把逻辑收敛到 tRPC router,前端只管调用,整个项目的心智模型非常清晰。
提示:如果你之前只用过 REST API,第一次接触 tRPC 会觉得“这玩意儿不就是后端函数直接暴露给前端?”——没错,它本质上就是 RPC。但因为 TypeScript 的存在,这种暴露是类型安全的,而且不用写路由定义、不用生成客户端代码,省掉一整层胶水代码。
2. create-t3-app 项目初始化:从命令行到首屏运行
2.1 一条命令生成骨架,交互式选项逐项拆解
初始化 T3 项目非常快,只需要一行命令:
npm create t3-app@latest t3code运行后会进入交互式选择界面,有几个关键选项值得仔细看:
Use Tailwind CSS:默认勾选,建议保留。后面写原子化样式能省很多事。Use tRPC:默认勾选,这是 T3 的精华,建议保留。Use Prisma:默认勾选,数据库建模全靠它。Use NextAuth:按需勾选。如果你的项目需要登录体系,建议一开始就勾上,后面再加会比较痛苦。Use ESLint / Prettier:建议全开,团队协作时能少很多无意义的代码风格争吵。
选择完成后,脚手架会自动安装依赖并生成项目结构。我的习惯是立刻跑一次npm run dev,确认开发服务器能正常启动,再做后续调整。
2.2 生成后的目录结构到底该怎么看
create-t3-app 生成的目录结构非常规整,新手容易看懵,其实重点只需要记住两个位置:
src/server/:所有后端代码,包括 Prisma 客户端、tRPC router、鉴权配置。src/app/:Next.js App Router 下的前端页面和路由。src/components/:React 组件。
一个很关键的设计是src/server/api/下的文件结构:
src/server/ ├── api/ │ ├── root.ts # 根 router,汇总所有子 router │ ├── routers/ │ │ ├── post.ts # 业务子 router │ │ └── profile.ts │ └── trpc.ts # tRPC 初始化逻辑,含上下文和中间件 ├── auth.ts # NextAuth 配置 └── db.ts # Prisma 客户端单例这个架构的好处是:所有子 router 最终都会汇总到root.ts,前端调用时只要引入一个api对象,就能访问所有后端过程。
2.3 首屏页面最小实现:先跑通再谈优化
骨架生成后默认有一个首页,我一般会先清掉示例代码,做一个最简单的“你好,t3code”页面,验证前端到后端链路的完整性。
典型的最小实现分三步:先在后端写一个返回字符串的 tRPC procedure,再在前端用api.xxx.query()调用,最后渲染到页面上。这一步看起来简单,但它能证明整条类型链路是通的——如果后端返回类型变化,前端会立刻报类型错误。
当时我做了个小验证:把后端 procedure 的返回值从string改成{ message: string; timestamp: number }对象,前端代码没动,TypeScript 编译器立刻提示渲染位置类型不匹配。那一刻我才真正体会到什么叫“类型从后端长到前端”。
注意:初始化后不要急着改数据库连接,先用 SQLite 或本地 PostgreSQL 跑通链路,迁移和连接配置放在后面做,否则容易把“类型链路”和“数据链路”的调试混在一起,问题不好定位。
3. tRPC 实战:一个 Router 打通全栈类型链路
3.1 tRPC 的核心机制:不需要 REST 路由,也不需要客户端生成
tRPC 最反直觉的地方在于:它没有 HTTP 路由定义,也没有独立的 API 文档,后端写的每个procedure天然就是前端可调用的函数。
举个例子,后端定义:
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ getLatest: publicProcedure .query(async ({ ctx }) => { const latest = await ctx.db.post.findFirst({ orderBy: { createdAt: "desc" } }); return latest ?? null; }), });前端调用:
import { api } from "~/trpc/react"; function LatestPost() { const { data } = api.post.getLatest.useQuery(); return <div>{data?.title ?? "暂无数据"}</div>; }注意几个细节:前端调用不需要写 URL,不需要定义请求参数类型,不需要处理响应体格式。所有类型都从后端的 procedure 自动推导过来。如果后端把findFirst改成了findMany,返回类型变成数组,前端渲染data?.title会立刻报错。
3.2 输入校验与类型守卫:Zod 不是可选配置
tRPC 通常和 Zod 搭配使用,把输入校验放在类型边界上。这是 t3code 项目里让我最受益的部分。
import { z } from "zod"; export const postRouter = createTRPCRouter({ create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().min(1).max(5000), })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });Zod 在这里承担了两层职责:一是运行时校验前端传来的数据,二是通过 TypeScript 推断出input的静态类型。也就是说,前端传入什么数据、后端怎么处理,所有字段类型都是确定的、运行前就能被检查到的。以前我在 REST 接口里手写校验逻辑的行为彻底被杜绝了。
3.3 实验中发现的一个坑:错误处理千万别吞掉状态码
tRPC 的 HTTP 层默认是 POST 请求,错误信息通过tRPCError统一封装。我一开始图省事,在前端统一用catch捕获错误,打印error.message,结果发现生产环境下错误信息非常模糊。
排查过程让我长了个记性:
- tRPC 的错误分为
UNAUTHORIZED、NOT_FOUND、BAD_REQUEST等类型,调试时必须打印error.data.code,而不是只打 message。 - 前端日志埋点要带上 procedure 的路径,比如
post.create,不然线上报了错根本不知道是哪个接口出的问题。 - 后端在自定义错误时,不要手动抛
new Error(),要用tRPCError并指定 code,否则前端无法区分是参数错误还是权限错误。
import { TRPCError } from "@trpc/server"; if (!canEdit) { throw new TRPCError({ code: "FORBIDDEN", message: "没有权限操作此内容", }); }这些都是靠真实踩坑换来的经验,类型安全不等于错误处理安全,两者要分开对待。
提示:在开发模式下给 tRPC 开启日志中间件是个好习惯。create-t3-app 默认没开,我会在
trpc.ts里加一个 logger,每次请求打印调用路径、耗时、状态码。尤其是联调阶段,这个日志能帮你省掉无数“我这边没报错啊”的扯皮。
4. Prisma 数据建模与迁移的完整实操记录
4.1 Schema 设计:先想清楚关系,再写代码
t3code 的核心业务场景是内容发布和用户互动。我先用一张表来展示数据库模型的设计思路:
| 模型 | 关键字段 | 关系 |
|---|---|---|
| User | id, name, email, emailVerified, image | 一对多 Post |
| Post | id, title, content, published, authorId | 多对一 User,多对多 Tag |
| Tag | id, name | 多对多 Post |
| Session | id, sessionToken, userId, expires | 多对一 User |
| Account | id, userId, type, provider, providerAccountId | 多对一 User,NextAuth 关联 |
对于内容型应用,多对多的“PostsTags”中间表是必须的。Prisma 里只需要在两侧写关系数组,中间表会自动生成。不过要注意一个细节:如果要在中间表上存额外字段(比如标签权重、创建时间),就得用显式中间表,不能靠 Prisma 自动隐式关系,否则后面要加字段只能重新建表迁移,非常被动。
4.2 从 Schema 到数据库:迁移命令的坑与顺序
Prisma 的迁移流程是我觉得整个 T3 体系里最容易出错的地方。正确的操作顺序是:
- 修改
schema.prisma,更新模型定义。 - 本地执行
npx prisma migrate dev --name init,会生成 SQL 迁移文件并应用到开发库。 - 代码评审时连迁移文件一起审查,而不是只看 schema。
- 部署前执行
npx prisma migrate deploy,确保生产库和代码版本一致。
很多新手在第二步会踩一个经典坑:开发过程中数据库结构已经改过好几轮,结果迁移文件堆积如山,或者冲突不断。解法很简单——定期用npx prisma migrate reset清空本地库,重新初始化。别心痛数据,开发环境的数据本来就是用来造着玩的。
4.3 查询性能优化:N+1 问题必须提前考虑
Prisma 生成的查询默认是精益的,但在列表页场景下很容易出现 N+1 问题。比如文章列表需要展示作者名,如果你在循环里逐个请求作者信息,接口响应时间会成倍增加。
我的做法是直接用include:
const posts = await ctx.db.post.findMany({ include: { author: { select: { name: true, image: true } }, }, orderBy: { createdAt: "desc" }, });这样数据库只用两条 SQL 就能完成:一次查文章,一次查作者集合,Prisma 会自动做关联映射。
实测下来,本地开发环境数据量小看不出差异,一旦线上数据量上去,响应时间差异是几何级数的。曾经有一次接口从 40ms 涨到 2 秒,排查半天就是 N+1 问题。加了个 include 立刻降回来了。
4.4 关于 Prisma 类型源头的体会
Prisma 最强的地方在于,schema 文件不仅是数据库设计文档,还是全栈类型的起点。所有模型都会被生成到@prisma/client的类型定义里,tRPC 引用这些类型,前端再继承 tRPC 的类型。整条链路里,你只需要维护一个 schema 文件。
有一次我改了一个字段名,从post.content改成post.body,TypeScript 编译器瞬间报出二十多处错误,全是前端遗留引用。那一刻我是真心感谢 TypeScript——这要放在从前,估计得等用户反馈“内容不显示了”才能发现。
5. NextAuth 鉴权集成:正统配置之外,还需要处理的三件事
5.1 基础配置:跟着官方文档走就够了
NextAuth 在 create-t3-app 里是集成好的,真正的配置点在src/server/auth.ts。我使用 GitHub Provider + Credentials 组合,Provider 的配置确保正确设置环境变量:
GITHUB_CLIENT_ID=xxxx GITHUB_CLIENT_SECRET=xxxx NEXTAUTH_SECRET=xxxx NEXTAUTH_URL=http://localhost:3000NEXTAUTH_SECRET是重点。它用于加密 JWT 会话,生产环境必须用强随机字符串,不能写死在前端代码里。我一般用openssl rand -base64 32生成。
5.2 拿到 session 之后,第一件事是扩展类型
NextAuth 默认的session.user类型里只有name、email、image。但实际项目里,前端通常需要拿到用户的 id、角色、权限标识等。官方说法是“需要扩展模块声明”,这块如果漏了,前端会出现一团乱麻的隐式 any。
在项目里我做了这样的类型扩展:
// src/types/next-auth.d.ts import { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; role: string; } & DefaultSession["user"]; } }这段声明有个关键点:role字段必须由后端在auth.ts的callbacks中显式注入,JWT 会话里默认没有。我当时漏掉这一步,前端调session.user.id全是 undefined,排查了半天才发现是类型和运行时数据不一致。
5.3 会话刷新与失效处理:别只依赖 JWT
NextAuth 的默认策略是 JWT 会话,客户端每次请求都带着 token,服务端解密验证。这种方式无状态、扩展容易,但也有个实际问题:用户角色或权限变更后,旧的 JWT 仍然有效,直到过期。
在 t3code 项目里,我加了一个简单的 session 刷新机制:在 tRPC middleware 里检查用户状态,如果发现用户已经禁用或被删,就主动抛UNAUTHORIZED。这样即使 JWT 没过期,权限变更也能即时生效。
export const enforceUserIsActive = protectedProcedure.use( async ({ ctx, next }) => { const user = await ctx.db.user.findUnique({ where: { id: ctx.session.user.id }, }); if (!user || user.status !== "ACTIVE") { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, user } }); }, );这算是一个朴素的权限即时回收方案,生产环境表现稳定。如果你做的是高安全等级系统,建议考虑服务端 session 存储,而不是纯 JWT。
6. 部署实测:Vercel 一键部署与 Docker 自托管双路径
6.1 Vercel 部署:忽略两个配置项会翻车
create-t3-app 对 Vercel 的适配做得很好,基本是直接 push 代码就能完成部署。但有两个配置项容易被忽略:
- 环境变量必须在 Vercel 项目设置里配置,不会从
.env文件自动读取。 - Prisma 的生成产物在构建时需要执行 postinstall 钩子。如果 schema 有更新,必须在构建命令里加上
npx prisma generate && npx next build。
我当时第一次部署时,线上页面能打开,但所有数据库查询都报 500。排查过程很痛苦,最后发现是 Vercel 构建时 Prisma client 没有针对生产环境的引擎重新生成,本地环境的二进制和线上不匹配。
解决方案是在package.json里添加 hooks:
"postinstall": "prisma generate"然后重新部署一次,问题立刻消失。
6.2 Docker 自托管:构建缓存会让 Prisma 出错
如果想把 t3code 跑在自己的服务器上,Docker 是绕不开的方案。官方示例 Dockerfile 大概长这样:
FROM node:20-alpine AS base RUN apk add --no-cache libc6-compat FROM base AS builder WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY . . RUN npx prisma generate RUN pnpm build FROM base AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules EXPOSE 3000 CMD ["pnpm", "start"]这里有个隐蔽的坑:构建阶段如果使用了 BuildKit 缓存,而node_modules被缓存了,prisma generate可能不会重新执行,导致生产的 client 保持旧版本。我社区里见不少人栽在这里,建议在 Dockerfile 里把prisma generate放到依赖安装构建的最末端,或者干脆给构建命令加上--no-cache,牺牲一点构建时间来换确定性。
6.3 性能实测数据:gzip 后体积和首屏时间
部署完成后,我对 t3code 做了一轮基础性能测试。开发环境几乎没参考价值,生产环境数据更真实:
| 指标 | 数值 |
|---|---|
| 首页首屏时间(3G 网络模拟) | 1.8s |
| 全量 JS 压缩后体积 | 约 180KB |
| API 平均响应时间 | 120ms |
| 数据库查询 P95 | 约 200ms |
说实话,这个成绩不算顶级,但胜在模块清晰、类型安全带来的维护成本降低。如果你要追求极致性能,可以进一步做路由级代码分割、缓存 tRPC 响应、加 CDN。但对一个中型内容项目来说,T3 Stack 提供的默认性能已经完全够用。
注意:Vercel 上部署的 tRPC 默认是 serverless 函数模式,意味着每次请求都可能有冷启动开销。如果接口对延迟敏感,建议使用 Next.js 的
export const runtime = "nodejs"保持长驻进程,或者考虑自托管 Node.js 服务。
7. 从 t3code 项目里学到的几件小事
整轮开发下来,我最大的体会是:T3 Stack 并不能消灭所有后端问题,但能消灭“类型问题引发的后端问题”。你仍然要设计好数据库表结构,仍然要写好业务逻辑,仍然要审核安全边界——只是不再需要花大量时间在类型转换、文档同步、接口联调这些事情上了。
如果你正准备入手 T3,我个人的建议是先别急着追新依赖版本。create-t3-app 的版本升级往往伴随着 tRPC 版本大更,API 会有变化。锁定版本、理解每一个配置项的含义,比盲目跟着最新教程走更重要。
最后分享一个小技巧:在 t3code 项目里我会给前端的所有 tRPC 调用统一加一个onError回调,把错误直接映射到页面上最合适的提示组件。这个小改动让线上问题的定位速度提升了很多。
T3 这套组合可能不是每个项目的终极解,但对我这种长期被类型断层困扰的开发者来说,它把“改字段重跑一遍排查链路”这件事变成了过去式。如果你也想体验一把全链路类型安全的感觉,从npm create t3-app@latest开始,跑一个最小功能看看,比看任何文章都直观。