t3code:T3 Stack 全栈项目的脚手架与类型安全代码生成器

做了几年全栈项目,我渐渐发现真正的痛点根本不是写业务逻辑,而是那些重复又琐碎的初始化动作:新建项目要配一堆东西,新增一个带数据库模型的 API 模块要手敲几十行样板代码,不同人写的 tRPC router 风格还千差万别。t3code 就是从这个痛点里冒出来的一个命令行工具,定位是 T3 Stack 项目专用的脚手架与代码生成器。它能帮你完成三件事:初始化一套规范的全栈工程、按需加功能模块、自动生成类型安全的 API 代码。所有生成的内容都会直接写进你的 src 目录,可以随时修改,而不是把某个二手模板仓库整体克隆下来。这篇文章我会把它从设计思路到实际用法完整拆一遍,适合正在用或准备用 Next.js + TypeScript + tRPC + Tailwind 这套组合的开发者参考。

1. t3code 到底解决什么问题

1.1 T3 Stack 的价值与痛点

先给不熟悉的朋友交代一下背景。T3 Stack 并不是某个框架,而是一套围绕 TypeScript 构建的全栈组合,常见成员是 Next.js、TypeScript、tRPC、Tailwind CSS,数据库层通常搭配 Prisma,鉴权用 NextAuth。它最大的卖点是端到端类型安全:从数据库查询到页面组件,类型信息可以一路贯穿,改了后端字段,前端马上报错,省掉大量联调时间。

但真正用起来之后,问题也随之而来。T3 工程的前期搭建流程高度固定,create-t3-app 已经把这一步解决得很好了,可它只负责"初始化"这一下。项目进入开发期后,团队会频繁遇到几个反复出现的场景:要新增一个业务模块,得手动创建 tRPC router、在根 router 里注册、补 Prisma model、跑迁移,最后还要写一版调用示例;要加一个新的第三方服务,比如对象存储上传、支付回调,得从头翻文档、配环境变量、封装客户端。这些工作没有技术难度,但极度消耗精力,而且不同开发者做出来的结构差异很大,代码评审的时候经常要为一些约定俗成的事情来回拉锯。

我用 t3code 就是想把这些"高频但不用动脑"的环节固化成命令。它不是一个传统意义上的项目模板,而是持续陪伴在项目生命周期里的生成器,把团队内部的约定打成包,让规范化变成一个动作而不是一页文档。

1.2 为什么选择 CLI 而不是模板仓库

一开始也有朋友建议,直接维护一个完整的模板仓库不就行了?新项目 clone 一份,改改名字就能用。这个方案的优点是简单,缺点却在两周后就暴露了:模板仓库一旦开始迭代,老项目无法自动同步新规范;全局改动一下路由注册方式,所有已经 clone 出去的项目都得手动改;并且模板仓库往往过度包含,项目里不需要 Stripe 却也带着 Stripe 的环境变量占位,看着碍眼。

CLI 工具选择的是另一条路,思路更像 shadcn/ui 那种"模块化脚手架"。t3code 不是把整份项目复制给你,而是按需往现有项目里写入代码区块。初始化命令只生成最基础的骨架,后续想要什么模块自己加。这样项目始终保持干净,不会有一堆用不到的示例代码;团队规范更新之后,老项目也可以通过重新执行命令来增量升级,不需要推倒重来。

我在做这个设计决策时给自己立了三条原则:不生成"装饰性"的废话代码、所有产物必须可以被开发者直接读懂和修改、命令重复执行应当安全(要么幂等,要么明确报错)。后来的使用体验证明,这三条原则基本消掉了脚手架工具的绝大多数抱怨。

2. t3code 的设计思路与整体架构

2.1 核心命令设计

t3code 的命令集刻意保持精简,只有三个:

  • init:初始化一个新的 T3 Stack 项目
  • add:向现有项目添加功能模块
  • generate:按资源生成业务代码

为什么只保留这三个?因为我观察到一个规律:绝大多数的脚手架诉求都落在"建项目""加能力""写模块"这三个动作上。命令太多反而会提高记忆成本,最后大家还是回到手写的老路。

init的交互式和 create-t3-app 类似,会问几个关键问题:包管理器、要不要 Prisma、要不要 NextAuth、路由模式选 App Router 还是 Pages Router。这里我特意没有提供某个"全家桶"选项,就是担心用户无脑全选,把项目撑成一个自己也不完全理解的庞然大物。初始化完毕之后,项目里只会保留你确认过的东西。

add面对的是能力集成场景,常见参数如下表所示:

命令功能说明需要改动的位置
t3code add stripe接入 Stripe 支付env、lib 客户端、webhook 路由
t3code add upload接入对象存储预签名上传env、服务端工具函数、API 路由
t3code add sentry接入错误监控顶层 error.tsx、初始化脚本
t3code add email接入邮件发送env、lib/mailer、模板目录

