资讯详情

资讯详情

t3code:T3 Stack全栈TypeScript脚手架与CRUD生成器

做了三年以上的全栈项目有一阵子我特别害怕“再开一个新项目”。不是怕写业务代码是怕从零初始化那套基础设施建仓库、配 lint、选依赖、做认证、接数据库、定义路由每一步都有无数种选法选错一个等到上线才哼哧哼哧地改。后来我把踩过的坑和验证过的方案收拢成了一套自己的工具链取名叫 t3code。往小了说它是一个基于 T3 Stack 的 CLI 脚手架和模块生成器往大了说它把我脑子里“一个可维护的全栈 TypeScript 项目应该长什么样”完整翻译成了代码模板。这篇文章就把它的设计思路、核心用法和我在迭代过程中解决的几个硬骨头摊开讲一讲希望能给正在 T3 Stack 边缘试探的人省下几夜时间。1. 为什么我要做一套叫 t3code 的工程化方案——T3 Stack 的优势与痛点先说清楚 T3 Stack 的概念避免有人误以为 t3code 是个什么插件。T3 Stack 是 Theo 等人在全栈 TypeScript 社区推动的一套“约定优于配置”技术栈组合主心骨是 Next.js TypeScript tRPC Prisma Tailwind CSS NextAuth。这套组合最大的吸引力在于端到端类型安全数据库字段类型能一路推到前端表单输入框的类型提示里理论上看不到那种格式良好的 JSON 返回后在组件里突然变成 any 的尴尬场景。但理念很丰满实操起来有几个经常让团队卡壳的地方。第一初始化成本是隐性的。官方模板给了你一个已经能跑通的 Demo可一旦要加自己领域的核心模型就会发现自己对目录结构怎么摆、认证怎么接入、权限中间件挂在哪完全没底。很多人会照着 Demo 抄抄到一半发现每个文件的耦合关系根本理不清。第二tRPC 的“类型安全”不是白送的。它要求你把 router、context、middleware、API 层和客户端 Provider 之间的类型流打通。只要某一步少了AppType推导前端钩子上的类型提示就会悄悄退化成unknown而且不会报错只有等你访问不存在的字段时才懵。第三团队协作时“最佳实践”极易分叉。有人喜欢把所有路由堆在根 router 里有人习惯每张表一个模块有人用 zod 手动写输入校验有人把所有 JSON 任意通过。时间一长代码仓库就变成了无组织风格展览馆。我琢磨这个问题时想得很朴素能不能把熟手的工程规范固化成一个工具把“搭架子”变成一条命令把“新增一张数据表”变成一次交互问答于是就有了 t3code。它不是什么“低代码平台”也不会替你写业务逻辑它的职责非常聚焦初始化项目、生成模块脚手架、同步生成类型和路由以及强制统一项目结构。t3code 适合的读者其实挺明确想深入研究全栈 TypeScript 的小型技术团队以及被“从零配 NextAuth Prisma tRPC”吓到过好几次的个人开发者。它需要的不是什么高端编码能力而是你愿意接受一套“有一定主观性但自洽”的目录约定。2. 三分钟上手一条命令初始化目录结构与生成脚本设计2.1 环境准备用 t3code 之前请先确认环境里装了这几样Node.js 18.17 或更高版本。推荐直接上 Node 20异步本地存储相关的依赖在旧版本上容易出现坑。包管理器用 pnpm。你当然可以用 npm但 t3code 生成的 workspace 结构就是按 pnpm 的硬链接特性设计的换成 npm 后安装速度和磁盘占用都会倒退。一个可用的数据库。本地开发可以用 SQLite真实部署建议 PostgreSQL。t3code 初始化时有一个交互项会问你要哪种数据库选错也不怕无非是改环境变量、重新生成 Prisma Client。2.2 创建项目在空目录里执行npx t3codelatest create my-app执行后你会看到几个交互问题注意我只保留了有实际影响的选择项应用名称默认就是当前目录名包管理器建议 pnpm数据库类型SQLite / PostgreSQL是否需要认证模块基于 NextAuth v5是否需要管理后台基础页面回答完毕工具会直接开始安装依赖并生成初始 Git 仓库。我的设计原则是“不要让初始化变成一场漫长的英语听力测试”所以最多只有四五个问题不做那种十几个问题还夹带广告的交互。创建完之后的目录结构大致如下my-app/ ├── apps/ │ ├── web/ # Next.js 应用前端和 tRPC 路由层 │ │ └── src/ │ │ ├── pages/ │ │ ├── server/ │ │ └── styles/ │ └── api/ # 纯 tRPC 服务入口独立部署时用 ├── packages/ │ ├── db/ # Prisma schema、client、种子脚本 │ ├── auth/ # NextAuth 配置与适配器 │ ├── validators/ # zod schema 汇总供 API 与前端共用 │ └── config/ # 共享 tsconfig、eslint 预设 ├── pnpm-workspace.yaml ├── turbo.json └── .env.example这种 monorepo 结构是我折腾了很久才定下来的。很多人会疑惑既然 t3code 是给 T3 Stack 用的为什么不干脆单包线性目录因为一旦项目运行到中后期你会发现“API 服务”和“Web 前端”天然有两种部署节奏二进制编译、环境变量隔离、扩缩容策略都不一样。monorepo workspace 能让你在前期多花十分钟但后期省下的运维成本远高于这个值。2.3 常用生成命令初始化只是最外层功能t3code 真正提高效率的是模块生成器# 生成一个完整 CRUD 模块路由、prisma model、表单页面、类型 npx t3code add crud product # 只给既有模块加一个权限中间件 npx t3code add middleware product --require-admin # 同步 Prisma schema 和 Zod schema npx t3code generateadd crud product执行时会读当前的 Prisma schema如果里面已经有Product模型它会直接复用如果没有它会生成一条最简模型定义然后让你自己补字段。生成的代码不是我随手拼的模板而是经过了阶段性的抽离页面上的表格列、筛选条件、编辑表单字段全部可以从 Prisma 模型属性里推导出来。比如default(now())的字段不会出现在表单中updatedAt自动在更新时维护。这里有件值得强调的事生成器的输出一定是可读且可改的绝不是一个黑盒。如果你用 CtrlC 取消命令它不会产生半成品文件每次生成前会计算文件差异只覆盖我标记为“可自动编辑”的文件而那些真正写业务逻辑的例如product.service.ts会保留并跳过。3. 类型安全真相tRPC context、Prisma 模型与自动 CRUD 的联通逻辑很多同学费劲琢磨“怎么让全栈类型安全跑通”其实链条就三段Prisma 模型生成客户端 → zod schema 负责输入输出校验 → tRPC 负责把类型安全推到 React 端。t3code 在这条链上做了不少自动化核心是我在 db 包和 validators 包里放的两个小型代码生成器。3.1 从 Prisma schema 到 Zod schema如果只靠 Prisma前端只能拿到数据库字段的类型可无法对“创建数据时哪些字段必填、哪些有默认值、哪些有长度限制”做到天然推导。数据库约束要传给表单得走 zod schema。t3code 的做法是你写 Prisma 模型时用注释或 mock 原生属性携带校验信息。举个例子model Product { id String id default(cuid()) name String db.VarChar(100) price Decimal db.Decimal(10, 2) createdAt DateTime default(now()) map(created_at) updatedAt DateTime updatedAt map(products) }生成器解析模型时检测到name是String且字段长度 100会生成// packages/validators/src/product.schema.ts import { z } from zod; export const productCreateSchema z.object({ name: z.string().min(1).max(100), price: z.coerce.number().positive(), }); export type ProductCreateInput z.infertypeof productCreateSchema;这里有个细节Decimal类型在 zod 里常映射为z.coerce.number()。如果你直接把 Prisma 的Decimal传到前端会得到一个 Decimal 实例序列化时容易出现非预期格式。t3code 默认通过coerce.number()转成 JavaScript number并在 tRPC 的 output validator 里再次做一次Prisma.Decimal转换确保存进数据库的类型仍是 Decimal用户体验则是普通数字。3.2 context 和 router 的接线方式tRPC 的 context 是连接认证信息、数据库实例和请求元数据的枢纽。t3code 生成的默认 context 长这样// apps/web/src/server/context.ts import { inferAsyncReturnType, initTRPC } from trpc/server; import { createServerSideHelpers } from trpc/react-query/server; import { getServerAuthSession } from t3code/auth; import { db } from t3code/db; import type { FetchCreateContextFnOptions } from trpc/server/adapters/fetch; export async function createContext(opts: FetchCreateContextFnOptions) { const session await getServerAuthSession(); return { db, session, req: opts.req, }; } export type Context inferAsyncReturnTypetypeof createContext;然后初始化 tRPC// apps/web/src/server/trpc.ts import { initTRPC, TRPCError } from trpc/server; import superjson from superjson; import { ZodError } from zod; import type { Context } from ./context; const t initTRPC.contextContext().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const router t.router; export const publicProcedure t.procedure;注意 errorFormatter 这一行我特意把 zod 校验错误格式化后塞进了shape.data这样前端拿到 400 响应时可以直接把字段级错误映射到表单控件上不用自己解析一整串 error message。接下来权限中间件就挂在 procedure 上export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, user: ctx.session.user } }); });执行t3code add crud product后路由文件会生成类似这样的骨架// apps/web/src/server/routers/product.ts import { router, publicProcedure, protectedProcedure } from ../trpc; import { productCreateSchema, productUpdateSchema } from t3code/validators; import { z } from zod; export const productRouter router({ list: publicProcedure .input(z.object({ page: z.number().default(1), keyword: z.string().optional() })) .query(async ({ ctx }) { return ctx.db.product.findMany({ ... }); }), create: protectedProcedure .input(productCreateSchema) .mutation(async ({ ctx, input }) { return ctx.db.product.create({ data: input }); }), });这样一来跳过的只是重复的模板代码但你没跳过的业务逻辑比如分页搜索、库存扣减、权限策略这些仍然完全由你控制。这也正是我不喜欢“全自动智能生成”类脚手架的原因——它们往往生成一个不可维护的魔法层出了问题你连日志都看不懂。3.3 前端类型推导的关键AppTypetRPC 的类型安全能不能打通前端关键点是 router 导出类型// apps/web/src/server/root.ts import { productRouter } from ./routers/product; import { router } from ./trpc; export const appRouter router({ product: productRouter, }); export type AppRouter typeof appRouter;随后在_app.tsx里会把AppRouter传给createTRPCReactimport { createTRPCReact } from trpc/react-query; import type { AppRouter } from ../server/root; export const trpc createTRPCReactAppRouter();这一步是很多人踩坑的重灾区。如果你把import type写成了普通的import也会导致前端打包体积膨胀甚至出现运行时循环依赖。t3code 生成的默认模板里所有类型引用都显式标注了import type就是想从一开始杜绝这个问题。4. 部署上线时真正要紧的几件事常规配置之外的工程细节4.1 多阶段 Docker 构建我见过太多项目在本地跑得好好的一进 Docker 就各种莫名其妙的 Prisma error。根子在于构建过程没有处理好 pnpm workspace 的依赖提升和 Prisma Client 的生成时机。t3code 模板自带一份多阶段 Dockerfile核心思路是先拷贝整个仓库并安装依赖然后在中间阶段跑prisma generateFROM node:20-alpine AS base RUN npm install -g pnpm9 WORKDIR /repo FROM base AS dependencies COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY apps ./apps COPY packages ./packages RUN pnpm install --frozen-lockfile FROM dependencies AS build COPY . . RUN pnpm --filter t3code/db exec prisma generate RUN pnpm --filter t3code/web run build这条路线看起来平平无奇但关键点是prisma generate要在next build之前执行且必须放在/repo根目录能读到 schema 的位置。如果你把prisma generate放在了初始安装阶段后面一旦改了 schema 但没改 package-lock构建缓存会继续用旧的 client线上查出一个并不存在的字段报错时你会有砸电脑的冲动。4.2 环境变量管理t3code 的.env.example统一记录了全项目必须的环境量。我最后把变量分成三组方便排查类别变量必填说明认证DATABASE_URL是数据库连接串生产环境禁止用 SQLite安全NEXTAUTH_SECRET是给 session token 签名的密钥不低于 32 字符第三方GITHUB_ID/GITHUB_SECRET看情况使用 OAuth 登录时必填不含则退化为邮箱登录构建NEXT_PUBLIC_API_URL否如果前后端分域部署才需要默认走相对路径有一句肺腑之言不要把NEXTAUTH_SECRET写死进构建参数。Docker 镜像只要构建过一次里面就永久存在这个环境变量镜像被推送、拉取、分享等于密钥已经泄露了。正确做法是运行时注入部署平台里配成环境变量而不是塞进.env.production文件里提交到 Git。4.3 部署形态选择T3 Stack 的默认形态是“前后端同一 Next.js 应用”这在一开始最省事也适合中小团队。但当你出现某个后台任务需要高内存、而前台页面只需要低配的情况时建议把 tRPC 独立成一个 API 服务。t3code 模板里apps/api正是为此准备的。它使用独立适配器// apps/api/src/index.ts import { createHTTPServer } from trpc/server/adapters/standalone; import { appRouter } from t3code/web/server/root; import { createContext } from t3code/web/server/context; const server createHTTPServer({ router: appRouter, createContext, }); server.listen(3001, 0.0.0.0);容器里只跑这个 API 服务前端 Next.js 里的 tRPC client 把url指向它即可。拆开之后有利有弊类型安全仍然有但你需要在前端服务器和 API 服务器之间处理好 CORS也需要引入一套统一的请求日志方案。我会建议团队在月活用户没到一定量级前先不要拆留在 monolith 里能省很多分布式调试的痛。5. 踩坑实录我在 t3code 修复并沉淀的六个典型问题工具从零到一的过程没那么顺利。下面这几条坑都是实打实遇到过而且排查链路值得复述一遍。5.1 Prisma Client 不刷新现象是我改完schema.prisma的模型字段回到 tRPC 路由里立刻使用新字段结果 TypeScript 不报错但运行时数据始终为空。排查链路先确认pnpm prisma generate确实执行成功接着发现 Prisma Client 生成到了packages/db/node_modules/.prisma而应用实际链接到的是根.pnpmstore 里的那份。问题在于 monorepo 下的 Prisma 默认输出路径没有指向正确位置。解决方式是在packages/db/prisma/schema.prisma里显式指定 outputgenerator client { provider prisma-client-js output ../generated/client }这样仓库里所有包都从t3code/db/generated/client导入同一个 client再也不会出现“生成成功但应用读的是旧副本”的问题。5.2 NextAuth v5 下 JWT 会话无法关联数据库用户现象OAuth 登录后 session 正常生成但 tRPC 的 protectedProcedure 里拿到的ctx.session.user.id总是null。排查链路打开 NextAuth 源码和数据库发现 session callback 里默认返回的session.user是 token 的内容而 token 里根本没有任何数据库主键。因为 NextAuth v5 的 JWT 策略不会自动去查 Prisma 里的 user 表。解决方式是在 t3code 生成的一致会话回调中用数据库查询补全 user// apps/web/src/pages/api/auth/[...nextauth].ts callbacks: { async session({ session, token }) { if (session.user) { const dbUser await db.user.findUnique({ where: { id: token.sub }, }); session.user.id dbUser?.id ?? token.sub!; session.user.role dbUser?.role ?? user; } return session; }, },如果没有这个回调你会发现所有后台接口登录后仍然返回 401即使浏览器里明明有 cookie。5.3 tRPC errorFormatter 吞掉 zod 细节现象前端表单提交时后端只返回“UNAUTHORIZED”或“INTERNAL_SERVER_ERROR”完全看不到具体是哪个字段没通过校验。排查链路翻看 tRPC 默认 errorFormatter发现 zod 错误放在error.data.zodError但如果你自己覆盖了errorFormatter却忘了把原来的 data 展开字段错误就会被丢掉。修复方式就是我最开始展示的那段errorFormatter代码必须手动flatten()后挂到shape.data上。这个细节看起来无聊但在实际团队协作中价值极高因为前端不用自己解一大串嵌套数组。5.4 Tailwind 主题变量被 purge 误删现象在页面里动态拼接 Tailwind 类名bg-${color}-500上线后发现按钮全部没有背景色。排查链路这其实是 Tailwind 的经典问题不是 t3code 特有的。JIT 模式只会保留源码里完整出现过的类名动态模板生成的类名不会被扫描到。解决方式是在tailwind.config.ts里把这些类名显式加入 safelistsafelist: [ { pattern: /bg-(red|green|blue|yellow)-(400|500)/ }, { pattern: /text-(red|green|blue|yellow)-(400|500)/ }, ],我还给 t3code 做了一个小约定业务代码里不建议动态拼类名而用语义化的 variant 变量例如variantdanger映射到 CSS 变量上。这个约定长期执行下来UI 视图和主题配置之间会保持一对一的关系排查起来极其省脑子。5.5 构建时 tRPC 类型递归过深导致 TS 内存溢出现象在大型数据模型上执行pnpm run buildTypeScript 直接报Type instantiation is excessively deep and possibly infinite。排查链路这通常出现在 tRPC router 类型递归引用和 zod schema 的自引用场景中比如分类树的children字段。TypeScript 在实例化AppRouter时会逐层展开递归深度一大就撑不住。解决方式在大 schema 上把自引用字段提取成type CategoryWithChildren Category { children: CategoryWithChildren[] }并显式用type而不是interface同时把tsconfig中的skipLibCheck打开。如果你在 t3code 项目里遇到类似问题优先检查的是能不能把 tRPC 输入 schema 简化而不是盲目调大内存。5.6 前端水合时报 serializable 错误现象通过 tRPC 从服务端取到的数据回传到客户端时Next.js 报“Only plain objects can be passed to Client Components”。排查链路这是因为 Date、Decimal、undefined这类非纯 JSON 数据没有经过 transformer 处理。Next.js 的数据流要求组件树里传递的数据必须可序列化。解决方式就是 t3code 默认模板里已经配置了superjson。你只需要确保两处一致创建 tRPC client 时传入transformer: superjson服务端 router 创建时也传入transformer: superjson。如果只配了一边类型检查不会帮你抓bug但线上会间歇性白屏。6. 处理大型业务模型时我建议你在 t3code 基础上做的三件扩展6.1 在 Prisma 模型注释里追加 UI 语义标注t3code 的生成器支持在 schema 注释里读取简单的语义标签比如label(商品名称)、hidden、placeholder(请输入商品名称)。这些标签会被解析到 Zod schema 的外层描述里让前端表单自动渲染对应的 label 和 placeholder。这样做的最大价值是数据库 schema 成了唯一的真实来源前端表单页面永远和它同步。一个模型写多了之后大概是这个风格model Supplier { id String id default(cuid()) /// label(供应商名称) placeholder(例如华东仓) name String /// label(联系方式) hidden phone String default() }6.2 给它加一层审计日志中间件t3code 默认的 protectedProcedure 只做登录校验但生产环境很多表需要知道“谁在什么时候改了数据”。我在团队内扩展了一个auditProcedure下层自动记录操作人、操作字段、旧值和新值写入一张独立的审计表。这个扩展完全不需要侵入业务代码只用在 router 上切换一个 procedure 就行export const auditProtectedProcedure protectedProcedure.use(({ ctx, next, path }) { return next({ ctx: { ...ctx, audit: { user: ctx.user, path, timestamp: new Date(), }, }, }); });6.3 把日志从 console.log 切换到结构化输出如果你打算独立部署apps/api我特别建议把console.log全部换成pino或winston并在生成器模板里内置一个logger.ts// packages/logger/src/index.ts import pino from pino; export const logger pino({ level: process.env.LOG_LEVEL ?? info, transport: process.env.NODE_ENV development ? { target: pino-pretty, options: { colorize: true } } : undefined, });这样一来错误上报平台可以直接接收 JSON 日志流请求链路里的path、userId、耗时都能被结构化检索。前端控制台日志在不影响体验的前提下保留少量console.info其余一律路由到日志服务。我在实际改业务代码时发现这类工具链的价值并不仅限在“生成代码”那一刻它更像是一个团队的技术共识文件。生成器维护得越好新人越容易在几个小时内就把项目全貌摸清楚也越少发生“这里为什么有个魔法常量”的问号。最后分享一个我沉淀到现在的习惯每次用 t3code 生成完一个新项目我都会跑一次从零开始的构建流程把一条疏通链路里所有需要手动调整的地方记到CHANGELOG.md。这样过了两三个月当工具本身更新时我能快速对照出哪些模板改动会影响旧项目而不是抱着“升级一下就行”的侥幸心理直接 push。工具是死的但是围绕工具长期打磨自己的工作流程才真正让人省心。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →