我最早接触到 t3code 这个词,是在逛技术社区的时候看到一群人在讨论 T3 Stack。当时第一反应是“又一个爆火的缩略词”,但深入了解之后发现,它其实是一套非常务实的全栈解决方案,核心就是 Next.js + TypeScript + Tailwind CSS + tRPC + Prisma + NextAuth 这套组合。t3code 可以理解为 T3 Stack 的编码实践,也可以说是一套把类型安全贯穿到数据库、接口、页面每一个环节的全栈开发配方。
我实际用这套方案重构过一个团队任务管理工具,前后折腾了大概两周。最大的感受是:以前写全栈项目,前端一个 axios 封装、后端一堆 controller、数据模型又要单独维护类型定义,光胶水代码就能写一整天。用 T3 Stack 之后,类型从前端组件一路流到数据库表结构,中间几乎不用手写任何接口契约。
这篇文章不打算做官方文档复述,我想从一个实际开发者的角度,聊聊 T3 Stack 的每个组件到底解决什么问题、怎么一步步搭起来、实际开发中有哪些坑,以及什么情况下我建议你别用这套方案。
1. 从 t3code 这个名字说起:T3 Stack 到底解决了我什么问题
先说一个很多人刚接触时的困惑:t3code 和 T3 Stack 是什么关系?在我理解里,t3code 更偏向“T3 Stack 生态下的代码实践”,而 T3 Stack 指的是由 t3.gg 社区推广的一套全栈技术组合。它的核心成员是 TypeScript、Tailwind CSS 和 tRPC 这三个以 T 开头的家伙,再加上 Next.js 作为应用容器、Prisma 负责数据库访问、NextAuth 处理身份认证。
我第一次被它打动,是因为一条很长的报错链。
之前做一个内部项目,后端写了一个接口返回用户列表,前端调用时不小心把user.name拼成了user.nmae。这个错误在编译期根本发现不了,只有跑起来之后看到页面上全是 undefined 才知道。而且如果接口字段改了,后端改了,前端不跟着改也不会有人提醒你。
T3 Stack 的做法是让 TypeScript 在编译期就阻止这类错误。你在前端调用 tRPC 的 query 时,返回的数据结构直接由后端 procedure 的返回值推导出来。字段拼错了,IDE 直接给你标红。这不是什么魔法,只是把类型信息通过 tRPC 的泛型一路穿透到前端。
需要强调的是,T3 Stack 不是某个公司推的框架,而是一套社区共识的工程组合。它的官方文档里写过三条原则,我用自己的话翻译一下:
- 类型安全:从数据库表到前端 UI,整个数据链路都有一致的类型约束,而不是每层各自维护一份“类型副本”。
- 一体化(Full-stack):用 Next.js 一个应用同时承担前端页面和后端 API,减少部署节点和跨环境沟通成本。
- 渐进式采用:你可以先用 Next.js + TypeScript,后面再逐步引入 Prisma、tRPC 或 NextAuth,不用一上来就全家桶。
我实际用下来的感受是,这套组合特别适合中小型全栈项目、内部工具、创业团队的产品原型。它的天花板可能不如微服务架构那么高,但站在“快速交付一个能用的系统”这个角度,效率是真的高。
1.1 三种人最适合马上试 T3 Stack
- 独立开发者或小团队:一个人要写前端、后端、数据库,T3 Stack 能把状态管理的负担降到很低。
- 从普通前端转全栈的开发者:不需要重新学一门后端语言,TypeScript 到底,心智负担小。
- 做 MVP 和原型验证的产品团队:几天内就能出一个能登录、能读写数据库的真实应用。
如果你正处于一个“每个接口都要写 REST 文档、每个模型都要手写 DTO”的阶段,那 T3 Stack 这类方案真的会给你打开新世界的大门。
2. 拆开每个螺丝:这套组合拳里每个组件都有明确分工
很多人看到 T3 Stack 第一反应是“技术太多太杂”,实际上每一个组件都是在回答一个具体问题。我在项目里分别体会过它们单独拎出来用和组合起来用的感受,下面逐一说清楚。
2.1 Next.js 不只是 SSR 框架,它是全栈应用的容器
Next.js 在这里的角色是“宿主应用”。页面路由、服务端渲染、静态生成、API 路由都是它负责的。T3 Stack 选择它而不是 Vite + Express,是因为 Next.js 能同时管理“页面”和“服务端能力”两件事。
我用任务管理工具举例:任务列表页面需要 SEO 或首屏速度,可以用服务端渲染;而登录后的个人信息页需要隐私保护,则用客户端渲染。在同一个项目里,Next.js 的路由系统天然支持这些场景混用,不会让你为每一个页面写两套服务体系。
新版本的 App Router 还带来了 Server Components,这让事情更有意思了。部分数据可以直接在服务端取、直接渲染成 HTML,再配合 tRPC 处理交互性较强的部分,结合起来非常顺手。
2.2 tRPC:前端调用后端的体验,像调用本地函数一样
tRPC 是 T3 Stack 里最容易被低估的一个。它的核心思想是:既然前端和后端都用 TypeScript,为什么还要写一大堆 URL、请求方法、响应码的约定?
传统 REST 接口流程是:后端定义路由 → 前端拼接 URL → 前端约定类型 → 调用。每加一个新接口,至少三处地方要同步修改。tRPC 简化成了:后端写一个 procedure → 前端直接调用同名函数,类型自动推导。
我重构任务管理工具时,把原来 800 行的 REST controller + axios 封装,换成了十几个 tRPC procedure,代码量骤降。你要说性能上有什么区别?HTTP 还是那个 HTTP,请求还是在网络上走一遍,但开发体验完全不同了:不需要记接口路径,不需要手写请求类型,参数错了编译期就会报错。
有人会拿 GraphQL 来比。GraphQL 解决的是“多个端按需取字段”的问题,它有自己的 Schema 语法和解析器那一层。tRPC 更简单,直接复用 TypeScript 类型系统,没有额外的 Schema 文件。对于“一个 Web 应用配一个后端”这种场景,tRPC 的性价比明显更高。
2.3 Prisma:让数据库表拥有 TypeScript 级别的保障
Prisma 在 T3 Stack 里的位置是 ORM。它做的事是用schema.prisma文件定义数据模型,然后自动生成类型安全的查询客户端。
比如我给任务表加了一个status字段,值只能是TODO、IN_PROGRESS、DONE。传统写法是数据库里定义一个枚举,应用层再定义一个枚举或联合类型,两者靠人肉同步。Prisma 的做法是:你在 schema 里定义一次,生成的查询结果类型里status自动变成联合类型,写"DONE"能通过,写"DON"编译期直接报错。
更让我喜欢的是它的迁移机制。改完表结构后运行prisma migrate dev,它会自动生成迁移 SQL,并同步数据库结构。这比一堆手工维护的 SQL 脚本要安全得多,尤其当团队里有好几个人在同时改模型时,迁移冲突能提前发现。
2.4 Tailwind 和 NextAuth:两个被低估的效率组件
Tailwind CSS 被一些人当成“工具类膨胀”,但它真正解决的是“原子级样式复用”的问题。在 T3 Stack 项目里,页面组件可以一边写结构一边写样式,不用频繁切换 CSS 文件。加上@tailwindcss/forms这类插件,表单、按钮、卡片这些高频组件能很快统一风格。
NextAuth 是我之前一直绕开的组件,觉得配置复杂。但在 T3 Stack 里它和 tRPC 的集成已经做得相当顺滑。你只要配置好 OAuth Provider,比如 GitHub、Google,在 tRPC 的 context 里读取 session,就能拿到当前登录用户的信息。鉴权和业务代码不用纠缠在一起。
这套组合的价值不在任何单个工具,而在它们彼此之间的类型互通:Prisma 的模型类型能直接出现在 tRPC 的返回值类型里,NextAuth 的用户对象能直接被 tRPC 的 middleware 消费。类型系统把整条链路焊死了,这才是 T3 Stack 真正厉害的地方。
3. 实操:从空目录到能跑的项目,我用 create-t3-app 做了什么
说了这么多理论,下面进入实操。我用一个“团队任务管理工具”作为示例项目,后面几章的所有代码和经验都会围绕它展开。
3.1 脚手架命令与交互式选择
T3 Stack 官方提供了脚手架工具create-t3-app,一条命令就能生成项目。我创建项目时用的是:
npm create t3-app@latest my-task-app运行之后会出现交互式选项,新版脚手架会让你确认是否开启 TypeScript、Tailwind CSS、tRPC、NextAuth、Prisma 等。我第一次创建时全部选了开启,因为正好想体验全家桶。实际项目里,如果你现在还不需要 NextAuth,完全可以先不勾选,后面再手动加。这符合前面说的“渐进式采用”原则。
脚手架还会问 App Router 还是 Pages Router。现在新项目默认推荐 App Router,我也建议选它,虽然有些旧教程还在用 Pages Router 的写法,但社区方向很明显已经往 App Router 迁移了。
安装完成后,项目结构大致是这样的:
my-task-app/ ├─ prisma/ │ └─ schema.prisma ├─ public/ ├─ src/ │ ├─ app/ │ │ ├─ api/ │ │ │ └─ trpc/ │ │ │ └─ [trpc]/ │ │ ├─ page.tsx │ │ └─ layout.tsx │ ├─ server/ │ │ ├─ api/ │ │ │ ├─ routers/ │ │ │ ├─ root.ts │ │ │ └─ trpc.ts │ │ ├─ auth.ts │ │ └─ db.ts │ ├─ trpc/ │ │ ├─ server.ts │ │ └─ react.tsx │ ├─ middleware.ts │ └─ styles/ │ └─ globals.css这套目录结构不是随便摆的:server/api集中管理后端能力,server/db.ts统一导出 Prisma Client,trpc/react.tsx提供前端调用的 Hooks。你写业务代码时,大脑里始终有一条清晰的“后端 router → 前端 hook”对应关系。
3.2 环境变量与数据库准备:最容易卡住新手的一步
项目创建完之后,第一步不是npm run dev,而是先把环境变量和数据库准备好。create-t3-app 会生成一个.env文件,里面至少包含DATABASE_URL和认证相关的 secret。
DATABASE_URL指向数据库地址。开发环境我建议直接用本地 Postgres,或者用一个云数据库例如 Neon 的免费版。关键是不要第一次就跑npm run dev,然后发现页面能打开但所有数据接口都报错,那是因为数据库还没连上。
配置好环境变量后,执行:
npx prisma migrate dev --name init这行命令会根据schema.prisma中定义的模型,生成并执行建表迁移。即使你还没改过任何代码,这一步也能让项目进入“可运行”状态。
运行后验证方式很简单:启动npm run dev,看到监听 3000 端口,打开http://localhost:3000,页面能正常渲染,就说明整个链路通了。如果这个基础页面都出问题,先回头检查DATABASE_URL和 secret 是否配置完整。
3.3 开发时我必改的三个默认配置
create-t3-app 已经给了很好的默认值,但我在真实项目里还是会改几处:
一是把trpc的时间戳和日志配置打开。在src/server/api/trpc.ts里,可以给每个 procedure 加一个简单的耗时统计。开发环境里你能直观看到哪个接口慢,而不是等用户反馈。
二是统一 Zod 错误映射。tRPC 默认返回的错误信息偏原生,我会在router外层加一层 errorFormatter,把 Zod 的校验错误转换成结构化的字段错误。这个在后面讲的“输入校验”里会展开。
三是把 Prisma Client 的日志开启。在server/db.ts里添加log: ["query", "error"],数据库查询会直接打印在控制台。这个对排查 N+1 问题和慢查询非常有帮助。
4. tRPC Router 开发:从一开始就按业务领域组织,而不是按页面组织
很多初学者第一次用 tRPC 时会犯一个错误:按页面去建 router,比如邮一个pageA.ts的 router 对应首页的接口。这在小型项目里看起来没毛病,但项目只要稍微变复杂,页面 A 用到了用户数据、页面 B 也用到了用户数据,你就会陷入接口拆分的混乱。
我建议从一开始就按业务领域组织 router:taskRouter管任务、userRouter管用户、projectRouter管项目。每个 router 内部可以包含多个 procedure,查询和修改操作都放一起。
4.1 一个实际的 router 长什么样
下面是我在任务管理工具里写的一个简化版 taskRouter,重点是看它的结构,而不是代码本身:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc"; import { TRPCError } from "@trpc/server"; export const taskRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ status: z.enum(["TODO", "IN_PROGRESS", "DONE"]).optional(), page: z.number().int().min(1).default(1), }), ) .query(async ({ ctx }) => { const tasks = await ctx.db.task.findMany({ where: { assigneeId: ctx.session.user.id, ...(status ? { status } : {}), }, include: { project: true, }, }); return tasks; }), create: protectedProcedure .input( z.object({ title: z.string().min(1, "标题不能为空"), projectId: z.string().min(1), status: z.enum(["TODO", "IN_PROGRESS", "DONE"]).default("TODO"), }), ) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title, status: input.status, projectId: input.projectId, assigneeId: ctx.session.user.id, }, }); }), });这里有两个 procedure:list是查询,create是修改。注意它们都用了protectedProcedure,也就是说用户未登录时,调用会直接抛 UNAUTHORIZED 错误。这个中间件正是 T3 Stack 类型安全体验的一个具体体现。
4.2 为什么要用 Zod 做输入校验
有人会觉得 tRPC 已经自动处理了类型,为什么还要用 Zod?因为 TypeScript 是编译期校验,它防不住运行时传来的非法参数。
比如前端传一个status字段,类型写着"TODO" | "IN_PROGRESS" | "DONE",但请求完全可以通过某种方式把别的字符串带进来。如果没有运行时校验,非法值会直接穿过 tRPC 到达 Prisma,最后引发一个让人莫名其妙的数据库错误。
Zod 做的事情是在 procedure 入口处建立一道检查关卡。你可以把它理解为后端界的“表单校验器”。这也是 T3 Stack 官方推荐的标准做法:所有input都用 Zod 定义,好处是既能推导出参数类型(无需再手写 TS interface),又能保证运行时安全。
我在项目里还加了一个统一的 errorFormatter,把 Zod 错误转换成格式如{ field: "title", message: "标题不能为空" }的对象返回给前端。这样前端表单的<FieldError>组件可以直接渲染后端校验结果,不需要前端再写一遍校验规则。
4.3 Context 里的坑:不要每个请求都重新创建 PrismaClient
tRPC 是一个请求生命周期的概念,Context 会在每个请求进来时创建一次。很多人第一次写时把 PrismaClient 也放进 context 里新建,导致每个请求都会创建一个新的数据库连接实例。开发环境没感觉,一旦部署到 Vercel 之类的 Serverless 平台,瞬时并发一高,数据库连接池直接被打满,然后就出现一堆连接超时的报错。
create-t3-app 默认在src/server/db.ts里用了一个全局单例,类似这样:
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = db; }为什么用globalThis?因为开发环境的模块热更新会导致文件被反复重新执行,如果不把实例挂到全局对象上,每次热更新都会新建一个 PrismaClient 实例。这个单例模式看着很简单,但真能避免大量隐蔽的连接泄漏问题。
4.4 把业务逻辑抽离出去,别让 router 层无限膨胀
tRPC 的 router 文件很容易变成“大杂烩”。当你把查询、创建、更新、删除、权限判断全写在一个 procedure 里时,几百行代码就来了,复用和维护都变得困难。
我的习惯是:router 只负责“输入校验 + 调用服务 + 返回结果”,具体业务逻辑抽到server/services/目录下的函数里。比如“创建一个任务并自动给项目成员发通知”这种流程,应该封装成一个taskService.createTask(),router 里只做简单的转发。
好处很明显:在写单元测试时,你可以直接对 service 函数测试,不用每次都启动 tRPC 上下文。第二个好处是,如果以后某个接口需要被定时任务或脚本调用,你依然可以调用同一个 service 函数,而不必伪造一个 tRPC 请求。
5. Prisma 实战:从数据模型设计到查询优化的避坑指南
Prisma 是 T3 Stack 里最靠近“数据库”的一环。它设计得好不好,直接决定了业务代码写起来顺不顺。这一章我结合任务管理工具的数据模型,聊聊从 schema 设计到查询优化的一些实际经验。
5.1 schema 设计的关键决策
任务管理工具里最核心的是Task模型,它关联用户(执行人)和项目。我简化后的 schema 如下:
model User { id String @id @default(cuid()) name String? email String @unique emailVerified DateTime? image String? accounts Account[] tasks Task[] projects Project[] } model Project { id String @id @default(cuid()) name String tasks Task[] members User[] } model Task { id String @id @default(cuid()) title String status Status @default(TODO) dueDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt assignee User @relation(fields: [assigneeId], references: [id]) assigneeId String project Project @relation(fields: [projectId], references: [id]) projectId String } enum Status { TODO IN_PROGRESS DONE }有几个设计上的小经验:
- 主键用
cuid()而不是自增整数。对于面向用户的数据,CUID 不会泄露“这个 ID 是不是第三个创建”的信息,也更好在客户端缓存。 updatedAt一定要用@updatedAt标签。Prisma 会在每次更新时自动维护它,你不用手动踩点。- 枚举尽量在 Prisma 层声明,而不是靠字符串硬编码。这样数据库层面和 TypeScript 层面的取值约束能同步起来。
5.2 migrate dev 与 migrate deploy 有什么不同
这是新手最容易混淆的一组命令。
prisma migrate dev是开发环境用的。它会检测 schema 变化、生成迁移文件、应用到本地数据库。prisma migrate deploy是生产发布用的。它只应用已经存在的迁移文件,不会根据本地 schema 擅自修改远程数据库。
我见过不少人在生产环境跑migrate dev,结果本地一个 pending 的模型改变直接干掉线上的一张表。一定要记住:本地模型改完,先把migrate dev生成的 SQL 文件提交到代码仓库,发布流程里再执行migrate deploy应用。这样改哪张表、怎么改,都是经过代码审查的,不是某台开发机器临时生成的。
5.3 一不小心就会踩的 N+1 查询问题
Prisma 的灵活性带来一个副作用:可以在页面上按需取关联数据,但很容易在循环中触发大量查询。
比如我在任务列表页面一开始写的代码是:
const tasks = await ctx.db.task.findMany({}); // 然后在前端循环里,遍历每个 task 的 assignee 信息结果 DevTools 里看到网络请求基本正常,但服务端日志打出来几百条 SQL。原因是我在前端map循环里为每个 task 额外调了一次 tRPC query 获取用户信息,等于一次列表接口背后藏着 N 次额外查询。
正确做法是在findMany时直接include关联数据:
const tasks = await ctx.db.task.findMany({ include: { assignee: { select: { id: true, name: true, image: true } }, project: { select: { id: true, name: true } }, }, });这样一次 Prisma 查询就返回完整结构,前端也不再需要发第二个请求。Prisma 虽然在性能上有一定开销,但只要你养成“按需 include 而不是事后补查”的习惯,大部分 N+1 问题都可以避免。
5.4 多步骤更新用事务,别让数据半途而废
任务管理工具里有这样一个场景:把一个任务移动到“已完成”时,需要同时更新任务状态和项目的进度统计。如果没有事务保护,任务状态更新成功、统计更新失败,就会出现前台界面和数据表不一致的结果。
用 Prisma 的事务写得非常自然:
await ctx.db.$transaction([ ctx.db.task.update({ where: { id: taskId }, data: { status: "DONE" }, }), ctx.db.project.update({ where: { id: projectId }, data: { completedCount: { increment: 1 } }, }), ]);把多个写操作放进一个事务里,要么全部成功,要么全部回滚。这个功能在开发太重要了,因为真实业务里“一个操作影响多张表”的情况几乎每天都在发生。
6. 鉴权接入:把用户的登录态和安全控制做对
T3 Stack 里鉴权的默认方案是 NextAuth。它支持非常多 OAuth Provider,也能用邮箱密码登录,甚至支持 Credentials 自定义。我在实际项目中主要用了 GitHub 和 Google 两种 OAuth,因为队友用这两个最普遍。
6.1 基本配置与 Provider 选择
NextAuth 的配置文件在src/server/auth.ts,核心长这样:
import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import Google from "next-auth/providers/google"; export const authOptions = { providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), Google({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }), ], }; export default NextAuth(authOptions);然后要在.env里配置GITHUB_CLIENT_ID、GITHUB_CLIENT_SECRET、GOOGLE_CLIENT_ID、GOOGLE_CLIENT_SECRET这些值。去各自的 Developer Console 新建 OAuth App,回调地址指向http://localhost:3000/api/auth/callback/github。
实际踩过的坑有两个:一是新版 NextAuth 推荐的 secret 变量名是AUTH_SECRET,老教程里常见的是NEXTAUTH_SECRET。如果项目跑了新的版本,按旧配置经常会出现 session 无法持久化的问题。二是如果想在生产环境使用 OAuth,回调地址里必须填最终部署的域名,不能一直留着 localhost。
6.2 在 tRPC 的 protectedProcedure 里读取用户信息
create-t3-app 已经帮你把 NextAuth 和 tRPC 接好了。在src/server/api/trpc.ts里,有一段类似下面的代码:
const createTRPCContext = async ({ session }) => { return { db, session, }; };然后在每个 procedure 里你可以通过ctx.session.user拿到当前登录用户。NexthAuth 的 session 类型如果没做扩展,默认只有user.name、user.email、user.image,但很多业务需要一个稳定的user.id。create-t3-app 的src/server/auth.ts里已经帮你扩展了:
interface Session { user: { id: string; name: string; email: string; image: string; }; }有了user.id,任务管理工具里的“当前执行人”就可以直接绑定到登录用户。任务创建时设置assigneeId: ctx.session.user.id,查询时按assigneeId过滤,所有操作都带上用户维度,共享同一个数据库也不会乱。
6.3 用 middleware 保护路由,但别把一切变得全面带锁
tRPC 层可以保护 API 调用,页面路由层的保护是通过middleware.ts做的。create-t3-app 默认会在src/middleware.ts里写一个基于 NextAuthwithAuth的拦截器,最常见的配置如下:
import { withAuth } from "next-auth/middleware"; export default withAuth({ pages: { signIn: "/auth/login", }, }); export const config = { matcher: ["/dashboard/:path*", "/projects/:path*"], };这样做的好处是,用户未登录访问/dashboard时,会自动重定向到登录页。我在项目里还写了一个/auth/login页面,把 NextAuth 的登录按钮放进去,用户不需要被强制走 NextAuth 自带的默认页。
但有一件事需要提醒:不要用matcher: ["/(.*)"]全站拦截。你如果拦截一切页面,包括登录页本身,用户还没登录就会被重定向到登录页,然后又因为未登录被重定向,出现循环。这种问题排查起来很隐蔽,但优化方案也很简单:只保护需要登录才能访问的路径前缀,其他页面放给页面内逻辑自行判断。
7. 部署上线:在 Vercel 这类 Serverless 环境最容易踩的四个坑
T3 Stack 的官方推荐部署平台是 Vercel。Next.js 和 Vercel 本来就是一家人,部署体验非常顺滑:把代码推到 GitHub,Vercel 拉取仓库,配好环境变量,点击 Deploy 就上线了。但真正跑起来之后,有几个细节是文档里容易被忽略的。
7.1 环境变量和构建命令要重点核对
在 Vercel 的项目设置里,Environment Variables 需要手动添加。以 T3 Stack 为例,至少要填:
| 变量名 | 说明 |
|---|---|
DATABASE_URL | 生产数据库连接串 |
AUTH_SECRET | 用于 session 加密的随机字符串 |
GITHUB_CLIENT_ID | 生产环境的 GitHub OAuth ID |
GITHUB_CLIENT_SECRET | 生产环境的 GitHub OAuth Secret |
GOOGLE_CLIENT_ID | 生产环境的 Google OAuth ID |
GOOGLE_CLIENT_SECRET | 生产环境的 Google OAuth Secret |
另外有一点非常关键:如果项目使用了 Prisma,需要确保构建阶段能执行prisma generate。虽然npm install默认不会自动跑这个命令,但 create-t3-app 生成的 package.json 里通常有postinstall脚本。如果没看到,就在 Vercel 的 Build Command 里改成:
prisma generate && next build否则线上很容易出现@prisma/client did not initialize yet这类报错。注意生产环境的DATABASE_URL建议使用具备连接池能力的连接方式。因为 Serverless 环境下每次请求可能都会打开新的数据库连接,连接数很容易瞬间被耗尽。
7.2 Serverless 环境的连接数问题,值得专门说一次
我在开发工具第一次部署到 Vercel 后,遇到过一个异常:本地一切正常,但只要线上同时有七八个请求,就有几个请求报数据库连接超时。
排查日志后发现,Prisma 在 Serverless 环境下新建连接太快,而 Postgres 默认连接数上限很低。解决方法有几种:使用连接池(比如 Supabase 的 pooler 连接串),或者调整 Prisma 的连接池大小配置。还有一个思路是给schema.prisma加上:
datasource db { provider = "postgresql" url = env("DATABASE_URL") }这样 Prisma 就没有默认连接池限制,由 Serverless 平台和数据库端的连接池来管理。更保险的做法是直接使用云数据库厂商提供的连接池端口,而不是直连数据库实例。
7.3 长任务和定时任务别指望在 Vercel 函数里跑
Serverless 函数有执行时间上限。如果任务管理工具里有一个“每天给成员发任务逾期提醒”的定时任务,我的建议是不要写在 Next.js API 路由里,因为执行超时后调度不太可控。
正确的做法是用外部队列或调度服务,比如 Upstash Cron 或 Trigger.dev。你只需要拿一个 HTTP 请求“远程调用”tRPC 里的某个公共 procedure。举个例子:定时任务调用http://localhost:3000/api/trpc/task.checkOverdue,这个 procedure 只做查询和发通知,执行完就返回。把“什么时候执行”交给专门的调度平台,把“干什么活”留在你的应用里,职责清晰且好维护。
7.4 tRPC 的缓存与响应头设置
默认情况下,tRPC 的 query 是可以被浏览器和 CDN 缓存的,但需要设置Cache-Control响应头。对于任务列表这类实时性不强的数据,给 GET 类型的 query 加上缓存非常划算:
publicProcedure .query(async ({ ctx }) => { const tasks = await ctx.db.task.findMany({ ... }); return new Response(JSON.stringify(tasks), { headers: { "Cache-Control": "s-maxage=60, stale-while-revalidate=120" }, }); });实现方式略有差异,但核心思路是:知道哪些是真正的动态数据,哪些是几乎不变的数据,然后按需缓存。否则所有请求都直接穿透到数据库,Serverless 平台的账单和数据库负载会让你后悔。
8. 关于 T3 Stack 的日常思考:什么时候别用它,以及我最后留下的几条小建议
聊了这么多实操,最后我想说点不那么“技术正确”的大实话。T3 Stack 不是银弹,它有自己的边界。
8.1 什么情况我会主动放弃 T3 Stack
我用下面这张表帮自己快速判断:
| 场景 | 是否适合 T3 Stack | 原因 |
|---|---|---|
| 中小型全栈应用、内部工具、MVP | 适合 | 类型安全穿透,迭代速度快,部署简单 |
| 纯静态展示型网站 | 不适合 | 不需要后端和数据库,用 Vite/Next 即可,没必要引入 tRPC + Prisma |
| 大规模微服务架构 | 不适合 | tRPC 假设前后端同仓库同语言,微服务之间存在多语言异构时优势会被削弱 |
| 强实时协作场景(如在线文档) | 谨慎 | tRPC 支持 subscription,但用 WebSocket 长连接和 CRDT 还是需要一个专用方案 |
| 特别依赖第三方 REST API 的轻前端 | 不一定 | 你可以只用 Next.js + Tailwind,把 tRPC 替换成 fetch 封装 |
我自己最看重的是“端到端类型安全”这一点。如果你的团队已经习惯了手写 OpenAPI 生成的类型定义,且前后端是分开独立部署的,那么 tRPC 的优势确实会被稀释,引入反而带来额外的学习成本。
8.2 推荐的两个工程化习惯
第一,把 tRPC 的调用 Hooks 按查询键合理分层。react-query 的状态管理能力很强,但很容易变成每个页面都有一堆useQuery和缓存失效逻辑。我通常会在src/trpc/react.tsx等下文件里导出一个统一的useTaskQueries,把“任务列表的数据获取和失效”封装成一组方法,业务页面只关心渲染。
第二,给 tRPC 的 procedure 约定统一的错误码。不要每个 router 各写各的throw new Error。项目里我会定义一个自定义错误枚举,比如NOT_FOUND、UNAUTHORIZED、DUPLICATE_RECORD,然后在 errorFormatter 里统一转换。这样前端判断业务错误时,不用去理解不同错误对象的 shape,排查问题也快很多。
8.3 我给新手的三句话
从零开始接触 T3 Stack 的开发者,最常问我的问题无非是“我要不要先把所有文档都读完”。我的回答是:不,先跑起来。create-t3-app 生成的最小项目已经足够让你体验整套链路的魅力。先写一个带 tRPC 查询的页面,再加一个 Prisma 模型,然后接上 NextAuth 登录,完成这三个小循环之后,你对 T3 Stack 的认知会比看十篇教程更深刻。
技术选型没有绝对的对错,T3 Stack 让我重新体会到“前后端同构”的爽快感:写一个功能时,我只是从页面一路写到数据库,类型系统一直拉着我往前走,不用停下来设计一堆接口文档。这种体验你在用的时候可能会嗤之以鼻,然后突然某一天发现自己已经回不去了。如果你也打算做一个小而美的全栈产品,选 t3code 这条路试一下吧。