每个 add 模块都自带独立的配置清单,命令执行时会先检查当前项目有没有冲突文件,再执行写入和依赖安装。

generate则是对应资源 CRUD 场景。最常用的写法是t3code generate router <name>,例如t3code generate router post,它会创建 tRPC router 文件、补全根路由注册、生成一个页面调用示例,并提示你补数据库模型。这套交互逻辑我在 3.3 节会完整演示。

2.2 目录结构与模板工程分层

t3code 本身的代码结构分成三层,这个分层决定了它好不好维护,先放出来给大家看:

t3code/ templates/ base/ # init 时复制的基础文件 modules/ # 每个 add 模块的静态文件与说明 generators/ # generate 使用的动态模板 src/ commands/ init.ts add.ts generate.ts core/ logger.ts package-manager.ts file-utils.ts ast/ # 基于 ts-morph 的代码改写逻辑 register-router.ts append-env.ts update-config.ts config/ module-manifest.json # 模块依赖关系与冲突检测规则

静态模板放在templates层,命令逻辑放在src层,AST 改写逻辑独立成模块。这么分的原因很朴素:静态文件不需要编译,改起来方便;AST 逻辑最复杂也最容易出错,单独抽出来便于写单元测试。

动态生成没有用现成的脚手架库,而是选择了 Hygen。理由很简单:Hygen 的模板语法足够简单,<%= name %>这种插值方式谁都能看懂,而且它天然支持按目录约定去寻找模板文件,正好匹配我们generators/router这种组织方式。模板里的代码我会写得尽量"啰嗦",把注释也带上,因为生成出来是要人去读的,不是给机器跑的。

AST 改写是整个工具的核心难点。比如generate router post要在已有的根 router 里追加一行postRouter的注册代码,用字符串正则替换很容易误伤,用 ts-morph 把文件解析成语法树,找到trpcRouter的调用处再添加属性,就比较稳妥。这个环节我栽过几次跟头,后面会单独讲。

3. 从零到一:t3code 的实操指南

3.1 初始化一个 T3 项目

假设你还没装过 t3code,直接用 npx 是最省事的方式,不需要全局安装污染环境:

npx t3code init my-app

接下来会进入交互问答。以我实际使用时的选择为例:

? Choose your package manager pnpm ? Enable Prisma? Yes ? Enable NextAuth? Yes ? Router mode App Router ? Enable ESLint and Prettier? Yes

确认后,t3code 会先检查目录是否为空,然后复制基础模板,替换项目名等占位变量,执行对应包管理器安装依赖。这个过程会持续几分钟,中间能看到每个阶段的日志。

初始化完成后的关键目录大致长这样:

my-app/ src/ app/ page.tsx api/trpc/[trpc]/route.ts layout.tsx server/ api/ root.ts trpc.ts routers/ post.ts user.ts lib/ prisma.ts auth.ts prisma/ schema.prisma .env .env.example

