做了三年以上的全栈项目,有一阵子我特别害怕“再开一个新项目”。不是怕写业务代码,是怕从零初始化那套基础设施:建仓库、配 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 t3code@latest 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自动在更新时维护。
这里有件值得强调的事:生成器的输出一定是可读且可改的,绝不是一个黑盒。如果你用 Ctrl+C 取消命令,它不会产生半成品文件;每次生成前会计算文件差异,只覆盖我标记为“可自动编辑”的文件,而那些真正写业务逻辑的例如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.infer<typeof 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 = inferAsyncReturnType<typeof 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.context<Context>().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 前端类型推导的关键:AppType
tRPC 的类型安全能不能打通前端,关键点是 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传给createTRPCReact:
import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "../server/root"; export const trpc = createTRPCReact<AppRouter>();这一步是很多人踩坑的重灾区。如果你把import type写成了普通的import,也会导致前端打包体积膨胀,甚至出现运行时循环依赖。t3code 生成的默认模板里所有类型引用都显式标注了import type,就是想从一开始杜绝这个问题。
4. 部署上线时真正要紧的几件事:常规配置之外的工程细节
4.1 多阶段 Docker 构建
我见过太多项目在本地跑得好好的,一进 Docker 就各种莫名其妙的 Prisma error。根子在于构建过程没有处理好 pnpm workspace 的依赖提升和 Prisma Client 的生成时机。t3code 模板自带一份多阶段 Dockerfile,核心思路是先拷贝整个仓库并安装依赖,然后在中间阶段跑prisma generate:
FROM node:20-alpine AS base RUN npm install -g pnpm@9 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里显式指定 output:
generator 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里把这些类名显式加入 safelist:
safelist: [ { pattern: /bg-(red|green|blue|yellow)-(400|500)/ }, { pattern: /text-(red|green|blue|yellow)-(400|500)/ }, ],我还给 t3code 做了一个小约定:业务代码里不建议动态拼类名,而用语义化的 variant 变量,例如variant="danger"映射到 CSS 变量上。这个约定长期执行下来,UI 视图和主题配置之间会保持一对一的关系,排查起来极其省脑子。
5.5 构建时 tRPC 类型递归过深导致 TS 内存溢出
现象:在大型数据模型上执行pnpm run build,TypeScript 直接报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。工具是死的,但是围绕工具长期打磨自己的工作流程,才真正让人省心。