t3code这个项目是我这一年多来投入时间最多、也最满意的一个作品。如果你是个前端或者全栈工程师大概率已经听说过目前圈子里讨论度很高的 T3 Stack 这一套组合拳——Next.js 打底、TypeScript 贯穿、tRPC 做接口层、Tailwind 写样式、Prisma 管数据库、NextAuth 处理鉴权。t3code 就是基于这套体系做的一个开箱即用的全栈工程化脚手架/代码生成器它解决的核心痛点是每次起新项目都要重复搭环境、配类型、写接口胶水代码、琢磨目录结构这一大堆脏活累活能不能一次搞定、还能保持类型全链路的安全。这篇文章就是我基于 t3code 的真实开发经历沉淀出来的完整复盘包含了从设计取舍到核心实现再到上线部署和踩坑排障的全部细节适合正在研究全栈类型安全方案、想给团队搭一套标准化脚手架、或者单纯想理解 tRPC 和 Next.js 到底怎么配合使用的人。我会把整个思路和关键代码都拆开讲读完你就能照着落地。1. 项目背景与整体设计思路1.1 为什么非要搞类型安全的全栈先说说这个项目的起点。我自己带过一个十来人的研发小组组里前后端协作的常见模式是后端用 Swagger 出文档前端照着文档写类型定义联调的时候对着字段名一个个改前端说“这个字段后端没返回”后端说“我文档里明明写了”一天能来回拉扯好几轮。这种低效的根源在于类型不被当作一种契约来执行某个人改了个字段名其他人根本感知不到要等到运行时才炸。t3code 想做的事很简单就是把“类型”变成整个应用的钢筋骨架。前端组件里调一个 API能拿到什么数据、参数要传成什么样这些在编译期就被固定下来。你改了后端返回结构前端立刻在 IDE 里报红根本不需要等联调。这个体验一旦尝到甜头你就再也回不去写“any 大法”的日子了。我选择 t3code 这个命名其实含义就是TypeScript 3 件套 code这里的 3 件套并不是 T3 Stack 最初的三个字母游戏而是我实打实依赖的核心三件TypeScript语言、tRPC通讯层、Zod运行时校验。这三个东西放在一起才真正实现了从数据库到 UI 的端到端类型安全。1.2 技术选型背后的取舍逻辑很多人会问Next.js 搭个 API 路由不是照样能写全栈吗REST 接口哪里不好为什么要引入 tRPC我可以明确地说传统 REST 手写客户端的模式最大的问题不是性能而是类型链路的断裂。你写一个GET /api/user/123前端手写一个fetchUser函数类型要么自己再声明一遍要么从某个 d.ts 文件里面翻。一旦后端某个字段被删了你的类型文件不会自动更新于是就会出现“类型是绿的运行是红的”这种坑。tRPC 的思路则完全不一样。它不是在 HTTP 上硬套一个规范而是把一个 TypeScript 函数直接暴露给客户端调用。过程式的调用方式配合output类型自动推导让 IDE 的智能提示直接覆盖接口名、入参、返回值一点都不会绕。这里的关键是“过程调用”的思维转变——你不是在请求 URL你是在调用一个类型安全的远程函数只不过这个函数实际上通过网络执行。那么为什么还需要 Zod因为 TypeScript 的类型系统是在编译期生效的运行时根本不存在。如果客户端传过来的数据违反约定服务端得有一种运行时手段去拦截。Zod schema 的奇妙之处在于它既是运行时校验工具又能在编译期推导出类型你只需要写一份 schematRPC 的 input 和 output 都从它推导。数据进出门的那一刻类型就已经被检查过了。至于为什么选 Next.js 作为宿主框架理由其实很现实团队里 React 的存量最大Next.js 的 App Router 对服务端组件的支持又让 tRPC 可以在服务端直接调用逻辑省掉一层 HTTP 往返。一旦把 tRPC 的 router 接入到 Next.js 的 server component 里页面的服务端渲染可以直接和数据库打交道返回给客户端的已经是准备好序列化的数据性能和类型安全兼得。1.3 核心架构分层t3code 的架构其实就三层数据层Prisma 负责数据库访问模型定义写在 schema.prisma 里迁移和类型自动生成都由它处理。业务层tRPC 的 router 和 procedure 是唯一入口所有的业务逻辑都收敛到这儿不散落在组件里。表现层Next.js App Router页面和组件只负责消费 tRPC 暴露出来的类型化函数不直接碰 Prisma。这样的分层逻辑很简单表现层永远只知道“我能调用哪些函数、得到什么类型”它不需要关心数据从哪张表里来。业务层是唯一的中间人所有规则校验、鉴权、权限判断都放在这里统一处理。数据层对业务层透明只提供数据读写原语。我见过太多项目把业务逻辑写在useEffect里、写在 reducer 里、甚至写在组件渲染函数里最后代码的可维护性完全取决于写代码那个人的心情。t3code 把业务逻辑必须落在 tRPC router 内作为一条硬性约束从架构上规避这个问题。2. t3code 核心模块拆解2.1 路由设计tRPC Router 不是 REST 的替代品有人误以为 tRPC router 就是 REST API 的换皮其实它在设计哲学上差别很大。REST 的核心是“资源”所以你会看到一堆GET /user、POST /user、DELETE /user/:id。tRPC 的核心是“过程”它更关注“你调用的时候想干一件什么事”。比如createTodo、toggleTodo、getAllTodos语义直接就是动词。t3code 里我按领域模型来组织 router而不是按 HTTP 方法。比如用户模块、待办模块、文件上传模块各占一个文件用createCallerFactory把主 router 组合好。这样设计的直接好处是当你需要看“某个业务域的所有操作”时直接看一个文件就够了而不是在一堆 controller 里翻来翻去。router 内部还分了两层权限控制逻辑。第一层是 middleware比如isAuthed检查 session未登录的请求全部拒绝。第二层是 procedure 本身的 input 校验。middleware 管的是“你是谁”Zod 管的是“你传的参数对不对”两个维度互不干扰。每次新增业务操作你只需要写一个 procedure然后明确告诉它“要不要登录”、“输入是什么”其他什么路由前缀、HTTP method、错误码都不用管。2.2 数据校验一份 Schema 两头用t3code 里做得最漂亮的一部分是数据校验体系的统一。以前项目里常见的情况是前端用 Yup 校验表单后端用 Joi 校验入参两边各写一套规则稍微不一样就出线上事故。t3code 直接用 Zod 把校验逻辑定义在共享目录里一份 schema 前后端共用。比如创建一个待办事项的 schema 大概是这个样子import { z } from zod; export const createTodoSchema z.object({ title: z.string().min(1, 标题不能为空).max(100, 标题太长了), dueDate: z.date().optional(), priority: z.enum([low, medium, high]).default(low), }); // 推导出的类型直接可复用 export type CreateTodoInput z.infertypeof createTodoSchema;这对开发体验的提升是巨大的。你在表单组件里绑定的字段类型和路由接收的参数类型、数据库写入前的校验规则全部来自同一个来源。字段要改规则改一处全链路生效。这种“单一事实来源”的思维方式我认为比任何花哨的工具都重要——t3code 只是把这种思维落到了工程上。2.3 数据查询去掉了 90% 的重复代码传统全栈项目里每个数据查询几乎都要走一遍“定义 API 端点 - 写 controller - 写 service - 写前端调用函数”的流程。这里面的代码大量重复却没创造多少价值。t3code 里的查询流程简单到让人觉得不真实在 procedure 里直接查 Prisma然后自动暴露给客户端。写一个查询的完整代码大概这样export const todoRouter router({ getAll: protectedProcedure .input(z.object({ completed: z.boolean().optional() })) .query(async ({ ctx, input }) { return ctx.db.todo.findMany({ where: { userId: ctx.session.user.id, ...(input.completed undefined ? {} : { completed: input.completed }), }, orderBy: { createdAt: desc }, }); }), });注意这里连ctx.session.user.id都能直接拿到因为 middleware 已经做完了鉴权并把 session 塞进了 context。服务端只关心业务条件不用关心“当前用户是谁”这种横切关注点因为已经被统一处理了。前端调用时query函数的泛型会自动推导返回类型todo.type是什么字段、有没有可选项IDE 全都能提示出来。数据层的缓存用了 TanStack Query 来管它会把你已经查询过的数据放进缓存同时支持窗口重聚焦自动刷新。我一开始觉得服务端组件时代可能用不上它了后来发现它依然是 tRPC 在客户端的最佳搭档——服务端只能保证首屏数据正确但用户的交互操作、分页加载、并发更新后的缓存同步还是需要一个客户端状态管理方案来兜底。2.4 鉴权与上下文中间件一次搞定鉴权这块很多人喜欢在每个接口里写 if 判断 session 是否存在这种做法污染业务代码。t3code 里我把鉴权收敛到了 middleware 层受保护的 procedure 统一走带鉴权的子路由。const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: ctx.session } }); });关键在于ctx的传递逻辑。middleware 里校验完 session 后把新的 session 塞进next({ ctx })下一个 procedure 拿到的 ctx 类型就自动包含了已验证的 session。这意味着后续业务代码里ctx.session绝对不是 nullTypeScript 能够确定这一点不需要再写一堆空值检查。这样设计的好处是“未登录”的状态被保持在边界上一旦通过校验后续代码默认你是一个合法用户。团队里新手写业务逻辑时不需要理解鉴权流程的来龙去脉只要用protectedProcedure就自动获得用户状态。3. 实操过程从零开始搭建一个 t3code 全栈应用3.1 初始化项目我建议用 t3code 自带的 CLI 初始化模板开始省去一切手工配置的麻烦。初始化命令一行搞定npx create-t3codelatest my-app命令行交互里面有几个对我非常关键的选项使用 Next.js App Router 模式这是默认推荐的服务端组件模式下 tRPC 可以被直接调用不走 HTTP 层。开启 tRPC Zod Prisma这三个是 t3code 的核心组合缺一个类型链路的完整性就大打折扣。选择数据库本地开发用 SQLite 体验最顺畅部署再切 PostgreSQL如果团队有现成的数据库直接选 PostgreSQL。初始化完成后你会得到一个整洁的目录结构大致是这样的/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── todo.ts │ │ │ │ └── user.ts │ │ │ ├── trpc.ts │ │ │ └── root.ts │ │ └── db.ts │ ├── app/ │ │ ├── page.tsx │ │ └── layout.tsx │ ├── trpc/ │ │ ├── server.ts │ │ └── client.ts │ └── styles/初次看到这个结构你可能觉得生疏但它隐含的边界非常清晰server/api下面全部是后端逻辑app下面全部是页面与组件trpc是连接两者的唯一通道。如果你以后要做另一个项目复制这个结构就是复制了一套已经被验证过的最佳实践。3.2 定义第一个数据模型我以“待办事项”为例演示从零到一跑通全流程。先在prisma/schema.prisma里定义模型model Todo { id String id default(cuid()) title String completed Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String user User relation(fields: [userId], references: [id]) } model User { id String id default(cuid()) email String unique name String? todos Todo[] }定义完成后执行npx prisma migrate dev --name init这一步会生成迁移文件同时触发 Prisma Client 的类型重新生成。此时PrismaClient的 API 就自动带上这些模型的类型了。我见过不少人在这里栽跟头改了 schema 之后忘了跑prisma generate然后发现 TypeScript 编译报错傻傻地盯着屏幕半天不知道哪出了问题。记住一条铁律每次改 schema必须重新生成 Client。3.3 实现 tRPC 路由并接入页面模型建好之后我们来实现待办事项增删改查的全部 procedure。一个完整的 CRUD 功能在 t3code 里只需要一个文件核心代码大概是这样的import { createTRPCRouter, protectedProcedure } from /server/api/trpc; import { z } from zod; import { createTodoSchema, updateTodoSchema } from /schemas/todo; export const todoRouter createTRPCRouter({ getAll: protectedProcedure .input(z.object({ filter: z.enum([all, active, completed]).optional() })) .query(async ({ ctx }) { return ctx.db.todo.findMany({ where: { userId: ctx.session.user.id, ...(filter active ? { completed: false } : filter completed ? { completed: true } : {}), }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(createTodoSchema) .mutation(async ({ ctx, input }) { return ctx.db.todo.create({ data: { title: input.title, dueDate: input.dueDate, priority: input.priority, userId: ctx.session.user.id, }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const existing await ctx.db.todo.findFirst({ where: { id: input.id, userId: ctx.session.user.id }, }); if (!existing) { throw new TRPCError({ code: NOT_FOUND, message: 待办事项不存在 }); } return ctx.db.todo.update({ where: { id: input.id }, data: { completed: !existing.completed }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { await ctx.db.todo.deleteMany({ where: { id: input.id, userId: ctx.session.user.id }, }); return { success: true }; }), });前端页面的调用方式是 t3code 里最让我觉得舒服的地方。页面组件只需要引入封装好的api.todo.getAll方法不需要管它底层是怎么做请求的类型完全自动推导use client; import { api } from /trpc/client; export function TodoList() { const { data: todos, isLoading } api.todo.getAll.useQuery({ filter: all }); if (isLoading) return div加载中.../div; return ( ul {todos?.map((todo) ( li key{todo.id} span{todo.title}/span button onClick{() api.todo.toggle.useMutation().mutate({ id: todo.id })} 切换完成状态 /button /li ))} /ul ); }注意这里所有的调用都通过api.todo这个命名空间访问router 层级会在 IDE 提示里精确展开绝对不会出现手写字符串拼 URL 导致 404 的情况。这就是 tRPC 最核心的杀手锏安全不止体现在编译器里更体现在开发者每次敲代码时的即时反馈中。3.4 部署配置要点t3code 构建出来的应用是标准 Next.js 应用部署到 Vercel 是零配置的主流云平台也都支持容器化部署。但有几个点实际部署时容易踩坑我单独列一下数据库迁移生产环境数据库千万不要手动改表结构务必用prisma migrate deploy走迁移流程。环境变量数据库连接串、鉴权密钥必须使用平台的环境变量管理功能不能放进代码仓库。构建缓存Next.js 的构建缓存需要在部署平台上开启生成预渲染或 ISR 时可以显著提升构建速度不过要注意 Prisma Client 的生成结果不能混用不同平台的二进制。部署后的启动命令建议是npx prisma migrate deploy npm run build npm run start这三步的顺序不能乱——先保证数据库结构最新再构建产物最后启动服务。启动起来之后再看日志一般来说不会有什么问题因为类型检查在构建阶段就会把绝大部分错误拦在了门外。4. 性能优化与会话状态管理4.1 服务端组件直接用 tRPC 省掉一层网络大多数项目把 tRPC 当“接口层”用客户端发起请求到服务端。其实在 Next.js 的 App Router 模式下有一个鲜为人知的技巧服务端组件里可以不用 HTTP而是直接调用 tRPC 的 caller。这相当于一次本地函数调用速度和直接访问数据库差不多。t3code 已经在src/trpc/server.ts里封装好了服务端调用入口import { createCallerFactory } from /server/api/root; const createCaller createCallerFactory(); export async function getServerTRPC() { const session await getServerSession(); return createCaller({ session }); }然后在服务端组件比如首页的异步组件里你就可以直接拿来用了import { getServerTRPC } from /trpc/server; export default async function HomePage() { const trpc await getServerTRPC(); const todos await trpc.todo.getAll({ filter: all }); return pre{JSON.stringify(todos, null, 2)}/pre; }这个模式的巨大价值在于首屏渲染的数据获取不再经过 HTTP 往返不需要客户端再发请求、等待响应、设置 loading 态。服务端渲染直接拿到数据库里的数据返回给浏览器的已经是完整的 HTML。这个过程对用户体验的提升是实质性的——尤其是移动端网络不稳的时候快得不只是一点点。4.2 客户端状态的缓存策略虽然服务端组件解决了首屏问题但用户一旦在页面上执行交互操作比如点击“完成待办”按钮还是需要客户端发一次请求。这里就牵涉到客户端状态管理的策略问题。t3code 默认集成了 TanStack Query并且配置好了合理的缓存策略。它的工作方式比想象中智能你在 A 组件里调用api.todo.getAll.useQuery()查询了一版数据到了 B 组件再次调用同一个 query key 时不会重新发起网络请求而是直接复用缓存结合 staleTime 设置来控制刷新时机。我更推荐的做法是在 mutation 成功后调用utils.todo.getAll.invalidate()让 tRPC 自动重新拉取对应 query key 的数据。这样做的好处是心智负担极低任何增删改操作结束后界面自动回到和数据库一致的状态不需要手写状态同步逻辑。唯一的注意点是invalidate 是“失效并重新拉取”在列表很大或请求很重的场景下你得考虑是否有必要局部更新而不是无脑全量重查。4.3 并发写入的数据一致性实际业务里用户可能同时操作多个待办事项或者多个设备上同时改了同一份数据。tRPC 本身不提供并发控制你得在业务逻辑层面自己决定策略。我的经验是乐观更新 冲突时验证是最贴合用户体验的方案。乐观更新可以让界面先产生反馈比如点击按钮后勾选框立刻变绿等后端返回真正结果后再校准。这个过程用 TanStack Query 的onMutate和onError来做比较顺手。如果真的遇到版本冲突比如后端返回的数据和你更新前的不一致可以通过版本号字段做校验或者直接把服务端数据拉回来覆盖本地。这个思路听起来和数据库乐观锁差不多但在全栈类型安全的环境下实现起来非常干净因为前后端的数据结构永远是一致的。5. 常见问题与排查技巧实录5.1 类型推导失效从源头检查我见过最频繁的问题就是改了某个 schema 或 router 之后前端突然大量报类型错误甚至有些类型提示直接变成any或者不见。这种情况 90% 的原因是依赖关系没有刷新。这里有一个排查顺序可以供参考检查 tRPC router 根文件确认新加的 router 已经注册到root.ts里。检查 Prisma Client改了 schema 之后有没有重新prisma generate。检查 IDE 的 TypeScript 服务有时自动类型提示会滞后重启一下 TS Server 就好按CtrlShiftP输入TypeScript: Restart TS Server。对比一下自查的维度和方法下面这个表格在实际项目里可以直接当速查表用症状可能原因排查方向前端调不到新 router没注册到 root检查 root.ts 的合并逻辑新字段在输出里是 anyPrisma Client 未重新生成跑 npx prisma generate调用 mutation 时 input 类型不提示Zod schema 没导出类型确认用 z.infer 推导了类型页面组件里 ctx 类型是 unknownmiddleware 没有回传 ctx检查 next({ ctx }) 写法生产环境类型正常但运行时 500缺环境变量检查服务端日志和环境变量配置5.2 React 19 与 tRPC 的兼容性t3code 我最初搭建时用的是 React 18后来升级到了 React 19。升级过程中遇到过一个棘手的兼容性问题TanStack Query 的 v5 版本和 tRPC 的 React Query 适配器之间在useQuery的泛型推导上偶尔出现不一致导致服务端组件和客户端组件的类型无法对齐。解决办法是锁定版本组合不要全都升到最新。我实测稳定的组合是tanstack/react-query5.x trpc/react-query10.x react19。如果你在升级时发现类型报错优先检查这三个依赖的版本匹配不要盲目升级 tRPC 到 v11 beta它的一些 API 变更会破坏现有封装。还有一个容易忽略的点React 19 的严格模式下开发环境里的 effect 会触发两次TanStack Query 的请求也会重复执行。虽然不影响类型安全但会让日志看起来乱糟糟的。遇到这种情况先把 StrictMode 关掉排查而不是直接怀疑 tRPC 坏了。5.3 服务端组件与 use client 边界问题Next.js App Router 模式下一个最常见的报错是“Server Component cannot import client component”。t3code 里给了一个很清晰的约定凡是碰了 state、使用了事件绑定、调用了浏览器 API 的组件一律标上use client其余默认是服务端组件。但这个约定很容易被破坏比如某个工具函数文件里引用了 window 对象而这个文件被一个没有标记的组件间接 import 了就会直接抛错。排查方法其实不复杂看报错信息里提示的组件路径从那条路径往回追找到底是哪个文件引入了浏览器 API。大多数情况下问题出在一个“其实需要客户端交互但忘了加use client”的组件上。只要你坚持把数据获取放服务端、交互反馈放客户端这个思维框架边界问题基本能避免。5.4 部署环境的 Node 版本注意Prisma 对 Node 的版本非常敏感。我建议部署环境使用 Node.js 20 LTS 或更高版本。如果遇到 Prisma 引擎相关的报错可以先检查平台上的 Node 版本是否与本地一致。这个坑在云函数平台Serverless 环境上最容易出现因为你本地跑得好好的一上云就报错十有八九是运行时环境差异。解决方式也不难在项目根目录加.nvmrc内容写20部署平台会自动读取。本地构建时也尽量用相同版本防止“本地能过、线上挂了”的尴尬局面。6. 扩展方向与后续迭代思考t3code 现在已经不是一个小打小闹的脚手架了它逐步长成了一整套“团队级全栈开发的基础设施”。后续我想重点往这几个方向继续打磨首先是服务器端的实时能力。tRPC 目前主要是 query/mutation 这种请求-响应模式但很多场景在线协作、通知推送、爬虫任务进度反馈需要更强的实时能力。后续计划把 WebSocket 订阅能力也纳入 t3code让 procedure 可以直接发布订阅主题前后端依然保持类型安全。然后是数据同步冲突处理。目前的多端同时编辑场景还比较朴素我想增加一套基于字段级版本号的冲突检测机制。编辑器类应用、团队协同工具特别需要这个否则用户 A 改的字段被用户 B 整体覆盖体验就很糟糕。再者是插件系统。团队里不同项目有不同的模板需求比如管理后台和 C 端应用目录风格和鉴权模式差别很大。我打算把 t3code 的开箱模板做成可插拔的让团队自己写模板扩展包上传到内部 registry。这样 t3code 就不再只是一个脚手架而是变成团队内部的“代码生成平台”。这些都是围绕同一个核心痛点展开的——让开发者在写业务的时候少操一些不该操的心把注意力全部放在真正的业务逻辑上。类型安全不是目的减少意料之外的运行时错误、让团队协作更顺畅、让代码演进的每一步都有据可查这些才是 t3code 真正想要创造的价值。如果你正在搭全栈项目我个人建议你先拿 t3code 起一个小项目试试水把待办事项这种最小的闭环跑通再逐步加入真实业务。体验一下“改一处类型全链路都跟着变绿”的感觉我相信你会回来感谢这套方案。