和 create-t3-app 生成的工程最明显的区别是,这里没有server/api/routers/*.ts的一堆示例文件,只保留了一个最小的post.ts用来演示 router 的写法。我宁可让用户自己删除这个演示文件,也不希望默认带五个用不到的例子来回误导。

初始化指令整体符合预期,唯一要注意的是 Node 版本,t3code 依赖的解析逻辑要求 Node.js 18 以上。我第一次在 Node 16 的环境上跑,直接报了一个fetch is not defined的错误,后来在文档里加了版本检查才解决。

3.2 按需添加功能模块

项目跑起来之后,需要接新能力时用add。我实际在项目里最常用的是对象存储上传模块:

npx t3code add upload

执行后 t3code 会检测到当前项目是否有 Prisma(通过检查prisma/schema.prisma是否存在),如果缺少,会给出一个警告而不是直接失败,提示该模块不依赖数据库可以使用,但后续如果要记录文件元信息需要自行补充。这个提示是功能上线后根据用户反馈加的,之前有个用户执行 add upload 后手忙脚乱地发现没装 Prisma,白白排查了半天。

模块写入的内容包括:

  • .env.example里追加S3_ENDPOINT、S3_ACCESS_KEY_ID、S3_SECRET_ACCESS_KEY、S3_BUCKET_NAME四个变量
  • src/lib/storage.ts封装预签名上传与文件删除
  • src/app/api/upload/route.ts暴露一个限流的上传入口

以storage.ts为例,生成的核心逻辑是这样的:

import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; const s3 = new S3Client({ region: process.env.S3_REGION, endpoint: process.env.S3_ENDPOINT, credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "", secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "", }, forcePathStyle: true, }); export async function createPresignedUploadUrl(key: string, contentType: string, expiresIn = 600) { const command = new PutObjectCommand({ Bucket: process.env.S3_BUCKET_NAME, Key: key, ContentType: contentType, }); return getSignedUrl(s3, command, { expiresIn }); }

这里有个细节值得多说一句:为什么强调forcePathStyle: true?因为很多自建对象存储不像 AWS S3 默认支持虚拟主机风格访问,加上这个配置能兼容更多服务商。这一行就是踩过坑之后沉淀下来的经验。

add 执行完成后,日志会提示下一步操作,比如"请在你的.env中补全相关的配置项"。我的经验是:生成器不替你写.env正式值,只更新.env.example,这是为了不让真实密钥散落到模板和代码库历史里,安全习惯从工具层面养成。

3.3 用 generate 自动生成类型安全的 API 代码

这大概是我日常使用频率最高的子命令。比如现在要新增一个博客文章资源,我会先手动去prisma/schema.prisma里补上模型,然后执行:

npx t3code generate router blog --model Blog

generate会读取当前prisma/schema.prisma中的model Blog字段,自动推导出可用的查询输入,生成的 router 文件在src/server/api/routers/blog.ts,核心内容大致如下:

import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const blogRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ limit: z.number().min(1).max(100).default(20), cursor: z.string().optional(), }), ) .query(async ({ ctx }) => { return ctx.db.blog.findMany({ take: 20, ...(ctx.input?.cursor ? { skip: 1, cursor: { id: ctx.input.cursor } } : {}), orderBy: { createdAt: "desc" }, }); }), getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx }) => { return ctx.db.blog.findUnique({ where: { id: ctx.input.id } }); }), create: publicProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), }), ) .mutation(async ({ ctx }) => { return ctx.db.blog.create({ data: { title: ctx.input.title, content: ctx.input.content, }, }); }), });

注意这里生成的.query回调里用的是ctx.input而不是input,这是我经过深思熟虑的简写方式。这种写法读起来更直接,加上zod的 schema 定义本身已经提供了类型推导,ctx.input不强依赖前面的参数名,后续重构的时候更不容易因为解构名称不一致而翻车。

同时,t3code 还会在根 router 里自动追加一行注册代码:

// src/server/api/root.ts import { blogRouter } from "~/server/api/routers/blog"; export const t3codeRootRouter = createTRPCRouter({ post: postRouter, blog: blogRouter, });

这一步就是前面提到的 AST 改写逻辑。第一次实现时我偷懒用了正则匹配,逮到createTRPCRouter({就塞属性,结果有好几处注释里的示例代码被误改。后来换成 ts-morph,做一次真正的语法树操作,问题就彻底消失了。

还有一个容易忽略的点:生成代码里的 import 路径用的是~/server而不是相对路径../../server。这是 T3 项目初始化时在tsconfig.json里配置的 paths 别名。t3code 在 generate 时也会检查这个别名是否存在,如果项目里没有配paths,会主动提醒你加。类型安全的前提是 TypeScript 能正确解析这些类型,路径别名配置错了,后面的所有推断都会失效。

4. 踩坑实录与问题排查

4.1 依赖版本冲突

t3code 上线后收到最多的反馈是依赖初始化失败,绝大多数和版本锁定有关。T3 Stack 迭代很快,尤其是 Next.js 从 13 稳定到 14 再到 15,像next-auth与 Next.js 的 peer dependency 经常出现不匹配,tRPC的 10 和 11 版本写法也有明显差异。

我在生成基础模板时把版本号写死在模板的package.json里,而不是使用宽松的^范围。这算是一个比较激进的决策,因为 T3 Stack 的兼容矩阵变化太快,宽松版本很容易让一个原本能跑的项目在半年后安装到某个不兼容的 minor 版本。如果你用 t3code 初始化后马上就想升级某个关键依赖,我建议先跑通一次完整编译,确认接口没变再升,别直接无脑latest。

另外prisma和@prisma/client的版本必须严格同步,这个老生常谈的问题在这里出现频率更高,因为生成器会顺手修改schema.prisma,一旦引擎版本不一致,运行时会直接报PrismaClientInitializationError。现在我在 add 和 generate 逻辑里都加了检查,发现版本不匹配就直接终止并提示升级命令。

4.2 环境变量与 Prisma 迁移问题

初始化之后最容易遇到的运行时报错是这样的:

PrismaClientInitializationError: Environment variable not found: DATABASE_URL

其实模板里已经生成了.env和.env.example,但很多人改完schema.prisma之后只想着跑migrate dev,忘了DATABASE_URL是存放在.env里的,而.env此时还没有任何内容。打磨期我为generate增加了一个环境变量探测逻辑:如果检测到.env里没有DATABASE_URL但它引用了 Prisma,就会在完成代码生成后给出高亮的后续操作指引,省得用户再对着错误去看日志。

另一个相对隐蔽的坑是,部分开发者习惯把整个schema.prisma按 model 拆分成多个文件,用prismaSchemaFolder特性。t3code 初始模板默认使用单文件,但在 generate 时我会去解析prisma目录,如果你的项目启用了多文件 schema,生成器不会尝试替你改文件,而是打印一条清晰的提示,告诉你手动把 model 加到对应文件里。我刻意没有做多文件的自动写入,因为目前 Prisma 对多文件的支持虽然常用,但目录结构约定并没有强到可以安全假设。

4.3 tRPC 类型安全失效的排查思路

有用户反馈,generate 生成的 router 在页面里调用时没有自动补全。排查下来,九成是项目里tsconfig.json的 paths 配置没执行,或者别名不够统一。我在初始化模板里默认用的是@/*和~/*同时存在,但部分用户迁移旧项目时只留了一个,导致 import 路径解析失败,TypeScript 直接退化为any,类型安全当然不复存在。

其次要注意as const的使用。T3 项目里如果在页面端写了类似const api = trpc.useUtils();,之后又试图把 router 的返回作为常量引用,一定要显式加as const,否则字面量类型会被展开成宽泛的 string,属性名就会在调用时神奇消失。这个小问题排查起来很费神,因为编辑器不报错,运行也不报错,就是类型补全不见了。

我给 t3code 加了一个诊断子命令t3code doctor,专门检查五类问题:tsconfig.json的 paths 配置、@tanstack/react-query版本、tRPC 版本、.env是否存在、依赖锁定是否合规。真的建议在排错时先跑一遍这个命令,能省不少冤枉时间。

4.4 常见问题速查表

把这段时间维护 t3code 遇到的高频问题整理成一张表,方便大家直接对照:

现象可能原因处理方式
createTRPCClientError: fetch failed客户端调用地址没有配置绝对 URL检查NEXTAUTH_URL或trpc的url配置
generate 后页面无类型提示tsconfig.jsonpaths 缺失或别名不一致运行t3code doctor,按提示修复后重启编辑器
Prisma 引擎版本报错prisma和@prisma/client版本不同步pnpm dlx prisma version对比版本并统一
Tailwind 样式不生效模板用了@tailwindcss/postcss但未在 postcss.config 引入确认postcss.config.mjs中 plugin 配置正确
NextAuth session 类型丢失未自定义NextAuth的类型扩展文件生成types/next-auth.d.ts并声明session.user.id

这里特别提一下 Tailwind 的坑。T3 项目初始化时默认走 PostCSS 插件方式,很多人升级到 Tailwind CSS v4 之后,旧的tailwind.config.ts配置不会自动迁移到新的 CSS 语法,样式看起来就像"完全没生效"。t3code 在 add 一些包含 UI 样式的模块时会检测package.json里的 tailwindcss 版本,v4 项目会直接生成@import "tailwindcss";的写法,而不是往tailwind.config.ts里塞内容。这个小适配花了不少时间。

个人的一点实操体会

这段经验想分享给准备自建脚手架的朋友:代码生成器最忌讳的是生成出来的代码与项目现状脱节。t3code 花钱最多时间的地方其实不是写模板,而是设计那些"检查当前状态再决定生成什么"的逻辑。比如 add upload 之前先判断项目里有没有现成的src/lib/storage.ts,有就不覆盖;generate router 之前先检查模型字段变化,避免生成出连表结构都不对应的查询代码。任何一次无脑覆盖都可能毁掉使用者几十行手写改动,谨慎的默认行为才会让人敢反复依赖这个工具。

另外,我要为"模板里尽量少写示例"这个决策买单的经历也值得一说。早期版本我在 init 生成的page.tsx里写了一整个完整的用户列表 CRUD,本意是让新手能立刻看到全链路效果。结果大部分使用者都是直接全选删除,少数人甚至没意识到那是示例代码,把它当成了必须保留的正式逻辑。后来我把示例压缩到一个最小的计数器,只演示useQuery的用法,发现反而没有人再困惑了。工具应该帮人少做决定,而不是替他做太多无关紧要的决定。

目前 t3code 的下一步规划里,除了继续打磨 Prisma 多文件模式的兼容性,还在考虑加入可插拔的模板市场机制,让不同团队能发布自己的内部模块。这也是我在实际使用中体会最深的:一个脚手架工具的价值不在于能生成多少代码,而在于它是否真的贴合了你团队的重复劳动模式。开头提到的那个问题,现在回头看看已经解决大半,工具化这件事,值得每个技术团队认真做一次。