1. 先把话讲清楚:T3不是框架,而是一套"强约定"的技术选型组合
1.1 T3这套组合解决的是"选择困难",不是"性能焦虑"
t3code是我最近一直在维护的一个全栈项目,名字里的T3指的就是那套经常被挂在技术社区首页的组合:TypeScript、Tailwind CSS、tRPC,再配Next.js和Prisma。很多第一次接触的人会把T3 Stack当成一个"新框架",实际上它更像一份已经被验证过的技术选型清单——每个成员单独拿出来都不稀奇,但组合在一起之后,前后端之间的沟通成本被压到了极低。
我当初在t3code里下决心切到这套组合,很大一部分原因是受够了传统前后端分离项目里的"类型对不齐"问题。后端接口返回什么结构、前端代码里又按什么结构取数据,这两者之间最常见的状态就是靠文档和人肉同步。文档忘了更新,前端TS类型就不会跟着变,往往跑起来到某一页才发现某个字段是undefined。T3的解法很直接:后端用tRPC定义一次路由,前端调用时类型推导自动生效,字段加一个、减一个、改个名字,编译器第一时间告诉你哪里需要跟着改。
这不是什么黑科技,就是把"接口契约"这件事从人的记忆里搬到了类型系统里。对我这种记性不太好、又讨厌写两遍类型定义的人来说,这个省心程度是实打实的。另外Tailwind负责样式,Next.js负责渲染架构和路由,Prisma负责数据库建模和迁移,NextAuth负责登录会话——各司其职,没有重叠,也没有需要我去纠结"这个需求该用哪个方案"的模糊地带。
1.2 什么人最适合直接用T3,什么人要慎重
先说适合的:中小型全栈应用、内部管理系统、个人项目、需要快速验证商业想法的MVP,这种场景几乎是量身定做。t3code本身就是一个带后台的内容资源中心,用户端要列表、要详情、要搜索,管理端要登录、要增删改查、要看统计数据。整套业务逻辑复杂度适中,但对迭代速度的要求很高——从需求到上线的时间窗口往往只有一到两周。这种项目最怕的就是在"前后端怎么连"这种破事上浪费半天。
慎重的场景大概有两类。第一类是前后端分属不同团队、需要独立发布和独立部署的大型系统,tRPC把前后端绑得很紧,两边节奏不一致的时候反而难受,这时候老老实实搞REST或者GraphQL更合适。第二类是对首屏渲染有极致要求、需要大量定制BFF层的产品,T3默认依赖Next.js的架构,虽然能自己改,但改起来成本不低。
另外说句实在话:如果你的项目根本没有TypeScript基础,或者团队成员没人写过Prisma,直接上T3会有个陡峭的学习曲线。别被"模板开箱即用"的宣传迷惑,脚手架只是解决了起步问题,真正的成本在后面几个月维护代码的时候。
2. 用create-t3-app初始化项目:每个选项背后的实际工作
2.1 交互式选项逐个拆解
初始化t3code的时候,我用的是官方脚手架create-t3-app。跑起来很简单:
npm create t3-app@latest接下来的问题是交互式的,它会问你要不要启用各个模块。这里我逐个说下我的选择和建议:
| 选项 | 建议 | 理由 |
|---|---|---|
| Next.js | 必选 | 一切都是建立在它上面的 |
| Tailwind CSS | 建议选 | 不选的话样式方案又得自己纠结 |
| tRPC | 核心 | 选T3不就是为了它 |
| Prisma | 看项目 | 用SQLite/PostgreSQL/MySQL时强烈建议选 |
| NextAuth.js | 看需求 | 需要登录就选,不需要别硬选 |
| App Router | 新版默认 | 服务端组件是Next.js当前主方向 |
我当时的配置是除了NextAuth之外全选。没选NextAuth的原因是t3code早期并不需要用户登录体系,就是一个公开的展示型页面加一个简单的管理接口,登录是后加的。后来加的时候也验证了一件事:create-t3-app生成的基础结构里已经预留好了NextAuth的接入位置,手动补并不费劲。
这里就想提醒一句:不是每个选项都必须开。很多教程喜欢把全套选满,好像多一个模块就多一分专业,实际开发里没需求的模块只会增加项目初始化时的噪音。脚手架的本质是"按需组装",不是你项目的全部答案。
2.2 初始化后的目录结构与关键文件
初始化完成之后,t3code的目录结构大概是这样的:
src/ app/ # Next.js App Router页面 pages/api/ # API路由(tRPC的入口挂在NextAuth后面) server/ api/ root.ts # tRPC根路由 routers/ # 业务路由模块 auth.ts # NextAuth配置 db.ts # Prisma客户端单例 trpc/ server.ts # tRPC服务端封装 client.ts # 客户端React Query封装几个文件你需要额外注意,因为它们是整个类型安全的基础:
第一是server/api/root.ts。这个文件把所有的子路由聚合到一个appRouter上,前端调用时拿到的类型就是从它推导出来的。新加一个业务模块,先在这里注册,否则客户端找不到对应路由。
第二是server/db.ts。它创建了一个Prisma客户端的单例,避免在serverless环境下因为热加载反复创建数据库连接池。这个文件看着简单,但后面排查"数据库连接过多"的问题时,大多和这里的写法有关。
第三是trpc/server.ts和trpc/client.ts。前者负责创建服务端的tRPC实例,后者负责在前端创建React Query的客户端实例。两边通过同一个AppRouter类型关联起来,才有了"后端改一个返回字段,前端编译直接报错"的效果。
我见过不少人在初始化之后完全不看这几个文件,直接在页面里乱写API调用,等到类型报错又一脸懵。花十分钟把这些基础文件的职责理清楚,后面能省好几个小时。
3. 从Prisma模型到页面渲染:一条"类型安全"数据的完整旅程
3.1 模型设计与迁移:先想清楚关系再动手
t3code里最核心的模型是Resource(资源),对应每一条可展示的内容,包括标题、简介、正文、分类、标签、作者、发布时间这些字段。设计阶段我反复斟酌的是分类和标签的关系:分类是单选的,标签是多选的。这个在Prisma里写出来很直观:
model Category { id String @id @default(cuid()) name String @unique resources Resource[] } model Resource { id String @id @default(cuid()) title String summary String content String @db.Text categoryId String category Category @relation(fields: [categoryId], references: [id]) tags Tag[] published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique resources Resource[] }这里有个实操经验:关系字段的命名一定要克制。Prisma的关联既有外键字段如categoryId,也有虚拟字段如category,两者成对出现,命名混乱之后光是读schema就头大。我在t3code里定了一个规矩:凡是外键,一律以XxxId结尾;凡是虚拟关系,一律用对应模型的单数或复数名。配好之后,生成迁移并同步数据库:
npx prisma migrate dev --name init这一步会把模型转换成SQL并生成Prisma Client,之后你在代码里拿到的任何类型,都是直接来自schema定义的,改模型等于改类型,改完它自动处处生效。
3.2 tRPC Router的设计:按业务模块切分
t3code的API层没有做成传统的一堆接口文件,而是按业务模块分成了resourceRouter、categoryRouter、tagRouter等。每个路由内部再拆分公开查询和后台操作,这是一个很值得养成的习惯。
import { createTRPCRouter, publicProcedure, protectedProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const resourceRouter = createTRPCRouter({ listPublished: publicProcedure .input(z.object({ categoryId: z.string().optional(), page: z.number().default(1), })) .query(async ({ ctx }) => { return ctx.db.resource.findMany({ where: { published: true }, include: { category: true, tags: true }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), categoryId: z.string(), tagIds: z.array(z.string()), })) .mutation(async ({ ctx, input }) => { return ctx.db.resource.create({ data: { title: input.title, content: input.content, categoryId: input.categoryId, tags: { connect: input.tagIds.map(id => ({ id })) }, }, }); }), });这里两个关键封装值得展开说。一是zod的输入校验:tRPC把输入校验和类型推导合二为一,前端传错参数直接编译期就暴露,而不是等到后端返回400再去猜。二是publicProcedure和protectedProcedure的区分:公开查询任何人能调,写操作必须走登录态校验。我的习惯是默认用protectedProcedure,只有明确需要公开的接口才换回public,这样权限上更安全。
3.3 客户端调用与React Query的缓存意识
前端调用tRPC的体验是我切换这套组合后最强烈的感受提升。页面组件里直接调用,不需要手动维护请求状态:
"use client"; import { api } from "~/trpc/react"; export function ResourceList() { const { data, isLoading } = api.resource.listPublished.useQuery({ page: 1, }); if (isLoading) return <p>加载中...</p>; return ( <div> {data?.map((item) => ( <article key={item.id}> <h2>{item.title}</h2> <p>{item.summary}</p> <span>{item.category.name}</span> </article> ))} </div> ); }注意这里有两个隐性的坑。第一个是useQuery返回的数据是直接被推断成Resource[]的,你没法随手把它塞给一个类型不匹配的变量——这既是优点也是约束,习惯了之后你会觉得这种约束很舒服。第二个是React Query的缓存机制:同一个key的查询在多个组件里重复调用时,默认会走缓存而不是重新请求。这在列表场景里是好事,但当你做了增删改之后,一定要记得手动失效缓存:
const utils = api.useUtils(); await utils.resource.listPublished.invalidate();不失效的话,数据改了页面还是旧数据,看起来像"没生效"。我在t3code里至少有两次被这个问题骗了,以为是后端没更新,实际上是缓存没有正确清理。
3.4 NextAuth鉴权中容易被忽略的会话类型问题
t3code后期加了管理端登录,用的NextAuth。最容易被忽略的是会话类型扩展这件事。默认情况下,session对象里的user只有name、email、image这几个字段,如果你像我在数据库里给User表加了role字段,那么在服务端tRPC里通过ctx.session.user.role访问时会直接类型报错。
解决办法是声明类型扩展:
declare module "next-auth" { interface Session { user: { id: string; role: string; } & DefaultSession["user"]; } }这个细节我印象很深,因为网上大部分教程不会提到,而实际项目里几乎必然要用到自定义字段。别等编译报错了再去找原因,接NextAuth的第一时间就该把这个声明扩展写进去。
4. 实战中绕不开的坑:我的排查记录
4.1 服务端组件把"use client"边界吃掉导致的报错
t3code里最让我印象深刻的一次排查,是列表页突然在构建时报错,提示我"无法在服务端组件中使用客户端hook"。这个问题的根因在于Next.js App Router的组件边界:如果父组件是服务端组件,它引入的子组件里即便写了"use client",在某些情况下也会被吞掉,尤其当这个子组件再被另一个客户端组件包装时。
我当时的排查链路是这样的:先看报错信息定位到具体文件,发现是一个写着useQuery的列表组件;检查它的文件顶部确实有"use client"指令;再往上一层看,发现它被一个没有声明"use client"的容器组件引用。问题就出在这里——中间这层容器没有正确标记客户端边界,导致下游的hook在服务端执行时报错。
修复方法是在中间层也加上"use client",或者把服务端组件和客户端组件的引用关系理清楚。这里有一条很实用的判断标准:一个组件里只要用了任何hook,或者依赖浏览器API,就必须以它为边界,把它和它下面的所有子组件都划到客户端侧。
4.2 tRPC Context合并Auth时的类型冲突
另一个让我花了一个晚上的坑是tRPC的Context类型。NextAuth的session类型和tRPC的Context合并后,如果两边定义顺序不对,会出现"类型不匹配"的报错。我当时新加了一个ctx.session的访问逻辑,编译一直过不了,报错信息也看不出是哪个类型冲突。
最后排查下来,根因是我在server/api/trpc.ts里创建createTRPCContext时,把session和db挂载的顺序搞反了,导致Prisma客户端拿到的类型是联合类型而非具体的Client类型。解决起来其实就两行代码的事,但定位过程暴露了一个通用教训:T3项目里的类型报错,绝大多数不是你写错了,而是类型推导的原料不对。先检查Context里挂了什么,再检查路由里用了什么。
4.3 Prisma在构建阶段找不到数据库的经典问题
部署到生产环境之后,我发现每次构建都会在Prisma生成Client的步骤失败,报找不到数据库连接。这个问题几乎每个用Prisma的人都会遇到一次:Prisma在构建时需要读取.env里的DATABASE_URL来生成Client,但CI/CD环境里.env文件本身不会被提交到仓库。
我的处理是修改构建脚本,在构建前显式设置环境变量:
DATABASE_URL="$DATABASE_URL" npx prisma generate另一个更隐蔽的变体是,在本地开发一切正常,但部署后连接的是生产库,本地跑迁移时可能把开发库的结构改了。所以从第一天起就应该给数据库区分DATABASE_URL和DATABASE_URL_TEST这类环境变量,不同环境用不同连接串,不要混用。
4.4 环境变量里带前缀的公开变量问题
Next.js对客户端可见的环境变量有这个前缀约定:只有以NEXT_PUBLIC_开头的变量才会被打包进浏览器端代码。这个坑是我在t3code里接一个第三方地图服务时踩的——我写了个MAP_API_KEY,前端代码里怎么都取不到,控制台打印出来全是undefined。
排查链路很简单:先在服务端组件里console.log确认变量存在,再在客户端组件里看同一个变量,发现是undefined。立刻意识到是前缀问题,改成NEXT_PUBLIC_MAP_API_KEY之后就正常了。这个坑本身不复杂,但很耽误时间,因为报错信息不会告诉你"取不到是因为变量没公开",只会在浏览器端显示undefined,让你误以为接口数据有问题。
5. 上生产前的几个务实建议
5.1 数据获取一定要可观测
t3code上线一段时间后,我最大的体会是:全栈框架把开发阶段的体验做得很顺,但生产环境里黑盒问题依然要靠日志说话。建议在tRPC的每个查询和变更上挂一个简单的日志逻辑,记录路由名、耗时和状态码。不要觉得麻烦,等到用户反馈"某页打开很慢"而你又无法复现时,这些日志就是你唯一的抓手。
5.2 按功能切片组织代码,而不是按层
早期我习惯把所有查询集中在routers/里,页面组件只负责调用。后来发现模块多了以后,改一个功能往往要横跨四五个目录,特别别扭。后来改成按功能切片:比如"资源管理"这个功能相关的schema片段、路由、页面组件、Hook尽量就近放。这样任何一次需求变更的影响范围都集中在一个目录里,改起来明显顺手。
5.3 跟着大版本走,别攒技术债
Next.js和tRPC的版本迭代速度都很快,API变动频繁。我的建议是每次大版本发布后,至少安排半天时间做一次升级评估,别等项目跑了一年再想着升级。t3code在这点上是有过教训的——中间隔了两个大版本没动,后来一次性升级时整整折腾了两天才把类型错误清干净。
最后再分享一个我个人的习惯:脚手架生成的项目只是起点,真正属于你的代码要敢于动手改。t3code里的trpc封装、db单例我都手动调整过,加上自己业务的类型声明和工具函数,才慢慢长成顺手的样子。全栈类型安全这条路,早期需要一些耐心去理解类型推导的来龙去脉,一旦摸顺了,后面就是持续的低摩擦开发,值回票价。