
如果你最近在搜 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那类型安全就会被悄悄破坏。其次是它在某些极端场景下并不灵活。如果你想给外部第三方提供公开 APItRPC 不是个好选择它的查询格式是POST /api/trpc/xxx不是传统的 RESTful 风格外部对接成本高。我通常的做法是内部模块用 tRPC对外的开放接口单独用 Next.js 的 Route Handler 包一层 REST。2. 端到端类型安全的底层原理tRPC 做了什么事2.1 从 REST 到 tRPCAPI 定义方式的演进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.infertypeof 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-applatest t3code-demo cd t3code-demo创建过程中的交互选项我建议这样选TypeScript 或 JavaScript选 TypeScript这是整套体系的基础。Tailwind CSS选上后面写样式极快。tRPC选上这是核心。Prisma选上。NextAuth如果需要登录功能就选上不需要就先不选后面可以手动加。使用 App Router 还是 Pages Router优先 App RouterNext.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拿 sessionexport const createTRPCContext async (opts: { headers: Headers }) { const session await getServerSession(authOptions); return createInnerTRPCContext({ session }); };有了这个 contextprotectedProcedure就能在中间件里判断 session 是否存在不存在直接抛出UNAUTHORIZED错误客户端会收到明确的状态。4. 生产环境最容易翻车的三个坑及完整排查链路4.1 坑一类型推导突然失效IDE 全屏报错我遇到过一次新拉下来的分支pnpm install之后打开编辑器所有.query()和.mutation()调用都报类型错误错误信息类似Property note does not exist on type CreateTRPCProxyClient...这时候不要慌按顺序排查。第一步确认 tRPC 客户端创建的泛型参数是否正确。查看utils/api.tsimport { createTRPCReact } from trpc/react-query; import type { AppRouter } from ~/server/api/root; export const api createTRPCReactAppRouter();第二步确认根 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 这套组合到我写这篇文章为止已经稳定支撑了三个线上项目和两个内部管理系统。我不敢说它适合所有场景但如果你和我一样想把精力放在业务本身而不是前前后后的类型对账和文档维护它绝对值得一试。