t3code 是我最近基于 T3 Stack 整理出来的一套全栈应用模板。名字没有太多讲究,就是把 T3 生态里几个核心成员——TypeScript、Tailwind CSS、tRPC 和 Next.js——拼在同一个代码基座上,让它成为我开新项目时第一个克隆的仓库。这个模板并不是简单跑一下 create-t3-app 就完事,我在里面沉淀了完整的用户认证、数据模型设计、接口层划分规范和部署方案。几个内部项目跑下来之后,整体稳定性我还是比较满意的,所以动了整理成文的心思。这篇文章适合两类人看:一类是想试 T3 Stack 但不知道从哪下手的同学,另一类是已经在用、却总在类型、认证、部署这些环节上反复折腾的人。我会把实际项目里的设计决策、关键代码和踩坑记录都摊开来讲,尽量做到看完了能直接照着干。
1. 项目定位:t3code 到底解决了什么问题
1.1 为什么选 T3 Stack,而不是传统前后端分离
技术选型的时候,我实打实纠结过三条路线:传统 REST 前后端分离、GraphQL 全家桶,以及 T3 Stack。REST 方案最大的问题不是技术落后,而是类型契约的维护成本太高。前端要维护一份接口定义,后端要维护一份路由返回声明,两边一旦忘记了同步,类型上不会有任何提示,等来的就是生产环境里各种诡异的 undefined 报错。就算引入 OpenAPI 自动生成代码,也绕不开另一个麻烦:生成出来的类型很难按业务语义定制,每次改接口都要重新生成一遍,CI 里还得盯着版本是不是对得上。
GraphQL 在类型安全方面确实做得不错,但引入 Apollo Server、Codegen、客户端缓存这些配套之后,学习成本和心智负担会明显上升。对中小型应用来说,很多团队根本用不上订阅、缓存命中这类高级特性,最后往往只是把 REST 换了一种更复杂的形态,还多背了一套 schema 维护工作,收益并不划算。
T3 Stack 解决这个问题的方式完全不同,核心是用 tRPC 把前后端之间的调用类型直接打通。服务端写一个 procedure,客户端 import 进来之后,入参类型、返回类型、错误类型全部自动对齐,过程中不需要生成任何代码,也不需要维护任何契约文件。打个比方:REST 是两个人通过书信沟通,每次都要写地址、贴邮票、等回信,信寄丢了才发现;tRPC 是两个人直接打电话,同一套语法自然对齐,不会出现类型对不上的情况。这种开发体验是我最终选择 T3 的最根本原因。
1.2 t3code 的目录组织与模块边界
项目骨架我沿用 create-t3-app 的推荐结构,但在模块划分上做了更明确的约定。下面是我实际使用的目录布局:
t3code/ ├── prisma/ │ ├── schema.prisma │ └── seed.ts ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ # 按业务模块拆分 tRPC router │ │ │ └── root.ts # 根路由,合并所有子路由 │ │ ├── auth.ts # NextAuth 配置 │ │ └── db.ts # Prisma client 单例 │ ├── trpc/ │ │ ├── context.ts # 请求上下文,注入 session/db │ │ ├── trpc.ts # procedure 封装(含 protectedProcedure) │ │ └── react.tsx # 客户端 hooks 封装 │ ├── app/ # Next.js App Router 页面 │ │ ├── api/trpc/[trpc]/ # tRPC HTTP 接口入口 │ │ ├── (auth)/ │ │ └── (dashboard)/ │ ├── components/ │ ├── styles/ │ └── types/ └── package.json这套结构背后的原则是:按业务拆 router、按页面拆 UI、跨层共享类型放 types。server/api/routers 只放业务路由,不要在 trpc 初始化文件里堆逻辑,否则项目一复杂,那个文件就会变成谁都不想动的巨型模块;server/db.ts 负责提供 Prisma 单例;trpc/context.ts 负责把 session 和 db 注入到每个请求的 ctx 里。这样拆完之后,新增一个业务模块的路径非常固定:先在 schema.prisma 加模型,再在 routers 下加文件,最后在 root.ts 里合并一行。团队协作时边界清晰,代码评审也好做,不会出现谁都能往里乱塞文件的局面。
2. 核心模块设计与关键技术细节
2.1 Prisma 数据模型:主键、关联与索引的取舍
t3code 内置的领域模型是一个轻量协作系统,包含用户、项目、项目成员和任务四张核心表。Prisma schema 的关键部分如下:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? projects ProjectMember[] tasks Task[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Project { id String @id @default(cuid()) name String description String? members ProjectMember[] tasks Task[] createdAt DateTime @default(now()) } model ProjectMember { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) role String @default("member") createdAt DateTime @default(now()) @@unique([projectId, userId]) @@index([userId]) } model Task { id String @id @default(cuid()) title String done Boolean @default(false) dueDate DateTime? projectId String project Project @relation(fields: [projectId], references: [id]) creatorId String creator User @relation(fields: [creatorId], references: [id]) createdAt DateTime @default(now()) @@index([projectId, done]) }主键我统一用 cuid 而不是自增整数,这是 t3code 里一个很重要的决策。自增主键在单机数据库里确实简单直观,但一旦涉及到做只读副本、迁移数据或者合并多个环境的数据,就可能出现主键冲突;更麻烦的是自增 id 很容易让别人通过遍历接口拿到资源数量,对数据隐私不太友好。cuid 是客户端生成的、无序的字符串 ID,天然规避了这两个问题,代价仅仅是存储空间略大一点,对现代数据库来说完全可以忽略。
ProjectMember 表上的@@unique([projectId, userId])是一个复合唯一约束,保证同一个用户在一个项目里只能有一条成员记录。这是通过数据库层面防止重复加入的底线,不能只靠应用代码判断,因为并发的两个请求可能同时通过应用层检查。Task 表上的@@index([projectId, done])则是针对后台最常见的“按项目筛选任务,再看完成状态”的查询场景。有人可能会问,索引会不会拖慢写入?这种小体量的表基本没有感知,但对频繁查询的字段建立合适的复合索引,收益远大于那点写入开销。
2.2 tRPC 路由设计与 Zod 校验
tRPC 的用法不复杂,难点在划分粒度。t3code 的约定是:不要按数据库表拆 router,而要按页面或者用例拆。比如 Task 相关的操作集中在 taskRouter 里,包括按项目查列表、创建任务、切换状态、删除任务。一个典型的 router 长这样:
// src/server/api/routers/task.ts import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { createTRPCRouter, protectedProcedure } from "~/trpc/trpc"; export const taskRouter = createTRPCRouter({ listByProject: protectedProcedure .input(z.object({ projectId: z.string().cuid() })) .query(({ ctx, input }) => { return ctx.db.task.findMany({ where: { projectId: input.projectId }, orderBy: [{ done: "asc" }, { createdAt: "desc" }], }); }), create: protectedProcedure .input( z.object({ projectId: z.string().cuid(), title: z.string().min(1).max(100), dueDate: z.date().optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.$transaction(async (tx) => { const task = await tx.task.create({ data: { title: input.title, dueDate: input.dueDate, projectId: input.projectId, creatorId: ctx.session.user.id, }, }); return task; }); }), toggle: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { const task = await ctx.db.task.findUnique({ where: { id: input.id } }); if (!task) { throw new TRPCError({ code: "NOT_FOUND", message: "任务不存在" }); } return ctx.db.task.update({ where: { id: input.id }, data: { done: !task.done }, }); }), });每个 procedure 的入参都用 Zod 做了 schema 校验,这一步不是可有可无的。z.string().cuid()会在请求入口直接拦截非法 id,避免把脏数据带进数据库层;z.string().min(1).max(100)则限定了文本字段的长度,防止用户提交超长文本把页面布局撑坏。tRPC 和 Zod 配合最舒服的一点是:schema 只写一次,服务端入口有运行时校验,客户端的 TypeScript 类型又能自动推导出来,两头都不落空,这也是我坚持零 codegen 路线的底气。
protectedProcedure 是在 trpc.ts 里通过中间件包出来的一种受限 procedure,它会先检查 ctx.session 是否存在,不存在就直接抛 UNAUTHORIZED 错误。这样业务代码里就可以放心写 ctx.session.user.id,不需要在每个方法里手动判断登录状态,也不容易出现漏校验的漏洞。中间件还能继续扩展,比如管理员权限、租户隔离,本质都是对 ctx 做前置处理。
2.3 认证链路:NextAuth 与 tRPC Context
认证方面我直接用 NextAuth,并配合 JWT 会话策略。JWT 模式比起数据库 session 最大的优势是无状态,特别适合跑在 Serverless 环境里的 Next.js 应用,冷启动不会因为查 session 表多一次网络往返。缺点是用户信息变更不会立刻反映到已签发的 token 上,但这个问题在 t3code 里通过“tRPC procedure 按需查库”来兜底,比如需要最新用户资料时直接查 User 表,不依赖 session 里的快照。实战下来的结论是:中小型应用用 JWT 完全够用,没必要为了所谓的实时性引入数据库 session 存储,那会让每次请求都多一次数据库访问。
NextAuth 的核心配置集中在 src/server/auth.ts:
import { type NextAuthOptions } from "next-auth"; import { db } from "~/server/db"; export const authOptions: NextAuthOptions = { session: { strategy: "jwt" }, callbacks: { session({ session, token }) { if (session.user) { session.user.id = token.sub ?? ""; } return session; }, }, };session.user.id 这里是个容易踩坑的点:默认情况下 NextAuth 的 session 回调里并没有把用户 id 放到 session 对象上,前端拿到的 session 只有 email、name、image,没有 id。而很多业务查询、报表统计、权限校验都依赖 userId,所以必须在回调里手动把 token.sub 映射进去。tRPC 侧则在每次请求时通过 getServerSession 获取当前会话,并连同 Prisma 单例一起塞进 ctx,整个认证链路到这里就完全打通了。
3. 从零搭建 t3code 的实操流程
3.1 脚手架初始化与环境变量准备
如果你要从头复现 t3code,第一步自然是初始化项目:
npx create-t3-app@latest t3code交互式问答里我推荐按这个组合选:Next.js 用 App Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth,其余暂不需要。App Router 不是追新,而是 tRPC 官方对 App Router 支持已经很成熟,服务端组件、客户端组件、路由处理程序都能正常使用,相比 Pages Router 少了很多兼容层。
接着准备环境变量。复制 .env.example 为 .env,重点配置三个值:DATABASE_URL 指向 PostgreSQL 实例;NEXTAUTH_SECRET 是用来签名 JWT 的随机密钥,建议用下面的命令生成 32 字节以上的随机串,不要随便敲一段字符串应付了事:
openssl rand -base64 32最后执行数据库初始化:
npx prisma migrate dev --name init npx prisma generatemigrate dev 会创建 migration 文件并同步数据库结构,generate 会把类型定义输出到 node_modules 下的 Prisma client。后续每次修改 schema 之后都要再跑一遍 generate,否则 TypeScript 提示不会更新,运行时的 findMany、create 也不会认识新增的字段。这是新手最容易忽略的一步,我第一次就吃过这个亏,改完模型发现页面上一直报类型错误,排查半天才想起来漏了 generate。
3.2 把一个业务功能从模型做到页面
t3code 研发流程的核心价值在于“全链路类型贯通”。以 Task 列表页为例,模型建好之后,先确认 taskRouter 已经写好并合并到根路由:
// src/server/api/root.ts import { createTRPCRouter } from "~/trpc/trpc"; import { taskRouter } from "~/server/api/routers/task"; import { projectRouter } from "~/server/api/routers/project"; export const appRouter = createTRPCRouter({ task: taskRouter, project: projectRouter, }); export type AppRouter = typeof appRouter;然后写客户端组件,直接从 ~/trpc/react 引入 API hooks:
"use client"; import { api } from "~/trpc/react"; export function TaskList({ projectId }: { projectId: string }) { const utils = api.useUtils(); const { data: tasks, isLoading } = api.task.listByProject.useQuery({ projectId, }); const toggle = api.task.toggle.useMutation({ onSuccess: () => { utils.task.listByProject.invalidate({ projectId }); }, }); if (isLoading) return <div>加载中...</div>; return ( <ul> {tasks?.map((task) => ( <li key={task.id}> <label> <input type="checkbox" checked={task.done} onChange={() => toggle.mutate({ id: task.id })} /> {task.title} </label> </li> ))} </ul> ); }这里最值得注意的点是数据刷新策略。上面这段用的是“mutation 成功后 invalidate 对应查询”的方案,也就是服务端确认变更后再重新拉取列表数据。这种方案代码简单、不容易出错,适合对实时性要求不高的场景。如果追求极致体验,可以改成 onMutate 里先手动更新本地缓存、onError 里再回滚,但那样要额外处理组件卸载、并发请求覆盖等边界情况,复杂度会大不少。我建议先把 invalidate 方案跑通,等确实遇到界面卡顿、闪烁的问题,再考虑上乐观更新也不迟。
3.3 部署前的检查清单与关键配置
t3code 部署我通常采用 Node 运行时 + PostgreSQL,下面这份清单是多次踩坑之后沉淀出来的,每一条背后基本都对应过一次线上故障:
- NEXTAUTH_URL 必须设置为线上域名,反向代理、容器编排这类环境最容易漏掉这一项。漏了之后,OAuth 回调地址会不对,登录成功后被踢回环回地址,session 也容易失效。
- 数据库连接串要带连接池参数,尤其是 Serverless 平台,短时间并发创建连接很容易打满数据库连接数。PostgreSQL 可以在连接串里指定 pgbouncer 模式的连接池,或者直接使用数据库服务商提供的 pooler 地址。
- tRPC 的 httpBatchLink 默认走相对路径,反代部署时要注意 baseUrl 是否与外部访问域名一致,否则浏览器端会请求到错误路径直接 404。
- Prisma 的 query engine 在运行时需要系统 libssl,Docker 镜像里要记得安装对应系统依赖,否则容器启动后第一个 Prisma 调用就会报缺库错误。
部署没有一步到位的标准答案,不同平台的细节差异挺大,但把环境变量和连接池这两件事提前理清楚,能省掉一大半线上故障排查时间。
4. 常见问题与排查经验实录
4.1 高频问题速查表
我在 t3code 开发以及后续项目迭代中遇到的典型问题,整理成了一张速查表。这里只收录出现频率最高的六类,便于你遇到同样问题时按图索骥:
| 现象 | 常见原因 | 解决方案 |
|---|---|---|
| 客户端调用 API hooks 时方法不存在 | 子 router 没有合并到 appRouter | 检查 root.ts,确认导出对象里是否包含对应 router |
| 修改 Prisma schema 后类型没更新 | 忘记跑 prisma generate | 每次修改 schema 后执行 npx prisma generate,必要时重启 dev server |
| 开发环境 PrismaClient 报“实例过多” | Next.js 热更新导致模块被反复加载 | 用 globalThis 缓存 Prisma 单例 |
| tRPC 请求在部署环境返回 500 | 服务端 hostname 校验不通过或环境变量缺失 | 确认 NEXTAUTH_URL、DATABASE_URL 已配置,查服务端日志定位 |
| NextAuth 回调里 session.user.id 为空 | 没有在 session callback 中映射 token.sub | 在 authOptions.callbacks.session 里补上赋值逻辑 |
| Tailwind 类名不生效 | content 配置未覆盖相关目录 | 确认 tailwind.config.ts 的 content 包含 ./src/**/*.{ts,tsx} |
4.2 几个值得反复强调的细节坑
第一个坑是 procedure 划分粒度过细。我早期的版本把 task 的 list、create、toggle、delete 全部分开,每个都是独立的小文件,文件多到后面连自己都记不清哪个接口放在哪里。后来改成“按页面用例聚合”的思路,把同一个业务页面相关的操作放在一个 router 里,管理成本和命名负担都降下来了。这看起来只是文件组织问题,但对团队来说,它是代码可读性和交接效率的分水岭。
第二个坑是输入校验写了但没有生效。Zod schema 只有传给 .input 才会在请求入口执行校验,单独定义在外部文件里并不会拦截任何请求。有人喜欢把 schema 抽到公共文件复用,结果 import 回来之后忘了接到 .input 上,客户端的长度校验形同虚设,最后是数据库层报错才被发现。这一条听起来很低级,实操中遇到的人真不少,而且很难靠测试覆盖到。
第三个坑是 tRPC 的 superjson 序列化。t3code 默认开启 superjson,能让 Date、Map 这些类型跨过 HTTP 边界无损传输。但如果你在自定义中间件或者错误格式化里手动 JSON.stringify,Date 就会悄悄变成字符串,客户端拿到的类型提示还是 Date,运行时却不是,这种“类型提示与实际值不一致”的问题非常隐蔽。正确做法是统一走 tRPC 的序列化管线,不要在中间层做任何手动 JSON 转换。
5. 从 t3code 出发还能往哪些方向扩展
t3code 目前只覆盖了单应用模板这一层,实际上它衍生出的几个方向都值得尝试。第一个是测试体系:tRPC 的 HTTP 层可以直接用 supertest 打 e2e 测试,配合 vitest 和 msw 能覆盖大部分接口逻辑;数据库相关测试建议用独立测试库跑真实迁移,而不是去 mock Prisma,mock 出来的读写行为和生产环境差距太大,容易测出“假绿”。第二个是工程化升级:把这套模板塞进 Turborepo monorepo,前端应用、管理后台、共享 UI 组件拆成独立 package,数据库 schema 放在公共包里,多个应用可以共用同一套类型和 migration,改动模型时只需要提交一次。第三个是数据层替换:Prisma 的 query engine 体积和冷启动时间在部分边缘计算场景下不太理想,如果应用跑在 Node 运行时,可以对比 Drizzle ORM 这类更轻量的方案,权衡之后再决定要不要换。
最后说一点我自己真实的体会。模板这东西,最怕的不是功能少,而是违背实际业务的使用习惯。t3code 里的每一项设计,几乎都来源于真实项目里的一次次故障和返工。比起单纯给一份能跑的代码,我更希望这些选型理由和排查经验,能帮你在自己的项目里少走几段弯路。如果真要给个建议,我会说:先克隆下来,跑通一条最细的业务链路,再按你自己的需求去改目录、换模型、调认证,不要为了用模板而用模板。