做了快六年全栈开发,我最怕的不是业务复杂,而是每个新项目开头那几天:装依赖、配环境变量、声明接口类型、把上一套模板里用不上的库一个个卸载掉……这些活儿重复到让人怀疑自己是不是在流水线打工。去年我花了不少精力整理了一套自己的代码模板,名字就叫t3code,核心思路很简单:把 T3 Stack 里经得起实际项目检验的部分抽出来,封装成一套“搬过来就能跑”的样板工程。这篇文章会从为什么做它讲起,记录完整的初始化过程、一条业务功能从数据库到前端页面的真实链路,以及我在开发中踩过并解决掉的几个具体问题。如果你也在考虑用 T3 Stack 做全栈项目,或者只是好奇“端到端类型安全”到底怎么落地,这篇应该能给你一些参考。
1. 为什么我会亲手做 t3code:模板缺失的从来不是代码,而是“闭环”
先说一下 t3code 不是什么。它不是一个新框架,不是一个脚手架,也不是一个开源组织搞出来的重量级方案。它就是我维护的一套基于 T3 Stack 的工程模板,附带一小套示例业务代码——一个带用户、带发帖、带鉴权的完整最小系统。你把它拉下来,改几个环境变量,跑通,然后在这个框架里开始写你自己的业务。所有基础设施层面的东西,认证、数据库访问、接口定义、样式体系、环境变量校验,全部已经帮你摆好了。
1.1 每次开新项目都要重新布置一遍的“装修之苦”
做全栈的人应该都有这种感觉:业务代码本身往往不复杂,复杂的是“脚手架”阶段。你要先纠结用 Express 还是 Fastify,再纠结 ORM 选 Prisma 还是 Drizzle,然后还要考虑前端怎么调用接口,用什么方式管理请求状态。每一次组合都要重新踩一遍兼容性的坑。我做过不止一个项目,光把环境和工具链理顺就花了两三天,真正的业务代码一行还没写。这种重复建设让我越来越确定一件事:我需要一套固定的、经过验证的组合,新项目直接沿用,不讨论、不折腾。
1.2 t3code 要解决的核心问题:让类型安全打通“最后一公里”
我以前用 Next.js 写全栈项目,最常见的做法是pages/api下面建接口,然后前端用fetch去调。每次后端改了返回结构,前端要手动同步类型。运气好记得改,运气不好就会线上报错。我也用过 GraphQL,确实解决了类型生成的问题,但为了一个小项目引入整套 GraphQL 体系,总感觉有点重。t3code 的出发点就是抛弃这些“中间翻译层”,让后端代码直接变成前端可以调用的“函数”。我用api.post.getAll.useQuery()这种形式调用后端数据时,返回的类型是自动跟着走的——后端 Prisma 模型加了一个字段,前端这里立刻就能在 TypeScript 提示里看到。这就是我要的“闭环”。
1.3 市面上已有模板,为什么还要自己写
用create-t3-app初始化出来的项目其实已经很完整,但它是“通用版”。我想要的还差几件事:一是带一个真实可跑的示例业务,而不是只有/api/health这种空接口;二是把目录结构按业务域组织,routers下面按模块分文件,而不是把所有接口堆在一个文件里;三是给出部署环境下的完整说明,包括数据库连接池、环境变量清单这些真正会坑人的细节。所以 t3code 最终不是“又一个脚手架”,而是一份经过增删改查之后的“答案”,你拿过去能直接对照使用。
提示:如果你只是想要一个最基础的 T3 Stack 项目,直接用官方脚手架就行。t3code 更适合那些希望模板自带示例业务、自带部署说明、自带目录规范的开发者。
2. t3code 技术选型拆解:为什么偏偏是这一套组合拳
T3 Stack 这个名字里,“T3”并不是第三代的意思,而是三个以 T 开头的核心原则:TypeScript-first(类型优先)、Tailwind CSS(原子化样式)、tRPC(类型安全 API)。这三个东西是骨架,再配上 Prisma、NextAuth.js、Zod 作为血肉,构成了 t3code 的完整技术底座。
2.1 三个“T”的各自分工
TypeScript 是整个体系的根基,没有它,后面两个 T 都发挥不出价值。Tailwind CSS 解决的是样式组织问题——它的 Utility Class 理念和组件化开发天然贴合,不需要单独维护.css文件里的类名层级关系。tRPC 是这里面最特别的一个,它不依赖代码生成器,不依赖 GraphQL 那种复杂的 schema 定义,而是直接利用 TypeScript 的类型推导,把后端路由变成前端可调用的类型安全函数。这种做法的神奇之处在于:后端函数就是前端调用的“API”,类型在编译期间就被检查过了,不需要在运行时做任何额外验证步骤。
2.2 不是配菜,是刚需:Prisma、NextAuth 与 Zod 的定位
- Prisma负责数据库访问,它的 schema 文件同时是数据库模型和 TypeScript 类型的来源。
prisma generate之后,ctx.db.post.findMany()的返回类型是自动推导出来的,不需要手写 interface。 - NextAuth.js负责认证体系。t3code 里默认集成了 GitHub 登录,配置好
GITHUB_ID和GITHUB_SECRET就能跑通。Session 的类型注入到 tRPC context 里,后端能直接拿到登录用户信息。 - Zod负责参数校验。tRPC 的
input可以直接接收一个 Zod schema,前端传参在服务端和客户端两端同时被校验,类型和规则来自同一个定义,不存在“前端校验一套后端另一套”的分裂问题。
2.3 被 t3code 放弃的方案与理由
每个选型背后都对应一个被放弃的方案。t3code 里没选 Express 或 NestJS,因为 Next.js 的路由层已经足够承载 API 逻辑,没有必要再单独起一套服务端框架。没有选 Redux,因为 tRPC 的 query 自带缓存和重新获取机制,配合 React Query 已经完全覆盖了服务端状态管理的需求,Redux 那套 action/reducer 样板代码在这个场景里纯属多余。也没有选 REST 风格手写路由,因为手写意味着手工维护接口文档和类型定义,效率太低。这套组合最终收敛下来,开发体验最接近“写一个本地函数,然后直接调用它”。
3. 从空目录到第一条数据流:t3code 核心链路搭建实录
这一部分我完整走一遍流程。假设你已经装好了 Node.js(版本建议 18 以上)和 pnpm。我会按照 t3code 实际初始化时的操作来讲,尽量把每一步为什么这么做也说清楚。
3.1 初始化脚手架:选项怎么选
pnpm create t3-app@latest t3code交互式界面会让你勾选组件,t3code 的官方推荐选项是:
| 选项 | 是否启用 | 理由 |
|---|---|---|
| Next.js | 启用 | 全栈主体,负责页面渲染和 API 托管 |
| tRPC | 启用 | 类型安全的核心,前后端连接器 |
| Tailwind CSS | 启用 | 提供开箱即用的样式体系 |
| Prisma | 启用 | 数据库模型与类型同步 |
| NextAuth.js | 启用 | 给示例业务的用户系统做准备 |
| Drizzle | 关闭 | 和 Prisma 二选一,我选 Prisma |
路径别名@/* | 启用 | 简化 import 路径,避免阅读地狱 |
| ESLint / Prettier | 启用 | 团队协作的基本约束 |
初始化完成后,项目结构大概是这样的:
t3code/ ├── prisma/ │ └── schema.prisma # 数据库模型定义 ├── src/ │ ├── pages/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc].ts # tRPC HTTP 处理器入口 │ │ └── index.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ └── post.ts # 业务路由按模块拆分 │ │ │ ├── root.ts # 路由聚合处 │ │ │ └── trpc.ts # tRPC 实例与中间件 │ │ └── db.ts # Prisma Client 单例 │ ├── styles/ │ │ └── globals.css │ └── utils/ │ ├── api.ts # 前端 tRPC 客户端 │ └── ssg.ts ├── .env # 环境变量 └── package.json3.2 Schema 先行:先把数据模型定下来
t3code 里我放了一个最典型的示例业务——文章系统。打开prisma/schema.prisma:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? posts Post[] accounts Account[] sessions Session[] }这里用String+cuid()而不是自增Int作为主键,是因为 t3code 的目标部署环境包括 Serverless 平台,字符串主键在分布式环境下不用关心自增序列的协调问题。写完 schema 之后执行:
pnpm prisma db push pnpm prisma generatedb push会同步开发数据库的结构,generate会生成最新的 Prisma Client 类型。
3.3 tRPC 路由:把 CRUD 变成类型安全的函数调用
在src/server/api/routers/post.ts中定义文章相关接口:
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const postRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, include: { author: true }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1, "标题不能为空"), content: z.string().min(1), }) ) .mutation(async ({ ctx, input }) => { if (!ctx.session?.user) { throw new Error("请先登录"); } return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });然后把postRouter挂到根路由上。src/server/api/root.ts:
import { createTRPCRouter } from "~/server/api/trpc"; import { postRouter } from "~/server/api/routers/post"; export const appRouter = createTRPCRouter({ post: postRouter, });这里每个 procedure 里面都通过ctx拿到了数据库实例和 Session 信息。请求进来时,tRPC 会自动创建 context,把 Prisma Client 和当前登录态注入进去,不需要自己手动处理依赖注入。
注意:
publicProcedure表示“任何人可调用”,但我们在create内部手动做了登录校验。如果想在路由层统一拦截,可以写一个protectedProcedure,在中间件里判断ctx.session是否存在。做生产级应用时建议用后者,t3code 示例为了展示两种写法所以这里用手动判断。
3.4 前端消费:挂着类型提示的“本地函数”
前端调用只需要一行,而且全程有类型提示:
import { api } from "~/utils/api"; function PostList() { const { data: posts, refetch } = api.post.getAll.useQuery(); const createPost = api.post.create.useMutation({ onSuccess: () => refetch(), }); // ... }api.post.getAll.useQuery()里的post、getAll这两个“点”都是有类型提示的,你拼错任何一个路由名,编辑器会立刻标红。更关键的是posts会被自动推导成(Post & { author: User })[]类型,前端拿到数据后直接能用.map遍历,不需要任何额外的类型断言。
3.5 类型是怎么“全程手拉手”的
整个过程可以拆成一条链:
- Prisma schema 定义
Post模型; prisma generate生成Post对应的 TypeScript 类型;- tRPC 的 procedure 返回值基于 Prisma Client 推导;
- TypeScript 类型通过 tRPC 的
router类型定义传递到前端useQuery。
这条链完全由 TypeScript 编译期完成,没有代码生成器,没有.d.ts手写文件,也没有运行时类型转换。我打个比方:它就像两个人在同一个仓库里协作,后端改了数据结构,前端那边 TypeScript 会立刻“喊疼”,提醒你同步修改。这种体验一旦习惯,就很难回到手写接口类型的套路里。
4. t3code 开发过程中,我遇到并解决的四类真实问题
写模板项目和写业务项目有一个很大的不同:模板项目什么都要覆盖,所有边界情况都可能炸。这里挑四个实际解决过的坑,按排查思路记录,之后自己遇到能少走弯路。
4.1 修改 Prisma schema 后,编辑器一直报类型错误
这是最常见的坑,没有之一。你改了schema.prisma,加了字段,也执行了db push和generate,但前端编辑器还在用旧的类型提示。多数情况下是生成流程没有走到编辑器索引更新的那一步,尤其是你在另一个终端窗口跑着 Next.js dev server,IDE 可能没有重新加载类型。
排查链路:
- 重新执行
pnpm prisma generate,看输出是否有“Generated Prisma Client”字样; - 检查是否升级了 Prisma 版本,新版本生成目录可能变化,确认
node_modules/.prisma/client存在; - 重启 TypeScript 服务(VS Code 里按
Cmd+Shift+P选 “TypeScript: Restart TS Server”); - 如果还报错,删掉
.next目录再重启 dev server,偶尔 Next.js 的缓存也会导致类型读取不到新模块。
经验:把这些命令写进
package.json的scripts.postinstall里,每次pnpm install之后自动执行prisma generate,能从源头减少这类问题。
4.2 tRPC 路由合并时的命名冲突
t3code 的目录是按业务域组织routers文件的。放过一次错,两个 router 同时定义了一个叫list的 procedure:一个是post.list,一个是user.list,合并进root.ts时名字并不冲突,但前端调用变成了api.post.list和api.user.list,看起来正常。真正的问题出在内部复用和类型推导上——如果你在一个 router 里不小心用router.merge()合并了另一个 router,而两者都有同名 procedure,合并结果会直接覆盖,且不会报编译错误,运行行为却和你预期完全不同。
解决方法是给不同业务域的 procedure 命名时,尽量加上领域前缀,比如post.byId、user.profile。等到代码规模大到几十个 procedure 之后,这种命名习惯能避免大量“这个接口到底是哪个模块的”问题。
4.3 部署环境下数据库连接超时和连接数打满
本地开发完全正常,一部署到 Serverless 平台就频繁报 P1001 连接超时。根因是用 Prisma 直连 Postgres,而 Serverless 环境下函数实例会被横向拉起多个,每个实例都持有自己的数据库连接池,连接数暴涨直接压垮数据库。我在 t3code 的部署文档里专门写了这一条的解决方案:
- 使用外部数据库服务自带的连接池(Neon 有内置池化,Supabase 的 PgBouncer 模式效果明显);
- Prisma Client 初始化时设置合理的连接池大小:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"], }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;这个globalForPrisma模式是 Next.js 热更新环境下防止重复创建 Prisma Client 的标准写法,开发模式下如果你不这么写,每次文件改动都会 new 一个新的 Client,很快会触发“Too many Prisma Client instances”的警告。
4.4 环境变量校验失败导致的启动崩溃
t3code 用了@t3-oss/env-nextjs做环境变量校验。有一回部署新环境,只配置了DATABASE_URL,漏了NEXTAUTH_SECRET,结果整个应用启动直接报错。安全方面这其实是好事,避免带病上线。但从项目维护角度看,报错信息指向的env.mjs文件对新手不太友好,第一眼不知道缺了什么。
我的建议是:第一,初始化时把.env.example写完整,每个变量的用途注释;第二,生产环境用平台的 UI 或 Dashboard 配置环境变量时逐项核对;第三,如果环境变量有多个环境(staging、prod),尽量用同一套名称,避免不同环境变量名差异带来的排查成本。
5. 如果你也要搭一套类似的模板,几点实用建议
t3code 这套东西不是唯一解,但它验证了一些通用的工程思路。想自己搭一套类似模板的话,有几个建议可以参考。
5.1 不要“一上来就全都要”
我一开始也想把所有东西都放进模板:上传、日志、WebSocket、多语言、RBAC 权限……后来发现模板一旦塞满,就不是“开箱即用”,而是“开箱要删半天”。一个新人拉下来看到一堆用不上的模块,只会更困惑。t3code 最后只保留了一条最小但完整的业务链路——用户登录、发文章、文章列表——其余的都用注释和文档说明“什么时候再加”。
5.2 先建立“最小闭环”再扩展
模板最有价值的部分不是脚手架本身,而是它能不能展示一条完整的业务闭环:登录 -> 拿用户信息 -> 写数据 -> 读数据 -> 渲染列表 -> 样式处理。这条链路一旦跑通,后续加功能都有明确的参照点。我强烈建议你搭模板时先做这条闭环,哪怕业务看起来很简单。它验证了认证、数据库、API、前端四个层面是通的,有了这个底层信任,后面写业务才不会有“不知道在哪一层出问题”的焦虑。
5.3 想清楚自己的适用场景
T3 Stack 和 t3code 并不是全表通吃的银弹。它最适合的是中后台系统、个人项目、MVP 验证、内部工具;如果你是做一个访问量极大的内容站,或者业务里大量依赖第三方服务集成(比如复杂消息队列、流式处理),那传统后端框架仍然是更稳妥的选择。我自己的节奏是:新项目先问自己“需要这条路把类型安全带到多远”——如果只是写几个展示页面,没必要上全套;如果要做带数据关系的业务系统,T3 这套能省下大量联调时间。
最后再分享一点实际操作中的体会:模板类项目的价值,不在它代码写得有多炫,而在于你每一次复用它都能“少做一次重复决定”。t3code 目前已经在我自己新起的三个小项目里用起来了,感受是前期一次性搭好的基础设施,在后期业务开发时会数倍地通过开发速度回报你。如果你打算入 T3 Stack 的坑,不用纠结于一次到位,先拿它跑通一个真实功能,再决定怎么迭代自己的模板,这个方向是的确值得投入的。