1. 做t3code之前,我正被"前端写接口、后端写类型"折磨
1.1 表面问题是联调慢,根子问题是类型断裂
最早是我们组接一个中后台管理系统,前端每天最忙的一件事不是写页面,而是对着接口文档问后端:"这个字段啥意思?""这个字段到底可空还是不可空?""你说返回字符串,怎么给我返回了个数组?"
后端也委屈,因为写得急,文档更新不及时,有些返回结构改了没人发现。前端为了不阻塞页面,就疯狂写 any,写多了之后代码是跑通了,但同事接手时根本不知道某个字段到底有什么值。这种 "前端一套类型,后端一套类型,数据库又一套类型" 的割裂感,几乎每天都在消耗我们的精力。
后来我认真复盘了一下:我们缺的不是人,也不是技术栈,而是缺了一个能把前后端类型绑在一起的项目骨架。前端和后端各管各的,类型定义靠复制粘贴,这种模式只要项目一复杂就必然失效。与其反复补文档、对字段,不如从项目最初的结构设计上杜绝这件事。
1.2 t3code 的定位:不是为了某个业务,而是为了"以后所有业务"
t3code 是我给这套内部项目脚手架起的代号。它的目标很简单:以后我们团队开新项目,直接拉它当模板,不用再从零搞前后端分离、类型打通、样式体系这些基础设施。
我给 t3code 划定的边界很明确,它不是一个具体业务系统,而是一套类型安全的全栈项目模板。它要解决的核心问题是三件事:前端组件里写的类型、后端接口接收和返回的类型、数据库表结构的类型,三者能不能在编译期就保持一致?如果数据库字段改了,页面上的报错能不能立刻暴露,而不是等到运行时接口返回了才发现?
这也是 t3code 这个名字的由来:t 代表 TypeScript,后面的 3 是我们期望达到的三个核心目标——类型安全、快速启动、长期可维护。这套东西不是拍脑袋想出来的,而是基于我们团队过去几个项目踩过的坑总结出来的。
2. 技术选型没有跟风排行榜:t3code 锁死这几件套的原因
2.1 为什么是 TypeScript + Next.js,而不是后端模板引擎或纯前后端分离
t3code 选型时有几个候选方案摆在我面前:Vue + Express、React + Spring Boot、纯 Next.js。我没有看社区热度,而是模拟了团队真实协作场景,最后锁定了 Next.js 作为承载层。
有人可能会说,Next.js 不是一个全栈框架吗?我们不就是要做前后端分离吗?这里有个很关键的认知点:Next.js 在这里承担的不是"让服务端渲染页面"这个职责,而是同时承担了 API 路由层和前端应用层的职责。它让我们能用一套 TypeScript 语言栈把 API 层和页面层放在同一个仓库、同一个类型系统里管理,但页面的运行时交互仍然是纯前端逻辑,API 路由仍然是独立的接口层,并不互相污染。
选 Next.js 还有一个实际原因是我们团队没人想维护两套服务部署。以前前端的构建产物放在 CDN,后端的 Node 服务单独跑一个进程,日志、环境变量、部署流程都要准备两份。t3code 把 API 路由和应用页面放在同一服务里,部署时只需要一套流程,最低成本地实现了前后端同仓但逻辑隔离。
2.2 Tailwind 不是"无脑工具类",而是遏制样式债务的最后一道闸门
这个选择我第一次提出来时组里有人反对,觉得 Tailwind 的 class 串很长,会被后端同事吐槽。但真正推动我下决心的是我自己一个实验:拿之前的老项目做了一次样式重构,把 CSS 文件按页面拆开后发现,光是一个登录页就有几百行没删干净的重复样式,一旦需求要改颜色主题,得满文件搜索十六进制色值。
t3code 里我直接封装了一套 Tailwind 主题变量,而不是让组件里直接手写颜色值。所有颜色、间距、圆角、阴影都定义在 tailwind.config 的 theme 里,组件里只用语义化的类名,比如text-ink-normal、bg-brand-primary。这样业务开发时几乎没有机会写出和设计稿不一致的硬编码颜色。
这套做法的本质是把样式规则变成可查、可改、可运行时报错的代码,而不是靠团队自觉。新来的同事不需要在 style 文件里大海捞针,看到类名就能猜个大概。类名丑不丑其实不重要,重要的是能让每个人写出来的页面看起来像同一套体系。
2.3 tRPC 做粘合剂,以及为什么我不选 REST 和 GraphQL
这是 t3code 选型里争议最大的部分。很多团队都会天然选择 REST,因为它够通用、够保守;GraphQL 则被认为是过度的外挂。但站在类型安全的角度,REST 有个致命问题:前端要拿到接口数据的类型,必须自己去定义或者靠工具生成。接口一多,类型文件就开始膨胀,而且很多接口返回结构有大量重叠,维护起来相当痛苦。
tRPC 解决的是 API 契约的类型传导问题。我不用手动写路由字符串、不用手动定义请求和响应的 interface,直接写一个函数,它在前端调用时的入参类型和返回类型天然就是从后端推导过来的。这个过程对使用者来说是隐形的,但它消灭了一整类"前端类型和后端类型不一致"的 bug。
不选 GraphQL 的理由也很实际:小团队维护 schema 的成本太高,graphql 的 resolver 上的类型还算严格,但一到了各种自定义指令、缓存策略、权限组合就开始失控。tRPC 的学习曲线几乎为零,它会 TS 就能用,不需要学新的查询语言。它当然也有能力边界,比如不适合做开放 API 供第三方调用,但这不是 t3code 当前要解决的问题,t3code 服务的是内部系统。
3. 目录结构和数据流设计:t3code 的核心骨架
3.1 按业务模块切分仓库,而不是按技术栈切分
t3code 的目录结构我特意没有按 "components、pages、server、utils、types" 这样传统的方式组织,因为那样做会导致一个产品的功能模块被拆散到十几个文件夹里,改动一个订单流程可能要同时动五个目录。
我采用的是按模块组织的方案:
t3code/ src/ modules/ auth/ server/ # tRPC router 及相关服务端逻辑 components/ # 该模块特有的 React 组件 schemas/ # zod 校验 schema index.ts # 模块出口,只有这里能对外暴露 product/ server/ components/ schemas/ shared/ config/ # 公共配置(数据库、环境变量等) lib/ # 通用工具函数,不涉及具体业务 types/ # 跨模块共享的类型定义 app/ api/ # Next.js API routes 的挂载层 (pages)/ # 前端页面路由层 prisma/ schema.prisma这套结构的关键在于模块内高内聚、模块间低耦合。tRPC 的每个子路由都放在自己的模块 server 文件夹里,页面组件只从自己模块的 components 里拿组件,跨模块访问只能通过 index.ts 导出的公共 API。我踩过最大的教训就是千万不要把类型定义放在一个全局的 declarations 文件夹里,否则所有模块都能互相依赖,最后变成蜘蛛网。
3.2 一次典型的数据流转:从数据库表到页面显示
为了让没有接触过 tRPC + Prisma 组合的人理解 t3code 的数据流,我拿一个最简单的"获取产品列表"功能举例。整个过程用户看到的是页面加载出数据,但背后是这样一条链:
// prisma/schema.prisma model Product { id String @id @default(cuid()) name String price Int }// src/modules/product/server/product.router.ts import { z } from "zod"; import { router, publicProcedure } from "@/shared/lib/trpc"; import { prisma } from "@/shared/lib/prisma"; export const productRouter = router({ list: publicProcedure .input(z.object({ limit: z.number().min(1).max(100).default(20) })) .query(async ({ input }) => { return prisma.product.findMany({ take: input.limit, orderBy: { id: "desc" }, }); }), });// src/modules/product/components/ProductList.tsx import { trpc } from "@/shared/lib/trpc"; export function ProductList() { const { data, isLoading } = trpc.product.list.useQuery({ limit: 20 }); if (isLoading) return <div>加载中...</div>; return ( <ul> {data?.map((product) => ( <li key={product.id}> {product.name} - ¥{(product.price / 100).toFixed(2)} </li> ))} </ul> ); }注意这里的几个细节:Prisma 中的Product类型定义,通过 tRPC 的 query 返回后,前端的data是什么类型,完全由后端推导。我前端并没有写任何interface Product,但 ProductList 里的product.name、product.id、product.price全部有类型提示,字段大小写、可空性、数字精度全部和后端一致。
这个链路最核心的价值在改动期:如果我在 Prisma schema 里给 Product 增加一个isActive字段,然后忘了在查询里返回它,前端不会报错;但如果我在 schema 里把name改成了title,那么没同步改的前端组件就会立刻在编译期报错。对我来说这就够了——类型断裂的发现时机从"联调期"提前到了"编译期"。
4. 实战中的坑:t3code 从能 run 到能用的距离
4.1 Prisma 初始化连接和迁移流程,别等部署后才发现断连
第一次把 t3code 部署到测试环境,我就傻眼了:本地跑得好好的,部署后所有接口都返回 500。查日志发现是 Prisma 数据库连接失败,错误信息是Too many connections。
复盘后根因有两个。第一,我本地用的 SQLite,测试环境用的是 MySQL,两个数据库的连接行为差异很大。第二,Next.js 开发模式下有热重载机制,模块会被反复加载,而 PrismaClient 在全局只应该初始化一次。我在开发模式里图省事,直接在文件顶层new PrismaClient(),热更新时会不断创建新实例,导致连接数迅速飙升。
正确的做法是在全局单例里缓存 PrismaClient,而且要按环境差异做容错处理。我在 t3code 里专门写了一个 lib 处理这个事:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["query", "warn"] : ["error"], }); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = prisma; }这个模式几乎每个 Prisma + Next.js 项目都会用到,但真正理解它的人不多。它的逻辑是:开发环境下把 PrismaClient 实例挂到globalThis上,避免热重载时反复创建;生产环境则默认走独立实例。另外,部署环境一定要检查数据库连接数的限制,我给 MySQL 设置了max_connections调大,同时 Prisma 的connection_limit连接池参数也要同步调整。
4.2 tRPC 类型推导失效的三种场景,命中一个就够你查一天
tRPC 最大的卖点是类型推导,但在实际使用中有几个非常隐蔽的失效场景,我不止一次被坑。
第一个场景是在 setup 函数里动态拼接 router。tRPC 官方推荐在独立文件中创建 router,但我之前在 t3code 早期版本里用了工厂函数,外部传参后运行时生成 router。这种做法会导致前端trpc.useUtils()的类型怎么都不对,因为 TypeScript 无法在类型层面追踪运行时动态生成的接口。我的解决方案很简单:每个子模块导出静态 router,主 router 用mergeRouters或扁平的router对象显式声明,不使用任何动态生成逻辑。
第二个场景是把 tRPC 的 response 序列化成了 JSON 字符串再返回给前端。有次同事想在 query 里多返回一个字段,但他对字段做了JSON.stringify处理,返回类型就变成了 string,前端拿到的类型提示瞬间全乱。tRPC 底层已经有很好的序列化能力,不需要手动做序列化,凡是发现返回类型和 Prisma 模型不一致,第一反应应该是检查有没有JSON.stringify、format这类中间操作。
第三个场景是input 校验用的 zod schema 在前后端各写了一份。tRPC 的输入校验完全依赖 zod schema,而且 schema 是放在后端 router 里的,前端应该只使用推导出来的类型,不应该自己再定义一份 input 类型。一旦前端复制一份,就会和 zod 严格校验结果产生偏差。正确做法是只导出类型,不导出值:
// 后端导出类型 ShapeType export type ProductListInput = z.infer<typeof productListInputSchema>;4.3 别让 TypeScript 类型变成橡皮泥:输入校验与序列化边界
t3code 里有一个核心原则:任何写进数据库的字段,都必须在入口处经过 zod 校验;任何返回给前端的数据,都必须是数据库模型的直接映射。这不是小题大做,而是防止类型安全被绕过。
举个具体的例子:产品模块的创建接口接收一个price字段,前端传的是"12.50"这样的字符串,可能来自表单输入。如果我图省事,直接把这个字符串塞给 Prisma 的create,类型系统会提示错误,但如果我在 zod schema 里写了z.string()然后手动Number()转换,那么类型上price就已经不是一个干净的 number 了,后续任何对price做的计算都可能引入 NaN。
t3code 的做法是在 zod schema 入口处就做严格转换:
import { z } from "zod"; export const productCreateSchema = z.object({ name: z.string().min(1).max(100), // 先接收 string,再在 transform 中转为 number price: z.string().transform((val, ctx) => { const num = Number(val); if (!Number.isFinite(num)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "价格必须是一个有效数字", }); return z.NEVER; } if (num <= 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "价格必须大于 0", }); return z.NEVER; } return num; }), }); export type ProductCreateInput = z.input<typeof productCreateSchema>; export type ProductCreateOutput = z.output<typeof productCreateSchema>;你会发现这里我导出了两种类型:z.input是客户端期望传入的类型,z.output是服务端经过 transform 后的类型。tRPC 在前端调用时提示的是 input 类型,在服务端拿到的则是 output 类型,两端各取所需,类型依然闭合。
这类细节真的不能等写代码时再临场发挥。团队里如果每个人各按各的方式处理输入数据,最终类型安全一定会变成橡皮泥——看起来有约束,实际上怎么捏都有缝隙。
5. 把 t3code 当脚手架而不是框架:团队落地与演进
5.1 模板项目和业务项目的边界,别让骨架变成紧箍咒
t3code 作为内部模板,最怕的是被团队当成一个"开发框架"去迷信。模板和框架的区别在哪里?模板是一组可以被修改、被裁剪的起始文件,框架则是一套封装了核心流程的约束体系。我一直在刻意让 t3code 保持"薄"的状态——它只提供目录结构、技术选型、公共配置、基础封装,不提供任何强制的业务基类。
实际落地时我建议的新项目流程是这样的:把 t3code 仓库 clone 下来后,第一件事是改掉项目名和包名,然后把 modules 文件夹清空,只保留一个 auth 模块作为参考样例。为什么要保留 auth?因为权限校验是几乎所有业务系统都需要的逻辑,留着它新人能直接看到一个完整的 tRPC router 应该怎么写,包括受保护路由怎么写、session 怎么取。等新项目的 auth 功能实现了,这个样例模块就可以删掉了。
5.2 版本冻结与升级策略:不要跟着每个大版本踩坑
t3code 依赖的生态链比较长,Next.js、Prisma、tRPC 都有自己的发布节奏。这里我有两个建议,第一个是在 package.json 里锁死主版本号,不轻易跨大版本升级。Next.js 从 13 到 14 再到 15,每次升级都伴随着 App Router、Server Components 的语义调整,不是单纯改个依赖版本就能平滑切过去的。t3code 模板的 lockfile 直接提交到仓库里,确保每次新项目拉出来时依赖版本是确定、可复现的。
第二个建议是把升级当成独立任务来做,不要边写业务边升级依赖。我统计过,团队里精神状态最差的时候往往就是升级依赖之后的三天——全都是 API 变更导致的编译错误,业务需求还得照常交付,两边叠在一起极其痛苦。所以我会在 t3code 仓库里开一个独立的 branch,专门做依赖升级,跑完整个模块的冒烟测试后再合并。
5.3 新人上手路径设计:模板最好能"自带文档"
t3code 的文档我放在了 README 里,但不是写那种"项目简介+技术栈列表+启动命令"的三段式,而是按新人的认知顺序来编排:怎么启动项目、代码放在哪里、怎么新增一个业务模块、怎么新增一个 tRPC 查询、怎么验证前后端类型确实通了。
还有一个细节我觉得挺有用:我直接在仓库里放了一个scripts/check-template.sh,运行后会自动检查新业务模块的文件结构是否符合 t3code 的约定,比如 server 文件夹下必须有一个 router.ts、components 下不能直接 import 其他模块的 server 文件等。如果检查不通过,脚本会在本地 CI 里报出来。这样新人最早提交代码时就能得到规范性反馈,而不是等到 code review 时被老同事逐条指出来。
这套模板带来的收益是实实在在的:团队新项目从"初始化+搭框架+部署"通常要三四天,缩短到了小半天。节省下来的时间没有用来摸鱼,而是用来把业务逻辑写得更扎实。更关键的是,前后端类型断裂导致的线上问题,几乎再没出现过。
最后分享两个我自己在 t3code 维护中积累的小心得。一是模板项目一定要敢于"留一个坏例子和对应修好的例子",我在 modules 下特意留了一个带注释的 package 模块,里面故意写了几处常见的错误写法,旁边就是正确写法——这比任何文档都更能让新人理解约定。二是所有公共封装的函数别急着抽象,t3code 里的 lib 目录当前只有不到十个函数,每个都是至少被三个模块复用后才抽出来的。过早抽象是模板项目最常见的包袱,宁可目录上显得"不够高级",也别让抽象成了看不懂的黑洞。