1. 搞懂t3code之前,先说说全栈TypeScript的痛点
如果你关注前端技术社区有一阵子,应该没少刷到过"T3"这个词。从t3.gg这个个人网站出发,Theo带着他的一套全栈TypeScript组合拳,硬是在Next.js生态里杀出了一条极具辨识度的路子。后来大家约定俗成,把这套组合叫T3 Stack,也就是t3code这个标签背后真正指的东西——Next.js、TypeScript、tRPC、TailwindCSS、Prisma,一套从数据库一直摸到UI层的完全TypeScript化开发方案。
先说清楚一件事:T3不是框架,不是轮子,更不是一个需要你完整安装的运行时。它是一组工具的选型思路,一个"怎么把这些东西拼到一起最顺手"的实践集合。真正让它在开发者中间传开的原因,是它解决了全栈TypeScript开发里最让人头疼的三个问题:
第一,前后端类型割裂。写REST接口的时候,前端调fetch拿到的数据,和后端res.json()返回的数据,本质上没有任何类型关联。后端把字段名改一个,前端不报错,运行起来才发现undefined,这种问题单靠约定根本治不住。
第二,样板代码太多。REST要定义路由、写controller、配校验、搞文档;GraphQL要写schema、resolver、fragment。你还没开始写业务逻辑,光是把接口层铺好就已经心力交瘁。
第三,数据库的类型盲区。ORM虽然能帮你生成一些类型,但查询出来的对象、关联关系、筛选条件,很多时候依然要靠手写interface或者any兜底,一不留神就失去类型保护。
T3这套组合的设计哲学就是:既然全栈都是TypeScript,那就让类型从数据库一路畅通到组件里,中途不做任何转换、不写任何胶水代码。写的时候你觉得舒服,重构的时候你更会觉得值——改一个Prisma模型的字段名,前端组件里直接亮红,这种体验是传统前后端分离给不了的。
这篇文章不是T3官方文档的翻译,更像是我自己从零搭项目、上线、踩坑之后的复盘。如果你正准备用Next.js+tRPC+Prisma做一个正经的全栈应用,或者已经装了脚手架但不知道每一层怎么串联,这篇应该能帮你省下不少试错时间。
2. 用create-t3-app搭起第一套项目:选项取舍与目录结构
2.1 脚手架选项怎么选
T3栈的入口基本就是create-t3-app,一条命令就能把整个骨架拉下来:
npx create-t3-app@latest my-t3-app这里略微提醒一句,npx拉取的是最新版本,如果你在IDE内置终端里装,Node版本最好在18以上,不然Prisma的引擎和Next.js的编译链会闹脾气。
交互式命令行会问你要不要装tRPC、Prisma、Tailwind、NextAuth等等,我第一遍点的时候全选了,结果项目里躺了一堆用不上的依赖。实际做项目时,我的建议是分场景取舍:
| 选项 | 什么时候选 | 什么时候不选 |
|---|---|---|
| tRPC | 所有前后端交互都走这个,必选 | 纯静态站点、没有数据交互 |
| Prisma | 需要持久化数据,必选 | 只用本地Mock或第三方API |
| TailwindCSS | 想要快速出样式、统一设计变量 | 重度依赖组件库,如Ant Design |
| NextAuth | 需要账号体系,可以后面补 | 用自建JWT或第三方登录服务 |
2.2 目录结构里藏着的数据流秘密
初始化完的目录,第一眼看上去和普通Next.js项目没太大区别,但src/server与src/app的分界是T3的精髓:
src/ ├── app/ # Next.js App Router页面层 ├── pages/api/trpc/ # tRPC的唯一HTTP入口 ├── server/ │ ├── api/ │ │ ├── routers/ # 业务路由,比如postRouter、userRouter │ │ └── root.ts # 聚合所有router │ ├── db.ts # Prisma客户端实例 │ └── auth.ts # NextAuth配置(如果选了) ├── trpc/ │ ├── server.ts # 服务端tRPC上下文与初始化 │ ├── client.ts # 客户端调用封装 │ └── react.tsx # React Hooks桥接 └── styles/很多新手在这里会犯一个认知错误:以为server/api/routers里能随便写任意服务端代码。不是的,tRPC的router只负责"接口"这一件事,重逻辑应该拆到router之外的service或lib里,router保持薄薄一层,职责清晰,后面测试也好写。
另外要注意,t3 code的官网模板默认用App Router,而不是老的Pages Router。App Router下的Server Component可以直接调用服务端逻辑,甚至不需要走tRPC的HTTP层;但客户端交互(表单提交、按钮点击、无限滚动)还是得挂在trpc/react.tsx导出的Hooks上。理解这两条路径的差别,后面写页面时才不会迷茫——能Server端做的事就在Server端做,不能的才交给tRPC。
2.3 上下文(Context)里到底放了什么
tRPC的createTRPCContext是串联NextAuth、Prisma和请求信息的关键。初始化代码大概长这样:
export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { db, session, headers: opts.headers, }; };这个context会在每个请求到达时执行一次,并且注入到所有procedure的ctx参数里。这意味着你在任何一处业务代码都能通过ctx.db访问数据库、通过ctx.session?.user判断登录态。我在实际项目里几乎每个router都会用到这两个字段,所以这里的初始化质量,直接影响后面所有接口的编写体验。
3. tRPC:把接口层彻底"类型化"的实践
3.1 router、procedure与query/mutation的基础概念
tRPC的核心抽象只有三个:router(一组接口的集合)、procedure(一个接口,分query和mutation)、middleware(中间件)。用代码说话更直观:
// src/server/api/routers/post.ts import { createTRPCRouter, publicProcedure } from "../trpc"; import { z } from "zod"; export const postRouter = createTRPCRouter({ latest: publicProcedure .query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: 10, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().min(1), })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: input }); }), });query对应GET型操作(读数据),mutation对应写操作(增删改)。注意input用了zod做运行时校验——这是T3栈全链路类型安全的起点。zod既能在运行时拦住非法请求,又能让input的类型在前后端同时被推导出来,客户端调用时连参数类型都是提示齐全的。
3.2 一个完整业务接口的编写过程:校验、鉴权、错误处理
光会写hello world级别不够,我拿一个带鉴权的删除接口举例,把细节讲透:
delete: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id }, }); if (!post) { throw new TRPCError({ code: "NOT_FOUND", message: "文章不存在", }); } if (post.authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "只能删除自己的文章", }); } await ctx.db.post.delete({ where: { id: input.id } }); return { success: true }; });这里的protectedProcedure来自一个中间件:如果ctx.session为空,直接抛UNAUTHORIZED。中间件在定义router之前统一声明,一次写好,全局生效:
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);几个容易踩的坑我想重点提一下:
- TRPCError的code不全是HTTP状态码,前端拿到的是统一的
error.message,所以在message里写详细原因比依赖状态码靠谱。 - 权限校验一定要在数据库操作之前做,顺序反了就会出现"先查库再判断权限",慢且不安全。
- zod的
uuid()校验很严格,如果前端传的是自增数字ID,记得改用z.number().int().positive(),不然会一直报Invalid input。
3.3 对比REST和GraphQL:什么时候选它最划算
我身边有朋友问:既然有REST和GraphQL,为什么还要用tRPC?我的答案很直接:如果你前后端都是TypeScript,且部署在一台服务器上(Next.js应用本身就同时承载前后端),tRPC是成本最低的方案。
REST的问题在于类型边界要靠工具生成(OpenAPI + codegen),而且每一个资源都要考虑URL设计、HTTP方法语义,项目一大,文档和代码就经常对不上。GraphQL的Schema更灵活,但引入Apollo一套东西,学习曲线和运行时复杂度都不小,小团队很难hold住。
tRPC的逻辑很简单:没有独立Schema文档,没有codegen,类型就是代码本身。前端引入的是同一个类型定义,调用时IDE直接给全提示,连"接口文档过期"这种问题都不存在。代价就是你被绑死在TypeScript生态里了——如果哪天有第三方用Python/Go来调你的接口,tRPC这套就不那么友好了,一般建议用tRPC做内部接口,外部真正对外开放的API再用REST单独包一层。
4. Prisma与数据库:让SQL也变成类型安全的体验
4.1 schema建模与迁移的工作流细节
Prisma在这个栈里扮演的是"数据库访问层"的角色。第一次用的时候我最喜欢的是它的schema文件,一眼就能看完整套数据模型:
model User { id String @id @default(cuid()) email String @unique name String? posts Post[] createdAt DateTime @default(now()) } model Post { id String @id @default(cuid()) title String content String author User @relation(fields: [authorId], references: [id]) authorId String published Boolean @default(true) createdAt DateTime @default(now()) }改完schema之后的固定动作是:
npx prisma db push # 开发环境:直接同步到数据库 npx prisma migrate dev # 开发环境:生成迁移文件并应用 npx prisma migrate deploy # 生产/CI环境:执行已有迁移这里我强烈建议用migrate dev而不是db push,哪怕是在本地。db push虽然快,但不会生成迁移历史,时间一长,生产环境的库和本地schema没法对账,部署时一脸懵。迁移文件是团队的资产,能回滚、能review,别嫌麻烦。
4.2 关联查询、分页、筛选的常见写法
Prisma的查询API是我用过的ORM里最接近直觉的。一对多关联查询:
const userWithPosts = await ctx.db.user.findUnique({ where: { id: userId }, include: { posts: true }, });需要分页和筛选时,组合条件写起来也很清晰:
const posts = await ctx.db.post.findMany({ where: { published: true, title: { contains: keyword, mode: "insensitive" }, }, include: { author: { select: { name: true } } }, orderBy: { createdAt: "desc" }, skip: (page - 1) * pageSize, take: pageSize, });有几个细节值得留意:
mode: "insensitive"只对PostgreSQL和MySQL有效,SQLite不支持,本地开发如果用的SQLite,关键词搜索会变成大小写敏感,测试和线上行为不一致。include一口气把关联数据拉出来很爽,但要注意N+1问题。一个列表10条文章,每条文章都include作者,Prisma会把它生成一条带JOIN的查询,问题不大;但如果关联的是两层以上的嵌套,最好用select按需取字段,减少传输量。- 大表分页建议用
cursor游标而不是skip/take,否则页码越深越慢。Prisma的cursor做的是基于唯一索引的定位,性能稳定得多。
4.3 类型推导如何贯穿前后端
Prisma生成类型的方式是prisma generate,它会把schema里所有模型变成@prisma/client导出的TypeScript类型。T3栈里tRPC的procedure返回值不会做二次断言,而是直接推导ctx.db的结果类型。
这带来一个非常爽的体验:前端postRouter.latest.useQuery()拿到的data,就是Post[],且Post的字段和author关联关系全部有类型;你在前端写data.author.name如果字段不存在,编译期直接报错。从数据库到组件,没有任何一处手写接口类型。这就是tRPC+Prisma组合的杀手锏。
5. TailwindCSS的样式开发与性能控制
5.1 原子化CSS的真实开发体验
作为一个常年写CSS的人,我一开始对Tailwind是持怀疑态度的——不就是内联样式换个写法吗?真正在T3项目里用下来才发现,它最大的价值不是省代码,而是把"样式决策"压缩到了最小单位。写一个卡片组件,不用纠结类名叫card-container还是card-wrapper,直接在JSX里堆类名:
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm hover:shadow-md transition-shadow"> <h2 className="text-lg font-semibold text-gray-900">{post.title}</h2> <p className="mt-2 text-sm text-gray-600 line-clamp-2">{post.content}</p> </div>习惯了这种写法之后,回到传统CSS反而觉得累:起名字、找文件、切上下文。尤其T3这类全栈项目,逻辑层代码已经不少,样式层面能少一个心智负担是一个。
5.2 主题定制与暗色模式落地
Tailwind在T3项目里默认配置了tailwind.config.ts,要加主题变量很直接:
import type { Config } from "tailwindcss"; export default { darkMode: "class", theme: { extend: { colors: { brand: { 500: "#6366f1", 600: "#4f46e5", }, }, }, }, } satisfies Config;暗色模式我现在统一用darkMode: "class",而不是默认的media。原因是用户手动切换明暗的诉求在现代Web应用里太常见了,靠系统偏好一锤定音不现实。配合Next.js的next-themes库,几行就能搞定持久化和闪屏问题。样式文件里只要出现dark:bg-gray-900这类变体,就会自动因html上的dark类切换。
5.3 JIT编译与打包体积的注意事项
Tailwind的新版本默认JIT编译,也就是只生成你在代码里用到的类。但有一个坑:如果你在数据库里存了颜色值,然后在前端动态拼接类名,比如`bg-${color}-500`,JIT是扫不到这个字符串的,编译出来这个类就不存在。解决方法是把完整的类名写进配置的safelist,或者干脆用内联样式处理动态颜色。
另外,T3项目的页面如果是纯SSR且没多少按钮弹窗,Tailwind的CSS体积一般能压在10KB以内,但如果引入了第三方组件库(比如Headless UI),要注意按需导入,否则样式表和JS都会被撑大。
6. 部署上线与踩坑实录
6.1 环境变量、迁移命令在CI/CD中的执行顺序
T3项目部署到Vercel或者自己的服务器,环境变量是第一个杀手。特别提醒:.env和.env.example的约定要遵守好,新人最容易犯的错是把DATABASE_URL写成localhost就提交了,或者线上环境没有配NEXTAUTH_SECRET导致登录一直失败。
正常CI/CD里的执行顺序应该是:
- 安装依赖
- 跑
prisma generate - 跑
prisma migrate deploy - 构建
next build - 部署产物
顺序不能乱。next build过程中Next.js会尝试静态渲染一部分页面,如果你的Server Component里调用了prisma,但这时还没generate,构建直接报错。migrate deploy必须在build之前跑,因为构建时可能就需要连数据库查数据。
6.2 生产环境的数据库连接池与SSR并发
本地开发连数据库毫无压力,一上生产就开始报too many connections,这个问题我见过太多次了。Prisma默认的连接池大小是num_cpus * 2 + 1,在Serverless环境(Vercel函数)里,每个函数实例都会单独创建连接池,并发一高,数据库连接数直接爆掉。
解决思路有三个,按优先级排序:
- 数据库端设置合适的
max_connections,并把Prisma连接池上限调小,比如?connection_limit=5。 - 用PgBouncer这类连接池代理,专门解决Serverless短连接问题。
- 避免在Server Component里高频执行数据库查询,把重查询挪到tRPC的query里,方便统一控制和缓存。
6.3 我实打实遇到过的几个坑
第一个坑:NextAuth的session在tRPC context里是异步的。getServerAuthSession()返回的是Promise,如果在createTRPCContext里忘了await,后面所有protectedProcedure都会拿到空的session,页面一直跳登录,排了半天才发现是这种低级问题。
第二个坑:zod的error默认不友好。前端提交非法表单时,默认的报错信息是英文的一长串路径描述。我后来统一在入口加了一层格式化中间件,把zodError转成字段级别的中文提示,体验立刻不一样。
第三个坑:Prisma在Next.js构建时的警告。如果你在代码顶层直接new PrismaClient(),开发时热更新会导致实例反复创建,官方推荐用globalThis缓存:
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;这段代码几乎是T3项目的标配,直接抄进src/server/db.ts就行。
7. 进阶方向与学习路径建议
7.1 性能优化:从查询优化到加载态设计
基础跑通之后,我建议把性能优化分成两层来做。
数据层优先看tRPC的query缓存。tRPC的react客户端配合react-query,默认就有缓存能力,但很多人没设置staleTime,导致同一接口在多个组件里调用时重复请求。按业务设置五秒到一分钟的staleTime,能显著降低数据库压力。
展示层学会拆分useQuery的数据粒度。比如帖子详情页,标题和正文是强关联的,一起查没问题;但侧边栏的"热门标签"是低频变化数据,建议单独一个query,设置更长的缓存时间。T3文档里推荐的"按数据变化频率设计接口粒度"这个思路,越早领会,后面优化越省力。
7.2 测试策略:类型安全不等于没有bug
类型安全解决的是"调用错"的问题,解决不了"逻辑错"的问题。T3项目里我建议至少覆盖三层测试:
- zod校验层:输入边界、异常输入,这是最便宜、最有效的测试。
- tRPC router集成测试:用
createCallerFactory直接调用router,不需要起HTTP服务,速度很快。 - 关键页面组件的交互测试:用Vitest+Testing Library,mock掉tRPC的query即可。
// router集成测试示例 import { createTRPCContext } from "../src/server/api/trpc"; import { appRouter } from "../src/server/api/root"; test("未登录用户创建文章应报错", async () => { const ctx = await createTRPCContext({ headers: new Headers() }); const caller = appRouter.createCaller({ ...ctx, session: null }); await expect(caller.post.create({ title: "hi", content: "hello" })) .rejects.toThrow("UNAUTHORIZED"); });7.3 从T3出发,下一步的技术扩展
T3栈是一个很好的起点,但业务复杂到一定程度,会有几个自然的扩展方向:
- 引入消息队列或后台任务:Prisma只管同步操作,异步任务建议交给别的队列服务,别硬塞进tRPC的mutation里。
- 文件上传:本地磁盘不可靠,尽早接入对象存储,tRPC只管返回上传凭证。
- 多服务拆分:如果团队开始写Python/Go服务,tRPC只做内部接口层,外部API单独用REST或gRPC暴露。
- 实时功能:tRPC本身不提供WebSocket,可以用socket.io或SSE互补,注意鉴权要复用同一套session。
说实话,T3这套技术选型并不完美,它也牺牲了对外部生态的兼容性,换取的是全栈TypeScript的极致内聚。我个人实操下来的体会是:中小型全栈项目用它开发速度是真的快,迭代一个版本可能只需要别人一半的时间;项目变大之后也不需要推翻重来,该补的模块化、缓存、队列按需加就行。如果你还在犹豫要不要入坑,我建议直接拿一个真实的小需求跑一遍完整流程——从建模型、写router、调UI到部署上线,半天就能感受到这套组合拳的威力。