搞全栈开发的朋友应该都有过这种感受前后端各自为政接口文档靠口头传递前端调一个字段等后端改半天数据库里一个表结构变了整个页面跟着报错。我最近把一个内部工具项目彻底推倒重来全部基于一套以 t3code 命名的类型安全全栈方案重写做完之后最大的感受就是原来前后端之间那种不断扯皮的沟通成本是可以被技术架构直接消灭掉的。这篇文章就把这个方案的选型逻辑、核心实现、踩坑记录和适用范围完整拆开给想从传统分离式开发转向全栈类型安全路线的同学一份参考。t3code 并不是什么新框架它本质上是把 Next.js、TypeScript、tRPC、Prisma、Zod 这套技术栈组合起来形成一整套从数据库到 UI 全部类型安全、端到端可推导的开发范式。核心就一句话让类型在数据库、接口、页面之间畅通无阻地流转。适合正在做中小型全栈项目、尤其是前后端都由自己或小团队维护的开发者。下面我从设计思路开始一步步说清楚这套东西到底怎么落地。1. 项目整体设计与思路拆解1.1 类型安全为什么值得折腾传统的前后端分离开发里前端写fetch请求手写interface定义响应数据结构后端单独维护一套接口返回代码数据库又有一套 ORM 模型。三套类型并没有关联后端改了字段名前端只有等运行时才发现数据库加了非空约束接口层忘记处理线上直接 500。这些问题是架构层面造成的靠所谓更仔细的 code review 根本防不住。t3code 的解决思路是把整条链路打通。Prisma 定义数据库表结构之后通过代码生成直接产出对应的 TypeScript 类型tRPC 接口层的输入输出类型在服务端定义一次前端调用时自动获得完整推导Zod 的 schema 同时用于运行时校验和静态类型推断一个 schema 处处复用。最终的效果就是数据库怎么定义接口就怎么返回页面就怎么消费中间没有一处类型是靠人肉维护的。这个设计对中型项目的开发效率提升很明显尤其是重构的时候。改一个 Prisma 字段编译期就能告诉你前端哪里用到这个字段而不是上线后用户帮你测出来。1.2 为什么这套方案适合小团队全栈如果你是一个五六人以内的小团队或者干脆就是个人独立开发产品t3code 这套思路特别合适。原因很实际小团队没法铺开几条产品线一个人的精力既要写业务逻辑又要写接口又得管前端如果接口文档、类型定义、数据库设计三座大山同时压过来必然顾此失彼。这种类型安全的全栈模式压缩的是“层与层之间的适配成本”。不用再单独用一整套文档工具管理接口说明因为类型定义就是活的文档前端也不用 mock 数据反复调试直接调真实接口并享受自动补全。tRPC 的useQuery、useMutation在组件里声明式调用后端函数的返回值类型自动同步到前端钩子这个体验用过就回不去。同时这套技术栈的组合是社区验证过的。Next.js 负责服务端渲染和路由Prisma 负责数据库抽象tRPC 替代传统 REST 层三者之间的配合经过了大量项目的检验我不需要自己造轮子所有方案都有成熟文档可以查。1.3 t3code 的目录结构参考动手之前先搭一个清晰的目录结构这是项目长期可维护的地基。下面是我在实际项目里验证过的一套结构不是教条是已经被踩过坑后沉淀出来的t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── post.ts │ │ │ │ └── user.ts │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ └── db.ts │ ├── shared/ │ │ └── schemas/ │ │ ├── post.ts │ │ └── user.ts │ ├── pages/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc].ts │ │ └── index.tsx │ └── styles/ │ └── globals.css └── package.json这套结构遵循一个原则server目录下的代码不进前端 bundleshared目录承载前后端共享的类型与校验规则。如果发现某个 schema 只在服务端用到那就不放进 shared避免把无关代码打包进浏览器。2. t3code 核心细节解析与实操要点2.1 Prisma 模型定义是起点整个类型链路的源头在prisma/schema.prisma。数据库的表结构直接决定后端有哪些数据可操作也间接决定了类型的上限。所以在动手写任何业务代码之前先把数据模型设计梳理清楚这一步比写接口重要一个量级。一个简单的示例假设我们要做一个博客系统model Post { 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 generatePrisma 会自动产出对应的 TypeScript Client 类型。之后在任何地方用prisma.post.create、prisma.post.findMany返回值和入参全部带类型检查写错了直接编译报错。有一个实操细节值得说明default(cuid())比自增 id 好在哪分布式场景不容易冲突且不会暴露数据量信息。如果只是内部工具用自增也没问题但一旦要考虑未来分库分表cuid 省去一次全局改造。2.2 Zod Schema 双复用策略说到类型安全很多人只想到 TypeScript 的静态类型但运行时校验同样重要。用户的输入是不可信任的浏览器端就算类型对了请求里照样可能给你塞奇怪的数据。Zod 在这里承担的是运行时守门员角色。比较推荐的做法是把 Zod schema 放在src/shared/schemas/里让服务端逻辑和前端表单共用import { z } from zod; export const createPostSchema z.object({ title: z.string().min(1, 标题不能为空).max(100), content: z.string().min(1, 内容不能为空), published: z.boolean().optional().default(false), }); export type CreatePostInput z.infertypeof createPostSchema;z.infer从运行时校验规则中推断出静态类型这保证了类型定义和校验规则永远同步。前端表单校验用的那一套规则后端接口的入参校验也是同一套不存在两处规则出现偏差的情况。2.3 tRPC 路由的组织方式tRPC 路由不是随便建个文件就完事它的组织会影响整个项目的可维护性。我的做法是先建一个trpc.ts作为基础上下文和中间件的初始化文件再建一个root.ts汇总所有子路由每个业务模块单独一个 router 文件。// src/server/api/trpc.ts import { initTRPC } from trpc/server; import superjson from superjson; import type { createContext } from ./context; const t initTRPC.contexttypeof createContext().create({ transformer: superjson, }); export const router t.router; export const publicProcedure t.procedure;// src/server/api/routers/post.ts import { createPostSchema } from ../../../shared/schemas/post; import { publicProcedure, router } from ../trpc; export const postRouter router({ create: publicProcedure .input(createPostSchema) .mutation(async ({ ctx, input }) { return ctx.prisma.post.create({ data: input, }); }), list: publicProcedure .query(async ({ ctx }) { return ctx.prisma.post.findMany({ orderBy: { createdAt: desc }, }); }), });这里有几个要点。第一superjson专门用来处理Date、Map等 JavaScript 特殊类型Prisma 返回的createdAt是 Date 实例不配置 superjson 的话前端拿到的会是被序列化成字符串的 ISO 时间处理起来很别扭。第二每个 router 文件只暴露当前业务实体的操作不要在 post 的 router 里写 user 的查询职责不清晰的问题早后期会膨胀成一层荆棘。2.4 上下文 context 的组装tRPC 的 context 是每一次请求的初始数据最典型的就是数据库客户端。它的设计决定了请求处理函数里能否访问到 prisma 实例。// src/server/api/context.ts import { prisma } from ../db; import type { inferAsyncReturnType } from trpc/server; export async function createContext() { return { prisma, }; } export type Context inferAsyncReturnTypetypeof createContext;以后要加登录鉴权就在这个 context 里读取 session 信息并放入ctx.user。这样在每个 resolver 里都能拿到当前请求用户的信息做权限判断非常方便。3. 实操过程与核心环节实现3.1 项目初始化和依赖安装我建议使用create-t3-app的实践方式做参考——虽然我这里是自己徒手搭但核心思路一致。初始化一个 Next.js 项目并安装关键依赖npx create-next-applatest t3code --typescript --tailwind --eslint --app cd t3code npm install trpc/server trpc/client trpc/react-query trpc/next tanstack/react-query superjson zod npm install -D prisma npm install prisma/client这一套依赖里需要特别解释的是tanstack/react-query。tRPC 的 React 绑定层底层依赖 React Query 做数据缓存、请求去重、状态管理。可以说 query 函数列表在 tRPC 里是切面的而 React Query 是它的底座。不理解这一点后面配置 tRPC Provider 的时候很容易一头雾水。安装完成后初始化 Prismanpx prisma init这会生成prisma/schema.prisma和.env两个核心文件。.env里面写数据库连接串比如DATABASE_URLmysql://root:passwordlocalhost:3306/t3code注意不要把这个文件提交到 git 仓库否则数据库密码直接泄露。3.2 建立 API 入口Next.js 的 Pages Router 或者 App Router 都需要一个 HTTP 入口把 tRPC 接入。以 Pages Router 为例在src/pages/api/trpc/[trpc].ts创建import { createNextApiHandler } from trpc/server/adapters/next; import { appRouter } from ../../../server/api/root; import { createContext } from ../../../server/api/context; export default createNextApiHandler({ router: appRouter, createContext, });这个文件的作用相当于一个通配路由把所有发往/api/trpc/*的请求转交给 tRPC 处理。不用自己一个个写 REST 接口这是 tRPC 利用 HTTP 协议做 RPC 的经典设计只有真正理解了这一点才能真正理解 t3code 的效率所在。在src/server/api/root.ts里汇总所有子路由import { router } from ./trpc; import { postRouter } from ./routers/post; import { userRouter } from ./routers/user; export const appRouter router({ post: postRouter, user: userRouter, }); export type AppRouter typeof appRouter;3.3 前端接入与类型推导前端首先要配置 tRPC Provider让 React 组件能拿到 query client。比较推荐的做法是创建一个统一的trpc.ts封装// src/utils/trpc.ts import { createTRPCNext } from trpc/next; import type { AppRouter } from ../server/api/root; import superjson from superjson; export const trpc createTRPCNextAppRouter({ config() { return { transformer: superjson, queryClientConfig: { defaultOptions: { queries: { staleTime: 60_000 }, }, }, }; }, ssr: true, });然后在_app.tsx里包裹 Providerimport { trpc } from ../utils/trpc; const MyApp ({ Component, pageProps }) { const TrpcProvider trpc.withTRPC(() ( Component {...pageProps} / )); return TrpcProvider /; };这个流程走通之后前端组件里的调用就完全类型化了。在 React 组件里写trpc.post.list.useQuery()返回的data类型自动推出来写trpc.post.create.useMutation().mutate({...})入参缺失会自动报错。这就是 t3code 最值得称道的体验前后端之间没有暗信息。3.4 做一个完整的发布流程为了让整套流程看得更全我把一个完整的博客发布场景串起来。假设前端有一个表单收集标题和内容提交后创建记录并刷新列表。先写着 zod schemashared 层复用export const createPostSchema z.object({ title: z.string().min(1).max(100), content: z.string().min(1), });后端 router 不变前端组件const createPost trpc.post.create.useMutation({ onSuccess() { utils.post.list.invalidate(); }, }); const onSubmit (data) { createPost.mutate(data); };这里值得学习的是onSuccess里的invalidate()。调用创建接口成功后React Query 会自动把post.list缓存标记为过期下一次渲染时自动重新拉取最新列表。不需要手动 setState 更新数据流非常简单直接。4. 常见问题与排查技巧实录4.1 Prisma 的陌生错误和迁移策略实际用下来最常遇到的坑反而是 Prisma 本身。尤其是初学者容易在schema.prisma里加了字段后只重新generate却不做迁移导致 Prisma Client 的类型是新的数据库实际表结构是老一套运行时报各种字段不存在的错误。正确习惯是每次改 schema 之后执行npx prisma migrate dev --name add_field_xxxmigrate 命令会自动对比 schema 和数据库差异生成迁移 SQL同步数据库表结构并重新生成 Prisma Client。注意本地开发环境可以直接 migrate dev生产环境部署前要 review 迁移文件并用migrate deploy执行不要在生产环境直接跑开发迁移命令。还有一个新手容易被绕晕的点使用prisma generate重新生成 Client 之后必须重启开发服务器否则 Node.js 进程里缓存的是旧类型的 Client仍然不报类型错误但运行时行为还是旧的。这类问题不容易排查必须先确认这一点再去找代码逻辑的 bug。4.2 tRPC 类型不匹配的排查思路tRPC 号称类型安全但真正用起来还是会遇到类型推导不对的情况。最常见的原因是你同时安装了不同版本的 trpc/server 和 trpc/client或者 trpc/client 与 trpc/react-query 版本不一致。tRPC 对版本一致性要求极其严格稍微错开一个小版本useQuery的类型推导就可能变成never或者丢失参数提示。遇到此类问题我的顺序是先npm ls trpc/server trpc/client trpc/react-query比对版本再检查tsconfig.json里的 strict 是否开启。tRPC 的类型推导极度依赖 TypeScript 的严格模式如果不开启这些类型特性很多保护就失效了。这也是 t3code 这样的方案在初始化时一定会把strict: true配好的原因。提示如果你在某个组件里trpc.post.list.useQuery()得到的 data 是 any先查 tsconfig 的严格模式这比翻业务代码高效得多。4.3 生产环境部署时的两个坑部署到 Vercel 或者自己的服务器时有两点值得提前准备。其一是 API Handler 必须在服务端运行不要试图在 edge runtime 里跑 PrismaPrisma Engine 依赖 Node.js 原生二进制edge 环境跑不通。Next.js 的默认配置没问题但如果你给路由设置了export const runtime edge调到数据库的接口直接报错。这个坑花了我不少时间建议所有涉及 Prisma 的接口保持 Node.js runtime。其二是数据库连接串的配置。开发环境.env里写的DATABASE_URL和生产环境的是两回事。如果部署平台和数据库不在同一区域每次查询的物理延迟会非常高。数据库连接最好走内网地址Payload 虽然也有这个要求但 Prisma 对延迟更敏感因为连接池的大小有限慢查询会导致连接被占满进而表现为间歇性请求超时。4.4 常见问题速查表现象可能原因解决办法Prisma 类型和数据库不一致改了 schema 忘了 migrate执行npx prisma migrate devtRPC 类型推导丢失版本不一致或 strict 未开启对齐 tRPC 版本开启 TS strict 模式生产环境接口 500数据库连接串错误或没配置检查部署平台环境变量edge runtime 下单 Prisma 报错Prisma Engine 不支持 edge去掉runtime edge配置直接请求/api/trpc/*返回 404API 入口文件路径不对确认pages/api/trpc/[trpc].ts存在且导出了 handlersuperjson 没配置导致 Date 变字符串前后端 transformer 不一致两端都设置 superjson5. 这个方案适合用来做什么5.1 适合的场景t3code 所代表的类型安全全栈方案最适合的是业务逻辑集中、数据模型清晰、前后端都由同一个小团队维护的项目。典型场景包括企业内部的管理后台、SaaS 产品的 MVP、博客/CMS 类内容系统、小型电商后台、开发者工具的控制台。在这些场景里最大的成本往往是业务逻辑的重复表达——前端一次、接口一次、数据库一次。t3code 把这三次压缩成一次省下来的时间非常可观。我自己用这套方案三天时间做了一个带权限管理的后台系统基础模块换个传统前后端分离写法一周都紧巴巴。5.2 不适合的场景也要讲清楚边界。如果项目是要给第三方提供开放 API且这个 API 的消费者可能是任意语言的那 tRPC 确实不太合适——它依赖 TypeScript 的 type 信息跨语言调用体验会打折扣。这种场景应该保留 REST 或 GraphQL 作为对外协议内部服务用 tRPC 没有问题。另外如果团队里同时维护十几个项目语言栈并不统一有 Go、Java 后端也有 Node 后端那整套 t3code 方案只能在 Node 技术栈的项目里局部使用无法作为组织级标准。它是武器库里的一件趁手兵刃但不是万能钥匙。5.3 我的个人体会最后说点实际的。用 t3code 这套思路新建项目的时候前期搭环境确实比单个框架初始化要费一些功夫要理解 Prisma、tRPC、Zod 各自的边界和它们之间的衔接点。但真正进入业务开发之后这种前期投入的回报极其可观。我最大的感受是心里有底——改一个字段、删一个属性不再需要担心哪些地方忘了改导致线上爆炸编译器已经帮你兜住了很大一部分低级错误。如果你正准备开始一个中型全栈项目又恰好是前后端一把梭的开发者强烈建议试试这类类型安全全栈方案。从项目初始化到第一个功能落地多花半天时间搭建基础设施后面获得的效率回报是以周为单位的。