如果你在搜 t3code 搜到我这里,那我猜你多半和我一样,正打算用 T3 Stack 这种“全家桶”路线,又不想从零把基建再铺一遍。t3code 是我基于 T3 Stack(TypeScript、Tailwind、tRPC、Next.js)沉淀下来的一套工程化项目模板,它不负责帮你生成业务代码,而负责把代码从“能跑”推到“能长期维护、能多人协作、能一键部署”的状态。这篇文章我会讲清楚,为什么我放着 create-t3-app 不用、非要自己折腾出 t3code,包括目录结构怎么拆、工程化配置怎么焊死、Docker 部署踩了哪些坑,以及最后我对模板这条路的真实建议。适合那些正准备做全栈项目,或者已经用了 T3 全家桶但还是觉得配置很乱、分支风格各异的开发者。
1. 为什么在 create-t3-app 之上还要折腾出 t3code
1.1 create-t3-app 解决的,和没解决的
create-t3-app 这个脚手架的最大价值,是把 Next.js、Tailwind、Prisma、tRPC、NextAuth 这几个库在初始化阶段就帮你串好。它确实省掉了第一天的整合成本,跑起来以后你也能很快看到一条完整的请求链路:页面里调用 tRPC,服务端查数据库,再通过 NextAuth 拿用户身份。
但等你真把它当成团队项目基座,问题就开始冒出来了。
第一,它默认生成的目录是极简的“单应用”结构,所有代码都堆在一个文件夹里。业务一旦超过一万行,你会发现路由、组件、服务端逻辑搅在一起,今天改一个用户的模块,明天不得不在三个目录里来回跳。第二,它没有统一的 lint、format、commit 规范。虽然模板里自带了 eslint-config-next,但多人协作时每个人本地装的编辑器插件版本不同,格式化结果就不同,PR 里永远飘着一堆无关紧要的空格改动。第三,它默认不提供 CI、Dockerfile、环境变量校验这些东西。你确实可以后续自己补,但补的过程就是重复造轮子,而且每个项目成员都会“自由发挥”地补一遍。
我刚开始用 create-t3-app 时也觉得“这个模板真香”,但两个项目跑下来以后,我发现真正阻碍效率的不是 Next.js 或 tRPC 本身,而是这些脚手架不管的“工程外围”。
1.2 t3code 的定位:不是脚手架,是团队基线
所以 t3code 的定位从一开始就不是 create-t3-app 的替代品,而是一个更偏“团队基线”的项目模板。
它预置的东西包括这样几层:
- pnpm workspace 构成的 monorepo,把前端、后端、数据库、UI 组件拆到独立包里。
- 统一的 tsconfig、eslint、prettier 配置,不靠编辑器插件各自为政。
- 环境变量统一用 zod 做运行时校验,缺了谁、格式错了谁,启动时直接报错,不等到线上才炸。
- tRPC router 按业务域拆文件,不写成一个几千行的 root router。
- Prisma 实例和 migration 脚本统一放在
packages/db,谁要改数据库都走同一个命令。 - 预置 GitHub Actions,PR 进来就自动跑 lint、typecheck、build。
- 提供多阶段构建的 Dockerfile,依赖安装、编译、运行三阶段分离。
这些能力单独看都很基础,但把它们作为一个“模板的默认值”固定下来,价值就出来了。新人加入团队以后,不需要再读一份几十页的 Wiki 来搞清楚“提交代码前要跑哪些命令”,直接照着模板来就行。
1.3 一段真实接入经历
我印象最深的一次,是给一个内部中后台项目迁到 t3code。
那个项目最早是用 create-t3-app 初始化的,跑了三个多月,代码量过了两万行。问题集中爆发在一次功能迭代:我们需要同时改动用户表结构、新增一个管理后台、还要给前端加一个数据看板。原本的目录下,Prisma schema 在项目根目录,tRPC router 在src/server/api/root.ts里,后台页面却要新建一套 layout。结果就是:数据库迁移脚本、服务端 API、后台页面互相纠缠,一个人改了 schema,另一个人 merge 时冲突能解一下午。
后来我花了两个周末把它重构成 t3code 的结构,把 Prisma 迁到packages/db,把 router 拆成 user、project、task 三个独立文件,再补上环境变量校验和 CI。迁移完成当天,我们直接启动了一个新项目,把packages里的东西原样引用过来,效率一下子不一样了。
2. t3code 的骨架设计:monorepo 里哪些代码该进包里
2.1 目录结构:apps、packages、tools 三层
t3code 的目录我直接给你看当前版本:
t3code/ ├── apps/ │ ├── web/ # Next.js 前端 + tRPC Server(App Router) │ └── admin/ # 后台管理端,可选 ├── packages/ │ ├── db/ # Prisma Client、schema、migration、seed │ ├── trpc/ # trpc context、router 定义、类型导出 │ ├── ui/ # 基础 UI 组件,目前以 React + Tailwind 为主 │ └── config/ # 共享的 eslint、prettier、tsconfig 配置 ├── tools/ │ └── scripts/ # 项目级脚本:数据库备份、依赖升级、清理缓存等 ├── package.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml为什么用 pnpm workspace 而不是更常见的 npm workspaces?主要原因是 pnpm 默认对依赖采取符号链接隔离,配合 monorepo 时不会出现“一个包能访问到另一个包的未声明依赖”这种隐性问题。pnpm 对磁盘占用也友好,几十个依赖包可以共享同一份 store。
为什么要把db单独拆出来?因为只要你的项目不只有一个 Next.js 应用,将来必然要面对这个问题。比如你有一个 web 端、一个后台、一个定时任务 worker,它们都要访问同一个数据库。如果每个应用里都放一份 Prisma Client,生成位置不同、版本不同,迟早会乱。放到packages/db后,所有应用通过import { prisma } from '@t3code/db'使用同一个 Client 实例。数据库结构变更也只需要在一处维护。
2.2 tRPC 路由拆分与 merge 的统一出口
T3 Stack 里最容易被“写烂”的地方就是 tRPC router。
很多项目会把所有接口都堆在一个root.ts里。几十个 procedure 挤在一起,看起来是“集中管理”,实际改起代码来相当痛苦。因为在 tRPC 里,每个 procedure 还带着自己的输入校验、权限校验和业务逻辑,文件太大以后,你根本分不清哪个输入属于哪个模块。
t3code 的做法是按业务域拆成独立 router,再在根节点统一聚合:
// packages/trpc/src/routers/user.ts import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; import { z } from 'zod'; export const userRouter = createTRPCRouter({ me: protectedProcedure.query(async ({ ctx }) => { return ctx.prisma.user.findUnique({ where: { id: ctx.session.user.id }, }); }), update: protectedProcedure .input(z.object({ name: z.string().min(1).max(50) })) .mutation(async ({ ctx, input }) => { return ctx.prisma.user.update({ where: { id: ctx.session.user.id }, data: { name: input.name }, }); }), });// packages/trpc/src/root.ts import { userRouter } from './routers/user'; import { projectRouter } from './routers/project'; import { taskRouter } from './routers/task'; import { createTRPCRouter } from './trpc'; export const appRouter = createTRPCRouter({ user: userRouter, project: projectRouter, task: taskRouter, }); export type AppRouter = typeof appRouter;这样在前端写 client 时,路径是trpc.user.me、trpc.project.list,结构非常清晰。更重要的是,多人协作时每个人可以各自维护一个 router 文件,合并冲突的概率大幅下降。
还有一个小细节:t3code 会把protectedProcedure和publicProcedure统一放在packages/trpc/src/trpc.ts里,避免每个 router 文件里重复定义 middleware 和 session 判断。这样将来如果要从 NextAuth 换成别的鉴权方式,只需要改这一个文件。
2.3 数据库包 Prisma 与迁移的目录归属
packages/db内部的布局是这样:
packages/db/ ├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── src/ │ ├── client.ts │ └── seed.ts └── package.json我建议 Prisma schema 不要放在应用根目录,而是放在独立的 db 包里。原因很简单:schema 是“基础设施”,不是“页面功能”。它在多应用场景下属于公共数据定义,所有应用都应该依赖它,而不是反过来让某个具体应用充当它的宿主。
在 monorepo 里,Prisma Client 生成的路径要特别注意。Keep 默认的prisma-client-jsgenerator 即可,但不要在schema.prisma里手工指定output到某个应用目录,否则每次prisma generate都会往别的包里写文件,版本一多就出现诡异提示。直接把client.ts作为唯一导出口:
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', 'error', 'warn'] : ['error'], }); if (process.env.NODE_ENV !== 'production') { globalForPrisma.prisma = prisma; }使用命令的时候,在 t3code 根目录统一提供脚本:
pnpm --filter @t3code/db prisma:migrate pnpm --filter @t3code/web dev这样任何团队成员都不需要关心“Prisma 的配置文件在哪”,一个命令就够了。
3. 工程化规范:t3code 默认给你焊死的细节
3.1 env 用 zod 校验,别再用 process.env 裸跑
我见过太多线上事故是环境变量造成的:DATABASE_URL少一个字符、NEXTAUTH_SECRET长度不够、某台服务器漏配了变量,结果服务启动时表现正常,一旦有用户访问就走异常路径。
t3code 用 zod 对所有环境变量做运行时校验。核心思路是这样的:
// packages/config/src/env.ts import { z } from 'zod'; const envSchema = z.object({ DATABASE_URL: z.string().min(1), NEXTAUTH_SECRET: z.string().min(32), NEXTAUTH_URL: z.string().url().optional(), PUBLIC_APP_URL: z.string().url(), }); const env = envSchema.parse(process.env); export { env };这里我刻意用了parse而不是safeParse,就是为了让应用在启动阶段直接失败。很多人怕“崩”,但环境变量缺了本来就应该快速失败,而不是等用户踩雷。开发环境下,zod 报错信息会清楚告诉你哪个字段有问题,省掉一晚上排查。
需要注意:带NEXT_PUBLIC_前缀的变量会暴露在浏览器 bundle 里,所以绝不能放密钥。我的习惯是任何密钥类变量都不加NEXT_PUBLIC_,前端需要用的公开配置(比如 CDN 域名)才加。t3code 的env.ts只服务端引用,确保不进入客户端代码。
3.2 lint、pre-commit、CI 的三层过滤
代码规范的维护不能靠自觉,要靠工具链。t3code 设计了三条防线:
- 本地:
pnpm lint和pnpm typecheck,随时可跑。 - pre-commit:用 husky + lint-staged,只对暂存区文件做 eslint 和 prettier 检查,速度快。
- CI:每次 PR 推送到 GitHub 时,跑全量 lint、typecheck、build。
lint-staged 的配置大致如下:
{ "*.{js,jsx,ts,tsx}": [ "eslint --fix", "prettier --write" ], "*.{json,md,yml}": [ "prettier --write" ] }最容易被忽略的是 commit message 规范。我见过团队里 commit message 写得五花八门,等要出 changelog 或做版本回溯的时候,git log 就是一锅粥。t3code 直接内置 commitlint,要求 commit 必须按conventional commits格式:
feat(web): 增加用户列表页 fix(trpc): 修复 project.list 返回空数据问题 chore(db): 迁移用户表增加手机号字段不建议一开始就上很多重工具,先做这三层就够把“脏代码”挡在主线之外了。等团队人数超过十个人以后再考虑接编排系统,也不晚。
3.3 Dockerfile 里最容易翻车的三个点
Docker 部署是我自己踩得最狠的一块。这里先把经验给你:
第一,安装依赖时一定要用pnpm install --frozen-lockfile,不要用pnpm install。前者会严格按 lockfile 版本走,保证测试环境和生产环境依赖一致。pnpm install可能会因为 lockfile 被更新而悄悄装出不同版本,到时候很难排查。
第二,Next.js 如果想用 Docker 减小镜像体积,必须在next.config.js里开启output: 'standalone'。只有 standalone 模式会生成一个包含最小依赖的服务端目录,否则你要从node_modules里手动拷一堆包,很容易漏。开启之后,Dockerfile 用多阶段构建:
FROM node:20-alpine AS builder RUN corepack enable WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY apps/web/package.json apps/web/ COPY packages/db/package.json packages/db/ COPY packages/trpc/package.json packages/trpc/ COPY packages/config/package.json packages/config/ RUN pnpm install --frozen-lockfile COPY . . RUN pnpm --filter @t3code/web build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/apps/web/.next/standalone ./ COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static COPY --from=builder /app/packages/db/prisma ./prisma EXPOSE 3000 ENV HOSTNAME=0.0.0.0 ENV PORT=3000 CMD ["node", "apps/web/server.js"]第三,Prisma 比较特殊。prisma generate会在node_modules/.prisma/client下生成引擎文件,standalone 模式默认不会把这些文件拷进镜像。所以上面 Dockerfile 里单独把packages/db/prisma目录复制进去,同时在启动命令里确保DATABASE_URL能被正常读到。如果你用了独立 Prisma 引擎二进制,可能需要额外设置PRISMA_ENGINES_MIRROR或者把引擎文件打进镜像,这部分需根据部署平台调整。
4. 部署到生产:我在 t3code 上线过程中踩过的坑
4.1 Next.js standalone 与静态导出的边界
我在第一次用output: 'standalone'时,有个认知偏差:以为开启 standalone 后其他静态资源也会自动包含。实际不是。standalone 目录里只有服务端运行所需的 JavaScript 文件,CSS、图片这些静态资源不会出现在里面。
所以 Dockerfile 里这一句必须写上:
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static同时还要在next.config.js里显式声明 standalone:
const nextConfig = { output: 'standalone', };如果你用了output: 'export',那就变成了纯静态站点,tRPC 和 NextAuth 这类需要 API 的能力就全部失效了。这是另一个极端。t3code 默认选择 standalone,保留 Next.js 的 API 路由和 tRPC 能力,只是在部署时要多复制一层静态文件,别漏。
4.2 Prisma 并发迁移和连接池
项目上到多实例以后,Prisma migration 很可能出事。
我之前有一次同时滚动更新了三个实例,结果每个实例在启动时都执行了一次prisma migrate deploy,数据库被多个进程同时写迁移表,直接报锁冲突。后来我把迁移做成独立步骤:在任何应用实例启动前,先跑一个单独的 job 执行:
pnpm --filter @t3code/db prisma:migrate应用实例里完全不做 migration,只负责读数据库。连接池方面,如果用的是 PostgreSQL,建议在连接串后面加上连接池参数,比如用 PgBouncer 时要配:
DATABASE_URL="postgresql://user:pass@host:5432/db?pgbouncer=true&connection_limit=5"但这里有个小坑:开启pgbouncer=true后,Prisma 的 prepared statements 会被禁用,某些查询性能会受影响。一般先不加这个参数,等连接压力真实出现了再做池化改造。t3code 模板默认使用 Prisma 默认连接池,并在部署文档里标清楚“如何切换”。
4.3 tRPC 的请求上下文在 serverless 下的踩坑
如果你把 t3code 部署到 serverless 平台(比如 Vercel、函数的容器实例),要注意 tRPC 的 context 创建频率。
最简单的错误是:每个请求都new PrismaClient()。在 serverless 里这几乎必然导致数据库连接数爆炸。所以我在packages/db里用globalThis做了 Prisma 单例缓存,应用运行时多个模块引用的是同一个实例。这个写法我在前面已经给过,它看起来有点“hack”,但确实是 serverless 场景下最稳妥的工程实践。
tRPC 还有另一个容易忽略的点:在 App Router 下,服务端调用 tRPC procedure 时,必须通过createTRPCContext手动传入正确的 headers。如果你直接把 Next.js 的headers()拿来做 session,要注意异步 API 顺序。一个常见做法是在每个 request 内部重新创建 context:
// packages/trpc/src/context.ts import { getServerSession } from 'next-auth'; import { authOptions } from '../auth'; import { prisma } from '@t3code/db'; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { prisma, session, ...opts, }; };在 tRPC 客户端使用httpBatchLink时,我建议把默认的请求超时调大一点,尤其是一些内部报表接口可能跑超过 10 秒。tRPC 本身没有服务端超时时间,但浏览器或网关会有,所以要用fetch的AbortSignal.timeout()显式控制。
httpBatchLink({ url: `${url}/api/trpc`, fetch: (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(15_000) }), });5. t3code 带来的取舍与我的真实建议
5.1 什么团队适合直接用模板
如果你面临的场景是这样的:
- 团队不止一个项目,需要统一的代码风格和依赖基线。
- 项目生命周期预计在半年以上,后续会有多人持续维护。
- 你不想花大量时间重复配置 Docker、CI、环境变量校验。
- 公司内部已经标准化了某个主技术栈,只差一个可复用的起点。
那么 t3code 这类“团队基线模板”非常合适。它最大的价值不是让你第一天爽,而是让你三个月后不痛苦。
5.2 什么时候你该放弃模板自己拼
反过来,我也踩过“模板太重”的坑。
如果你只是做一个一周内要上线的小活动页、一个临时原型,或者一个个人练手项目,直接套 t3code 反而难受。monorepo 的包拆分、CI 流程、commit 规范对单人或小原型都是负担。这种时候我建议直接用create-next-app,甚至手写一个简单目录就够了。
模板本质上是在团队协作成本和初始化速度之间做取舍。t3code 偏向“多项目、多人、长周期”的一侧。它不是银弹。
另外我想提一点:团队模板要保守。依赖版本不要一上来就追 latest,t3code 到目前为止仍把核心依赖锁在比较稳定的主版本范围里,每周固定时间升级一轮,升完马上跑 CI 和端到端测试。模板本身也需要持续维护,否则过几个月又会退化成一个“历史版本快照”。
根据我维护这一段时间的经验,下一步我打算给 t3code 补两块能力:一是内置 Vitest 和 Playwright 测试链路,让模板从“能跑”直接到“能测”;二是加一个 Turborepo 缓存,把 monorepo 的构建速度进一步拉起来。这两件事做完,整个模板的实用度应该又上一个台阶。
如果你也在维护自己的项目模板,我的核心建议只有一条:永远不要把团队私有配置硬编码进公开模板里。把配置拆到packages/config,用环境变量和 profile 区分环境,这样开源分支和内部分支可以共用同一个 core,不会改着改着就分叉。