t3code:让 T3 Stack 全栈脚手架从最小可用到团队级完整

发布时间:2026/10/26 16:46:36
t3code:让 T3 Stack 全栈脚手架从最小可用到团队级完整 三周前一个朋友把业务代码塞给我看说准备用 Next.js 重写一个内部管理系统结果光搭脚手架就搭了两个周末。我问他为什么不直接用 t3code他说没听说过。于是我把仓库地址发过去他二十分钟后跑起来第一个带权限的接口。这就是我想聊的项目t3code。如果你平时做全栈开发可能会觉得脚手架这种东西大同小异无非是帮你生成几个文件。但实际上大部分脚手架只解决了能编译的问题没有帮你解决团队真的能用的问题。t3code 是我维护的一个开源全栈脚手架它的核心不是套一个漂亮的模板而是把 T3 Stack 生态里最常用的能力组装成一个有清晰目录、有权限骨架、有数据库设计、有部署脚本的起始仓库。本文不吹概念只讲这个项目为什么这样做以及我实际使用过程中踩过哪些坑。适合谁看如果你正在用 Next.js 做全栈项目或者你想知道 tRPC 和 Prisma 在真实项目里如何组合再或者你也在维护类似的团队脚手架这篇文章应该能给你一些参考。1. 为什么我会写一个叫 t3code 的脚手架1.1 脚手架很多缺的是什么其实社区里已经有 create-t3-app 这样非常成熟的初始化工具了它可以把 T3 Stack 基础配置给你拉起来选择要不要 Tailwind、要不要 Prisma、要不要 NextAuth然后生成一个干净的项目。说实话对于个人玩具项目create-t3-app 完全够用。但放到真实业务里你会发现刚初始化完还缺一堆东西用户、角色、权限这些表没有而几乎所有业务系统都需要它们。没有审计日志和操作流水排查问题的时候两眼一抹黑。没有路由级别的权限中间件前端按钮是隐藏了但接口谁都能调。没有统一的错误处理和响应格式后端报错的时候前端完全不知道发生了什么。没有 Docker Compose 和部署脚本本地能跑上了服务器就各种缺环境。没有种子数据新同事拉完代码连一个能登录的账号都没有。这些短板不是 create-t3-app 的错因为它的定位就是最小可用初始化而不是团队级骨架。t3code 想补的正是这块在保留 create-t3-app 干净底子的前提下把业务系统里高频出现的通用模块提前放进去。我称之为起步即完整而不是起步即最小。1.2 t3code 的目标边界不做什么写脚手架最怕的是越写越复杂最后变成一个大家都不想看懂的巨型框架。所以 t3code 给自己定了非常清楚的边界不做低代码平台。它不提供可视化配置界面也不会读数据库自动生成整站业务逻辑。不试图替代业务代码。用户模块、权限模块是通用的但订单、库存、账单这些业务模块必须自己写。不绑定任何云厂商。部署脚本提供 Docker Compose 和通用的 systemd 模板云厂商相关配置一律不写死。不搞黑魔法式的代码生成。所有生成出来的代码都是普通 TypeScript团队里任何一个成员都能读得懂、改得动。我把这个边界写进了项目的 README 开头用加粗字标出来t3code 是骨架不是框架更不是业务平台。它能帮你省掉重复劳动但不会替你思考业务。下面用一个表格快速对比普通初始化和 t3code 的差异能力普通初始化模板t3codeT3 Stack 基础工程有有用户/角色/权限表结构没有内置登录与 session 流程可选内置含 NextAuth接口权限中间件没有内置可插拔审计/操作日志没有内置基础版统一错误响应没有内置种子数据脚本没有内置Docker Compose 本地环境没有内置Turborepo 多包管理没有自带这个对比不是我拍脑袋定的而是从过去一年多个内部项目里统计出来的重复代码清单。如果你也在维护团队脚手架我建议先把自己项目的通用需求列一遍再决定骨架里放什么而不是看什么火就塞什么。2. 技术底座T3 Stack 四件套和 t3code 的取舍2.1 这一代 T3 Stack 为什么值得作为底座T3 Stack 通常指 Next.js、tRPC、Tailwind CSS、TypeScript 的组合。很多人第一次听到会觉得这是个新框架其实它只是把几个各自优秀的工具按官方推荐的方式组合在一起。t3code 选择它作为底座核心原因有三个第一Next.js 是现在全栈应用里最顺手的框架之一。App Router 之下的 Server Component、Server Actions 和 Route Handler 可以覆盖大部分前后端需求。它可以让一个项目同时承担前台页面、后台接口和 SSR 渲染省去了单独维护一个前端项目和一个后端项目的烦恼。第二tRPC 解决的是前后端接口的类型一致性问题。简单说后端定义了一个createUser的 mutation前端调用的时候入参类型和返回类型都是自动推导出来的。少了一堆手写 interface也少了一大堆基于字符串的 API 调用。而且 tRPC 不是全有或全无的方案你可以只在某个模块里用它其他模块继续用 REST。第三Tailwind 和 TypeScript 是提高团队效率的基础设施。Tailwind 让样式代码变得几乎没有上下文成本TypeScript 让重构变得可预期。选型这件事最怕的是团队内反复摇摆T3 Stack 的官方文档已经把组合理由写得足够清楚所以我直接站在这套组合上继续往上搭。2.2 Turborepo 与 pnpm 的目录结构单一 Next.js 项目当然也可以跑但 t3code 的业务目标里包含用户模块复用数据库客户端复用校验规则复用如果全都往 apps/web 里塞后面会越来越乱。所以我选择了 Turborepo 配合 pnpm workspace 的 monorepo 结构。先看整体目录t3code/ apps/ web/ # Next.js 应用包含页面和 tRPC API packages/ db/ # Prisma schema、client、migration auth/ # NextAuth 配置、session 工具 validators/ # zod 校验规则与共享类型 config/ # eslint、tsconfig 公共配置 docker-compose.yml # 本地依赖postgres、redis turbo.json pnpm-workspace.yaml package.json目录结构决定了协作方式。我把 Prisma 放在packages/db而不是放在apps/web/prisma下原因很简单未来如果业务里多出一个管理后台或者其他 Node 服务大家共用的应该是同一个数据库 schema 定义和同一个 Prisma Client。把 db 独立成包可以让整个仓库的依赖方向更清晰apps/web依赖packages/db和packages/auth。packages/auth依赖packages/db。packages/validators不依赖任何内部包保持纯粹。这样的依赖关系让 Turborepo 可以精确感知任务缓存。比如你只改了packages/validators里的一个 schemaTurborepo 就不会重新构建packages/db只会让依赖它的任务失效并重新执行。对于小团队来说这个加速可能感知不明显但它有一个更大的好处每个包的职责边界是肉眼可见的不会出现这个函数该放哪的争论。2.3 环境变量配置monorepo 里最容易乱的地方Monorepo 的坑往往不在代码而在环境变量。刚开始我直接把所有环境变量写在apps/web/.env里结果发现packages/db里的 Prisma Client 读不到DATABASE_URLpackages/auth读不到NEXTAUTH_SECRET排查了很久才发现 Turborepo 默认不会把所有环境变量都透传给子包。t3code 的解决方案是统一的 root.env加上 turbo.json 的显式声明{ globalEnv: [ DATABASE_URL, NEXTAUTH_SECRET, NEXTAUTH_URL, REDIS_URL, SMTP_HOST, SMTP_PORT ] }globalEnv数组会把环境变量注入到所有 package 的任务里。这样packages/db里的 Prisma Client 也能拿到DATABASE_URL而不用在每个子包单独建.env。另外一个经验是不要把.env.example写得和.env完全一样。我会在.env.example里故意写一些非法占位值比如DATABASE_URLpostgresql://change_me:change_melocalhost:5432/t3code这样谁忘了复制配置程序会立刻报错而不会用默认配置在后面跑出一个诡异状态。3. 从零跑通一个 t3code 实例3.1 初始化流程与验证如果你拿到 t3code 仓库想在一台干净机器上跑起来推荐按下面这个顺序执行git clone gitgithub.com:yourname/t3code.git cd t3code pnpm install cp .env.example .env docker compose up -d pnpm db:generate pnpm db:migrate pnpm db:seed pnpm dev很多人会直接跑pnpm dev然后发现页面能打开但登录不了因为数据库还没建表、种子数据也没写入。所以我习惯把流程拆成三件事先把依赖装好再把中间件启动最后做数据库初始化。pnpm db:migrate执行的是 Prisma Migration它会按照packages/db/prisma/schema.prisma中的定义创建表。pnpm db:seed会执行 packages/db 下的 seed 脚本生成一个管理员账号、几个普通用户、基础角色和菜单权限。验证是否成功有一个很直接的标志启动后访问登录页用 seed 脚本里打印出来的管理员邮箱和密码能登进去能看到用户管理菜单。3.2 用户、组织和权限三张表怎么放进骨架在真实业务里用户权限通常不是一张User表加一个role字段那么简单。t3code 的 schema 里预置了四张核心表User、Role、Permission和UserRole。其中UserRole是多对多关联表这样设计的好处是一个用户可以有多个角色一个角色也可以挂多个用户。Prisma schema 的核心片段大概长这样model User { id String id default(cuid()) email String unique name String? passwordHash String? createdAt DateTime default(now()) roles UserRole[] } model Role { id String id default(cuid()) code String unique name String users UserRole[] permissions Permission[] } model Permission { id String id default(cuid()) code String unique roles Role[] } model UserRole { userId String roleId String user User relation(fields: [userId], references: [id]) role Role relation(fields: [roleId], references: [id]) }这个设计并不复杂但足够支撑绝大多数后台系统的权限判断。你在写业务代码时只需要通过ctx.session.user拿当前用户再去查他的角色是否包含某个权限码就能完成接口级鉴权。不用每次都在业务控制器里写一堆 if else。3.3 第一个受保护接口从数据库到前端全通跑通接口是验证骨架的最好方式。举个例子现在我打算做一个POST /api/trpc/order.create的接口只有登录用户才能下单。t3code 的目录约定是业务模块先建 router再建 service不要把所有逻辑都塞进 router 文件。先在packages/validators里定义下单的校验规则import { z } from zod; export const createOrderSchema z.object({ productId: z.string().min(1), quantity: z.number().int().positive(), remark: z.string().max(200).optional(), }); export type CreateOrderInput z.infertypeof createOrderSchema;然后在 apps/web 的 tRPC 根路由上新增 order 模块import { createRouter, protectedProcedure } from ../trpc; import { createOrderSchema } from t3code/validators; import { createOrder } from ../services/order.service; export const orderRouter createRouter({ create: protectedProcedure .input(createOrderSchema) .mutation(async ({ ctx, input }) { return createOrder(ctx, input); }), });此时前端组件里可以直接这样调用const createOrder trpc.order.create.useMutation(); const handleSubmit async () { await createOrder.mutateAsync({ productId: prod_1, quantity: 2, }); };你会注意到调用端不需要再单独写一遍类型定义。productId、quantity这些字段类型全部由 tRPC 在后端自动推导出来。如果你在createOrderSchema里把quantity改成string前端组件里马上会报类型错误。这就是 t3code 最核心的开发体验改后端校验前端编译期立刻给反馈。4. 类型安全链路t3code 最值钱的部分4.1 从 Prisma Client 到 tRPC Procedure 再到组件很多脚手架也号称有类型安全但真正把类型安全做成一整条链路的并不多。t3code 的链路是这样的数据库的表结构由 Prisma Schema 定义prisma generate会生成类型严格的 Prisma Client。tRPC Procedure 的 input 通过 zod schema 定义而 zod schema 可以z.infer出 TypeScript 类型。tRPC router 的返回值也会被 tRPC 自动推断。最后前端trpc.xxx.useQuery/useMutation会获取这些类型到组件里。链路的起点是数据库。这意味着如果某个字段在数据库模型里是String它就会从 Prisma Client 一路传到前端的组件 props。这个过程中没有任何手写的interface也就不会出现后端改了前端忘了同步的经典问题。用生活中的例子类比这就像你在一家餐厅订餐从前台点单、后厨做菜、收银结账都走同一张电子工单。你不需要把菜品名称反复抄写三遍因为抄写本身就会引入错误。tRPC 就是那张自动同步的工单。4.2 权限中间件为什么放在这一层权限判断可以放在页面组件里也可以放在 API 路由里但 t3code 选择放在 tRPC middleware 里。核心逻辑是让权限控制尽可能接近业务入口而不是散落在各个业务代码里。看下protectedProcedure的定义import { initTRPC, TRPCError } from trpc/server; const t initTRPC.contextContext().create(); const isAuthenticated t.middleware(async ({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { user: ctx.session.user } }); }); export const protectedProcedure t.procedure.use(isAuthenticated); export const createRouter t.router;这样写之后一个 procedure 只要声明自己是protectedProcedure它天然就有必须登录的约束。如果在某个业务里还需要角色校验可以再叠加一个hasPermission(order:create)的中间件。比如订单创建需要对应权限码时只需把 procedure 写成create: protectedProcedure .use(hasPermission(order:create)) .input(createOrderSchema) .mutation(...)这样的好处是权限规则和业务逻辑分开了。业务代码里看不到一堆 if 判断权限调整也不会碰业务逻辑。我见过很多团队把权限判断写进 service 层结果内容越来越多后来改一次权限要翻好几个文件。中间件机制则把这个问题收敛到了一处。4.3 退回 REST 后类型安全是怎么丢的理解了 tRPC 的类型安全就会明白为什么我不建议在同一个 Next.js 项目里轻易退回手写 REST API。假设你创建了一个/api/order接口export async function POST(req: NextRequest) { const body await req.json(); // body 是 any return NextResponse.json({ id: 1 }); }前端调用时你拿到的响应也只能手工声明类型const res await fetch(/api/order); const data (await res.json()) as Order;问题在于这个Order类型和数据库模型之间没有自动绑定服务端完全可以返回一个没有id的对象但前端类型却认为它一定有。你失去了编译期保护只能靠运行时测试发现问题。t3code 并不是排斥所有 REST但既然已经有 tRPC 这样的解决方案重复造一套手写接口的轮子对团队来说就是纯成本。5. 实际接入时踩过的四个坑5.1 数据不刷新Next.js 服务端缓存喝退第一次在 t3code 里做一个后台报表页面时我遇到一个诡异的现象数据库里的订单数据明明更新了但页面上显示的还是旧数据。浏览器刷新没用重新构建也没用最后发现是 Next.js 的 fetch 缓存问题。排查链路是这样的我在 Server Component 里用原生fetch调用了自己站点的 tRPC HTTP 端点而 Next.js 默认会对没有明确缓存策略的 fetch 做静态缓存。于是这个请求的响应在整个编译生命周期内都被视为可复用后续用户访问时直接返回了缓存结果。解决办法有两个。最直接的是在 fetch 时标注缓存策略const res await fetch(/api/trpc/order.list, { cache: no-store, });更推荐的方式是在服务端不通过 HTTP 请求自己而是直接使用 tRPC server caller。t3code 里专门封装了一个serverApi从 Next.js 服务端调用 router这样既绕开了网络开销也不会误踩框架缓存。这个问题排查花了我一个下午核心教训是在 Next.js 服务端访问自己的接口一定要清楚默认缓存策略不要以为同源调用就没有缓存。5.2 P1001 与连接数打满PrismaClient 实例管理第二次踩坑发生在开发环境。某天开始打开一个页面偶尔会报P1001: Cant reach database server但数据库容器明明在跑。一开始我以为是 Docker 网络问题反复重启容器无果。后来把错误日志打开看发现真正的问题不是连不上数据库而是连接数被占满了。原因说起来很常见Next.js 开发模式下热更新会反复执行模块代码如果每次执行都new PrismaClient()连接池很快就爆了。t3code 在packages/db里已经用了官方推荐的全局单例模式const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }但有一个团队同学接入时在自己的业务目录里又新建了一个prisma.ts并且没有使用这个单例结果就是连接数异常。后来我把packages/db的出口收敛成只有db这一个对象并在代码审查时明确要求业务模块禁止直接 import PrismaClient。这种基建类包的入口越窄团队踩坑概率越小。5.3 SSR 下 tRPC 并行请求被拖慢第三个坑是在做列表页 SSR 时发现的。页面里同时用了三个useQuery去拿统计数据、列表数据和用户信息。本来以为 tRPC 会自动合并请求结果发现服务端渲染时请求并不是一次发起而是逐个发过来响应时间翻了将近三倍。排查到最后发现原因在httpLink和httpBatchLink的差别。tRPC 默认推荐使用httpBatchLink它会把同一个事件循环 tick 里的多个操作合并成一个 HTTP 请求。但如果配置成了httpLink每个 query 都是独立请求。t3code 初始化时就应该用httpBatchLinkexport const trpcClient createTRPCNextAppRouter({ config() { return { links: [ httpBatchLink({ url: ${getBaseUrl()}/api/trpc, }), ], }; }, });如果做了合并仍然慢还可以考虑httpBatchStreamLink它能以流式方式返回合并结果避免等待最慢的一个请求。对于 SSR 场景这个优化效果非常明显。5.4 依赖版本漂移Monorepo 的隐性炸弹最后一个坑和 pnpm workspace 有关。某天另一个同事反馈pnpm dev可以跑但是生产环境构建出来的样式乱了。我怀疑是 Tailwind 版本不一致。用pnpm why tailwindcss一查发现根目录package.json里锁的是tailwindcss3.4.x而apps/web自己的依赖里出现了tailwindcss3.2.x构建时两个版本都被装进了 node_modules。这种依赖漂移在 monorepo 里非常隐蔽因为它不影响开发编译只在特定构建条件下暴露。处理办法是用 pnpmoverrides强制统一关键依赖版本{ pnpm: { overrides: { tailwindcss: 3.4.10 } } }另外在pnpm-workspace.yaml里建议显式列出要拦截的包比如packages/db和apps/web。这样后续新增业务包时不会被 pnpm 的默认 pattern 悄悄包含进去依赖关系可预测很多。6. 把 t3code 用在团队协作的几个落地建议6.1 seed 脚本是团队的第二文档t3code 里我最满意的功能其实是 seed 脚本。它看起来平淡无奇但对团队协作的帮助非常大。新同事入职第一天不需要翻十几页的内部 wiki只要执行pnpm db:seed就能得到一套包含管理员、普通用户、角色权限和示例数据的数据库。这个脚本必须是可重复执行的也就是幂等的。实际写法上我习惯在 seed 里先按唯一约束 upsert而不是直接 createconst adminRole await prisma.role.upsert({ where: { code: admin }, update: {}, create: { code: admin, name: 管理员 }, }); await prisma.user.upsert({ where: { email: adminexample.com }, update: {}, create: { email: adminexample.com, passwordHash: hashSync(admin123, 10), roles: { create: [{ roleId: adminRole.id }] }, }, });这样反复跑也不会报唯一约束错误。很多项目里的 mock 数据是一堆临时生成的文件用完就删但 t3code 的做法是把种子数据当成团队共享资产来维护谁改了接口字段谁就同步更新 seed 里的示例数据。6.2 约定式提交与代码生成t3code 本身不强制 commit message 规范但我建议使用它的团队把提交约定固定下来。原因不是仪式感而是 monorepo 里改动可能分散在 packages 和 apps 多个目录如果没有清晰的提交前缀git log 基本没法看。我自己会要求团队的提交信息至少带包名前缀例如feat(db): 新增订单状态变更记录表 fix(web): 修复订单列表在 SSR 下数据不刷新 chore(validators): 调整 createOrderSchema 的字段上限这样配合 Turborepo 的远程缓存时可以快速定位一次构建失败是由哪个包引起的。另外如果你们业务量大可以考虑做一个代码生成脚本扫描packages/db/prisma/schema.prisma里的 model自动生成基础的 tRPC CRUD procedure 文件和对应 zod schema。t3code 提供了一个最小实现示例它不是万能生成器但能把八成的重复样板工作干掉剩下需要你手工处理的只是那些真正有价值的业务逻辑。6.3 扩展 t3code 时我优先看的方向如果你也想在自己项目里借鉴 t3code我最推荐优先扩展的方向是权限系统而不是继续堆组件库或 UI 库。原因很简单权限系统一旦在早期没设计好后面每个业务模块都要为它买单。把角色、权限码、中间件拆分清楚后新业务接入成本会低很多。第二个值得扩展的是可观测性。t3code 现在内置了很基础的日志和错误响应但真实生产环境还需要接入外部日志采集、慢查询记录和错误告警。这部分我没有强行放进骨架因为各家团队用的基础设施差别太大。但我通常会在项目里留好logger的抽象层让团队按需实现。我个人这几年维护 t3code 最大的收获是发现脚手架不是越复杂越好而是把约定写得足够简单让别人拿到手之后不用猜。如果你也在做类似的模板先把权限中间件和种子数据这两块啃下来后面所有业务模块都能直接长在这个骨架上。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询