如果你最近在搜 t3code 这个词,大概率和我一样,是在研究 T3 Stack 这套全栈开发组合拳。我在几个内部项目和外包交付里把它当作默认脚手架用了超过一年,从最初的新鲜感到后来踩坑踩到条件反射,最后沉淀出一套个人工作流。这篇博文就是想把这些实践细节完整写出来:为什么我坚持选这套技术栈,端到端类型安全到底安全在哪里,从零搭一个带数据链路的全栈项目要分几步,以及生产环境里那些文档不会写、但你不注意就会炸的坑。
文章内容偏实战,面向已经会基础 React 和 Node.js 的开发者。如果是纯新手,建议先熟悉一下 Next.js 的 App Router 和 TypeScript 泛型,再回头看这篇文章,效果会好很多。
1. t3code 的定位:从三个痛点反推技术选型
1.1 传统全栈开发里我最受不了的三件事
先说我在用 T3 Stack 之前最头疼的三个场景,如果你也有同感,那这篇博文的思路你会很有共鸣。
第一,前后端类型不一致。后端 Java 或者 Node 写一套 DTO,前端 TypeScript 又手写一套 interface,两套定义靠人肉同步。项目跑三个月之后,谁都不敢保证前端那个User接口还和后端返回的数据长一个样。联调阶段 90% 的 Bug 都是字段名拼错、类型写错、字段多一个少一个。
第二,API 文档基本是废的。Swagger 文档刚写好那天是准的,改了两轮需求之后文档就没人信了。前端同事宁可直接看代码,也不愿意翻那个永远滞后的接口文档。
第三,模板代码太多。每加一个接口,要写 Controller、Service、DTO、路由注册、类型定义、前端请求函数、错误处理……一套流程下来,真正的业务逻辑可能就十行,周边代码搞了三百行。
1.2 t3code 这套组合是怎么解决这些问题的
t3code 本质上不是一个具体的新框架,它是把那几个已经被社区验证过的工具组装在一起,用约定胜过配置的方式减少决策成本。核心成员就五个:
| 技术 | 作用 | 突破口 |
|---|---|---|
| Next.js | 全栈应用框架,同时承载前端页面和后端 API | 一个应用里同时写页面和接口,部署也简单 |
| TypeScript | 全链路静态类型语言 | 前后端同一种语言,类型可以被推断和共享 |
| Tailwind CSS | 原子化 CSS 框架 | 写样式不用切上下文,开发效率极高 |
| Prisma | 数据库 ORM 和迁移工具 | Schema 即模型定义,生成的客户端自带类型 |
| tRPC | 类型安全的 API 调用层 | 从后端函数直接推断出前端调用类型 |
这五个东西单独拿出来都不是最新最酷的,但它们组合在一起之后形成了一个闭环:Prisma 的模型类型可以被 tRPC 的 router 直接引用,tRPC 的输出类型可以被 Next.js 的 Server Component 直接消费,前端页面调用 API 时拿到的类型和后端定义的类型由同一个 TypeScript 推断链路产生。
拿我最近做的一个任务管理小程序举例。以前的流程是后端定义一个GET /api/tasks,返回{ id: string, title: string, done: boolean }[],前端再复制一遍。在 t3code 工作流里,我在 Prisma schema 里定义Task模型,在 tRPC router 里写一个getAll方法,前端调用trpc.task.getAll.query(),返回的数据类型不用我写一个字符,编辑器里的自动补全直接给到title和done。
1.3 这套选择放弃了什么
没有任何技术选型是免费的,t3code 这套组合也有明确的代价。最明显的是它对团队 TypeScript 水平有要求,尤其是泛型部分。createT3Router、procedure.query这类 API 背后全是复杂的类型体操,如果成员只会写any,那类型安全就会被悄悄破坏。
其次是它在某些极端场景下并不灵活。如果你想给外部第三方提供公开 API,tRPC 不是个好选择,它的查询格式是POST /api/trpc/xxx,不是传统的 RESTful 风格,外部对接成本高。我通常的做法是:内部模块用 tRPC,对外的开放接口单独用 Next.js 的 Route Handler 包一层 REST。
2. 端到端类型安全的底层原理:tRPC 做了什么事
2.1 从 REST 到 tRPC:API 定义方式的演进
REST 架构的本质是把后端能力抽象成资源,用 HTTP 方法和 URL 表达操作意图。但问题在于,URL 只描述了资源和动作,没有描述数据形状。数据长什么样需要靠文档或者看代码才知道。GraphQL 解决了数据形状的描述问题,但引入了 schema 定义语言和前端查询语法的学习成本。
tRPC 走的是另一条路:不定义网络协议层面的 schema,直接把后端函数当作 API。它的核心思想很朴素:既然前后端都是 TypeScript,那我从前端直接调用后端函数,类型能不能通过 TypeScript 的推断链路自动传递?
这里面的关键机制是 TypeScript 泛型推断。tRPC 的router对象在创建时会记录每个 procedure 的输入输出类型。这个类型信息不是运行时的 JSON Schema,而是编译期的 TypeScript 类型。前端引入 trpc 客户端时,通过泛型把整个 router 的类型传给客户端,TypeScript 就自动推导出了所有可用的 query 和 mutation 方法及其参数类型。
2.2 一个最小例子的完整链路
拿我实际项目里的一个片段说明,后端 router 定义如下:
// server/api/routers/task.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const taskRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.task.findMany({ orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1) })) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title, done: false }, }); }), });前端调用只需要这样写:
// app/tasks/page.tsx "use client"; import { api } from "~/utils/api"; export default function TaskList() { const { data } = api.task.getAll.useQuery(); return ( <ul> {data?.map((task) => ( <li key={task.id}>{task.title} - {task.done ? "已完成" : "待办"}</li> ))} </ul> ); }注意到没有?前端代码里api.task.getAll.useQuery()调用返回的data,它的类型是后端findMany的返回类型Task[],并且是带id、title、done、createdAt这几个字段的完整对象。中间没有任何人手动声明过这个类型。
这就是 tRPC 的魔法:类型信息通过 TypeScript 的结构化类型系统自动流动。后端加一个字段,前端立即感知;后端改了字段名,前端编译直接报错。
2.3 为什么还要加 zod 运行时校验
类型安全是编译期的安全,它解决的是“我知道我给你的是什么形状的数据”的问题。但运行时的数据不一定可信,尤其是用户输入。tRPC 并不自动帮你做运行时校验,所以官方推荐配合 zod 使用。
zod 在这里的价值是双重的。第一,它定义了输入数据的运行时校验规则,不满足规则直接拒绝执行。第二,zod 的类型推断能力可以让输入类型完全从 schema 产生,避免重复声明。
import { z } from "zod"; const createTaskSchema = z.object({ title: z.string().min(1).max(100), priority: z.enum(["low", "medium", "high"]).default("low"), }); type CreateTaskInput = z.infer<typeof createTaskSchema>;CreateTaskInput这个类型完全由 schema 推导,后端 router 用它做校验,前端表单类型也可以直接复用同一个zodschema。如果前后端是 monorepo 里的同一个包,你甚至可以把 schema 文件放在共享目录里,两边 import 同一个对象,彻底消除同步成本。
2.4 这套机制的性能代价
类型安全不是没有代价。tRPC 每次请求走的都是 HTTP POST,请求体是 JSON,响应也是 JSON。相比直接在 Node 进程内调用函数,网络序列化和反序列化的开销肯定存在。但实际测试下来,在局域网和公网环境下,一次 tRPC 请求的额外开销在毫秒级,对绝大多数应用没有感知差异。
唯一需要留心的是不要把大文件通过 tRPC 传,它是为结构化数据设计的,文件走单独的对象存储服务更合理。
3. 从零搭一个 t3code 风格的全栈项目:完整实操
3.1 脚手架与环境准备
推荐使用create-t3-app来初始化项目,它会一次性把 Next.js、Tailwind、Prisma、tRPC 以及 NextAuth 集成好,省去手动配置的时间。
pnpm create t3-app@latest t3code-demo cd t3code-demo创建过程中的交互选项我建议这样选:
- TypeScript 或 JavaScript:选 TypeScript,这是整套体系的基础。
- Tailwind CSS:选上,后面写样式极快。
- tRPC:选上,这是核心。
- Prisma:选上。
- NextAuth:如果需要登录功能就选上,不需要就先不选,后面可以手动加。
- 使用 App Router 还是 Pages Router:优先 App Router,Next.js 官方现在的方向,Server Component 和 tRPC Server Helpers 配合起来很舒服。
环境方面,Node.js 版本 18.17 以上,包管理器建议 pnpm,它比 npm 对 monorepo 和依赖提升的支持更好,装依赖速度快,磁盘占用也小。我用 pnpm 之后没再切换过其他包管理器。
3.2 数据模型设计:用 Prisma Schema 定义一整套业务对象
假设我们做一个多用户的笔记应用。先设计最核心的User和Note模型。
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String @unique emailVerified DateTime? image String? accounts Account[] notes Note[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Note { id String @id @default(cuid()) title String content String published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }定义好之后执行迁移:
npx prisma migrate dev --name init这条命令会做两件事。第一,根据 schema 差异生成 SQL 迁移文件并执行,数据库表结构变成模型定义的样子。第二,重新生成 Prisma Client,让ctx.db.note具备完整的类型推导。字段名拼错,在编辑器里就会出现红色波浪线。
3.3 tRPC router 的完整实现
接着实现笔记的增删改查。先建一个笔记相关的 router:
// server/api/routers/note.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const noteRouter = createTRPCRouter({ getAll: protectedProcedure.query(async ({ ctx }) => { return ctx.db.note.findMany({ where: { authorId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.note.findFirst({ where: { id: input.id, authorId: ctx.session.user.id }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), }), ) .mutation(async ({ ctx, input }) => { return ctx.db.note.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), update: protectedProcedure .input( z.object({ id: z.string(), title: z.string().min(1).optional(), content: z.string().min(1).optional(), published: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input; return ctx.db.note.updateMany({ where: { id, authorId: ctx.session.user.id }, data, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.note.deleteMany({ where: { id: input.id, authorId: ctx.session.user.id }, }); }), });注意这里用的是protectedProcedure,它要求请求必须携带有效的登录会话,session 里的user.id直接被用作数据归属判断。这样你不需要在业务代码里反复写“判断当前用户有没有权限操作这条数据”的逻辑,因为你查询条件里已经带上了authorId,非本人的数据在数据库层面就查不到。
把 router 注册到根 router:
// server/api/root.ts import { createTRPCRouter } from "~/server/api/trpc"; import { noteRouter } from "~/server/api/routers/note"; import { userRouter } from "~/server/api/routers/user"; export const appRouter = createTRPCRouter({ note: noteRouter, user: userRouter, }); export type AppRouter = typeof appRouter;3.4 前端接入:Server Components 和 Client Components 的分工
Next.js App Router 下,我用 tRPC Server Helpers 在 Server Component 里直接预取数据,再把数据传给 Client Component,避免首屏空白。
// app/notes/page.tsx import { api, type RouterOutputs } from "~/trpc/server"; import { NoteList } from "./NoteList"; export default async function NotesPage() { const notes = await api.note.getAll.query(); return <NoteList initialNotes={notes} />; }NoteList是客户端组件,接收initialNotes初始化数据:
// app/notes/NoteList.tsx "use client"; import { api } from "~/utils/api"; import { useState } from "react"; export function NoteList({ initialNotes }: { initialNotes: RouterOutputs["note"]["getAll"] }) { const [notes] = useState(initialNotes); const utils = api.useUtils(); const createNote = api.note.create.useMutation({ onSuccess: () => utils.note.getAll.invalidate(), }); const deleteNote = api.note.delete.useMutation({ onSuccess: () => utils.note.getAll.invalidate(), }); return ( <div> <button onClick={() => createNote.mutate({ title: "新笔记", content: "内容" })}> 新建 </button> {notes.map((note) => ( <div key={note.id}> <h2>{note.title}</h2> <p>{note.content}</p> <button onClick={() => deleteNote.mutate({ id: note.id })}>删除</button> </div> ))} </div> ); }utils.note.getAll.invalidate()是 tRPC 提供的一个极其实用的方法,它在 mutation 成功后让对应的 query 标记为失效,react-query 会自动重新拉取最新数据,不需要手动刷新页面。这就是为什么我在这套体系里几乎不需要状态管理库:服务端是唯一数据源,客户端只管展示和触发变更。
UI 样式用 Tailwind 写,几个类名就能完成一个还算干净的界面,开发体验非常流畅。
3.5 认证与授权:NextAuth 配置要点
登录体系默认走 NextAuth。核心配置在server/auth.ts里,需要注意的坑是sessionStrategy选择。默认是jwt,适合无状态场景。如果应用需要频繁查询数据库里的用户资料,建议改用database策略,session 里只存 userId,每次请求通过ctx.session.user.id查库。
export const authOptions: NextAuthOptions = { providers: [ // 配置你的 provider,比如 GitHub、Google ], session: { strategy: "database", }, callbacks: { async session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }, }, };tRPC 的 context 创建逻辑在这里和 NextAuth 接驳:
const createInnerTRPCContext = (opts: { session: Session | null }) => { return { session: opts.session, db: prisma, }; };在createTRPCContext里通过getServerSession拿 session:
export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return createInnerTRPCContext({ session }); };有了这个 context,protectedProcedure就能在中间件里判断 session 是否存在,不存在直接抛出UNAUTHORIZED错误,客户端会收到明确的状态。
4. 生产环境最容易翻车的三个坑及完整排查链路
4.1 坑一:类型推导突然失效,IDE 全屏报错
我遇到过一次,新拉下来的分支,pnpm install之后打开编辑器,所有.query()和.mutation()调用都报类型错误,错误信息类似:
Property 'note' does not exist on type 'CreateTRPCProxyClient<...>'这时候不要慌,按顺序排查。
第一步,确认 tRPC 客户端创建的泛型参数是否正确。查看utils/api.ts:
import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "~/server/api/root"; export const api = createTRPCReact<AppRouter>();第二步,确认根 router 的导出有没有变化。如果root.ts里新增了 router 但忘了注册,客户端类型自然不会包含它。我在appRouter里漏注册过userRouter,整整排查了半小时,就是因为类型没错但运行时报错。
第三步,最经典的坑:后端 router 文件里引入了ctx.db.note.findMany(),但它没有在路由文件里加authorId条件,导致查询范围过大,虽然类型没错,但数据不对。类型安全只保证形状,不保证业务正确性,这一点必须时刻记住。
4.2 坑二:Prisma 迁移导致生产库结构失配
本地跑npx prisma migrate dev很顺,部署到生产环境后却发现query报错,提示某列不存在。原因是生产库没有执行迁移文件。
标准做法是在部署流程里加一个数据库迁移步骤:
npx prisma migrate deploy注意是deploy不是dev。deploy只执行迁移文件里还没有被应用过的部分,不会尝试重新生成迁移文件。生产环境不能执行dev命令,它可能会重置数据。
另外,如果 Prisma 升级大版本,migrate deploy有可能因为迁移文件里的 SQL 和当前数据库版本不兼容而出问题。我的经验是升级 Prisma 后先在本地用同版本数据库跑一遍migrate deploy再上生产。
4.3 坑三:认证会话导致 SSR 请求阻塞
App Router 的 Server Component 里直接调用api.note.getAll.query()时,如果对应 procedure 是protectedProcedure,那么getServerSession会处理 cookie 并拿到 session,没问题。但如果你的 Server Component 是静态渲染的,构建时没有请求上下文,调用会拿到 null session,然后被protectedProcedure拒绝,页面直接 500。
解决方法是给页面加export const dynamic = "force-dynamic",强制页面动态渲染,确保每次请求都有完整的上下文。
这段代码非常关键:
// app/notes/page.tsx export const dynamic = "force-dynamic";不加这一个标记,本地开发没问题,部署到 Vercel 或 Node 服务器后就会出现偶发的页面白屏或 500。我在一个客户项目里就因为这个线上故障了三个小时,排查链路是从错误日志看到UNAUTHORIZED,再看到 session 为 null,最后定位到渲染模式。
4.4 部署时的连接池配置
Prisma 默认连接数有限。如果你部署在 Serverless 环境,每个函数实例都会创建数据库连接,高并发下很容易把连接池打满。解决方案是设置连接池上限:
datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") }如果你的数据库服务商提供直接连接地址,建议通过directUrl指定。同时把应用逻辑里的连接池大小控制在合理范围,比如通过 PrismaClient 初始化时指定:
const prisma = new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], });Vercel 部署时还容易遇到构建内存不足,Vercel 免费套餐的构建内存比较紧张,建议在next.config.js里关闭 SWC 的一些不必要的依赖分析,或者用 pnpm 减少 node_modules 体积。
5. t3code 在团队协作中的落地建议
5.1 目录结构与模块边界
这是我用了一年之后觉得最舒服的目录结构:
src/ app/ # Next.js App Router 页面 components/ # 通用 UI 组件 server/ api/ routers/ # 按业务域拆分的 tRPC router root.ts # 汇总所有 router trpc.ts # tRPC context 和 procedure 定义 auth.ts # NextAuth 配置 trpc/ server.ts # Server Helpers 实例 react.ts # 客户端 tRPC React 绑定 utils/ api.ts # 客户端 api 代理对象 styles/每个业务域在routers下建一个文件,比如note.ts、user.ts、team.ts。每个文件只负责自己域内的逻辑,跨域调用通过 import 公共工具函数而不是直接访问另一个 router 的内部。这样模块边界清晰,多人协作时不容易冲突。
5.2 共享 sched 的最佳实践
zod schema 的复用是这套体系里最容易忽略但价值最大的点。我建议把 zod schema 集中到一个目录,比如src/server/schemas/,每个业务域一个文件。
// src/server/schemas/note.ts import { z } from "zod"; export const createNoteSchema = z.object({ title: z.string().min(1, "标题不能为空"), content: z.string().min(1, "内容不能为空"), }); export const updateNoteSchema = createNoteSchema.partial().extend({ id: z.string(), });后端 router 直接引用:
import { createNoteSchema } from "~/server/schemas/note"; create: protectedProcedure .input(createNoteSchema) .mutation(async ({ ctx, input }) => { ... });如果将来要做服务端表单校验和客户端表单校验的一致性,这套 schema 也可以直接给 react-hook-form 的 resolver 用,前端校验规则和后端完全一致,不会出现“前端提示邮箱格式错误,后端却收下了非法邮箱”这种不一致。
5.3 测试策略:类型安全不等于逻辑正确
类型系统能拦住形状错误,但拦不住业务逻辑错误。我现在的测试策略是三层:
第一层,单元测试。重点测 zod schema 的边界条件:空字符串、超长文本、非法枚举值。这些测试成本低、速度快,适合在 CI 里频繁跑。
第二层,集成测试。用真实的测试数据库,调用 tRPC router 的 createCaller 直接执行 procedure,验证数据是否能正确写入和读取。tRPC 的createCaller很适合这个场景,不需要起 HTTP 服务,直接拿到带有 context 的调用器。
import { createCaller } from "~/server/api/root"; import { createInnerTRPCContext } from "~/server/api/trpc"; const ctx = createInnerTRPCContext({ session: { user: { id: "test-user", name: "Test" }, expires: "2024-01-01" }, }); const caller = createCaller(ctx); test("创建笔记应该成功", async () => { const result = await caller.note.create({ title: "测试笔记", content: "内容", }); expect(result.title).toBe("测试笔记"); });第三层,端到端测试。用 Playwright 模拟真实用户操作,验证页面、API、数据库的完整链路。这一层跑得慢,但最接近真实用户体验,适合在发版前跑一遍。
5.4 CI 流水线上的强制检查
我会在 CI 里按顺序执行四个检查:
pnpm typecheck pnpm lint pnpm test pnpm buildtypecheck放在第一位,因为它是最快的反馈回路。类型错误尽早暴露,比运行时才报错节省大量时间。build放在最后,因为 Next.js 的构建过程会做静态分析,很多部署期问题在这一步暴露。
另外如果项目用了 Prisma,记得在 CI 里加一步:
npx prisma generate很多 CI 报错是因为 Prisma Client 没有生成。装了新依赖之后,prisma generate的结果不会自动包含在仓库里,必须在构建前重新生成。
最后再分享一个小技巧
如果你团队里有人对这套体系还是不适应,我建议从共享 zod schema 开始引入,而不是一步到位强推 tRPC。让后端同事先把 schema 定义好,前端同事直接用z.infer推导类型,这一步落地后,类型不一致的问题就已经解决了一大半。等大家尝到甜头,再逐步把 API 层迁移到 tRPC。技术升级最怕一下全铺开,小步快跑反而走得远。
t3code 这套组合到我写这篇文章为止,已经稳定支撑了三个线上项目和两个内部管理系统。我不敢说它适合所有场景,但如果你和我一样,想把精力放在业务本身而不是前前后后的类型对账和文档维护,它绝对值得一试。