t3code 这个名字,最初只是我敲目录名时随手起的代号。当时我决定把手里一个积压了很久的 Web 工具重写一遍,顺手用了 "T3 + code" 的命名,寓意很直白——这套代码想把 T3 技术栈用到极致:Next.js、TypeScript、Tailwind CSS、tRPC、Prisma,外加 NextAuth 做认证。项目从初始化到部署上线,前后折腾了大概三周,一半时间在写业务,另一半时间在和工程化细节搏斗。今天这篇就完整回顾 t3code 从 0 到 1 的过程:为什么选这套组合、每一步怎么落地、有哪些文档里不会明说的坑。如果你正准备开一个新的全栈 TypeScript 项目,或者已经在用 T3 栈但想看看别人怎么组织代码、怎么排坑,这篇文章可以直接当参考手册来用。
1. t3code 为什么选择 T3 栈:被类型断裂折腾怕了
先说说我为什么会走向 T3 栈。t3code 这个项目本身是个团队协作类的 Web 工具,核心功能很朴素:用户登录后可以维护自己的项目列表,每个项目下面挂若干任务,任务有状态、有截止日期,仅此而已。听起来不难,但这类业务最大的痛点是"胶水代码"太多——数据库表结构是一套类型,后端接口参数是一套类型,前端页面拿到的响应又是一套类型。三套类型全靠手写维护,改一个字段名,数据库、接口、前端三处都要跟着改,漏掉一处就是线上事故。
我之前用过 REST + OpenAPI 的方案,也试过 GraphQL,各有各的别扭。REST 虽然成熟,但前后端类型本质上靠生成器(比如 openapi-typescript)去同步,生成出来的类型经常有 TS 兼容问题,复杂联合类型要手工补丁。GraphQL 的 codegen 倒是强力,但引入的运行时复杂度和心智负担都不低。当时我就在想:如果我前后端都是 TypeScript,为什么不能直接通过函数调用拿数据,让类型本身成为唯一的契约?
T3 栈恰好就是冲着这个痛点去的。它的核心不是某个新框架,而是一组技术选型约定:Next.js 负责页面和应用框架,TypeScript 负责全链路类型,Tailwind CSS 负责样式,tRPC 负责 API 层,Prisma 负责数据库访问。官方推荐还有 NextAuth 处理认证。这套组合最大的特色,是类型可以从数据库一路"流"到浏览器端,中间不需要任何 codegen、不需要维护接口文档,改一个 Prisma 字段,前端调用处立刻出现红色波浪线。
1.1 T3 的三根支柱到底指什么
很多人把 T3 理解成"一个框架",其实它不是。T3 全称是 The T3 Stack,最初由 Theo 提出,核心强调三件事:TypeScript 优先、尽量少魔法、类型安全是开发体验的一部分。三根支柱分别对应 TypeScript、Tailwind、tRPC 的首字母。
- TypeScript:这个不用多说,t3code 里所有业务代码都按严格模式写,
strict: true是底线。 - Tailwind CSS:原子化 CSS。刚开始我很怀疑"所有样式都写在 class 里"会不会很乱,实际用下来反而因为类名直观、改动局部化,改 UI 的速度快了不少。
- tRPC:这就是 T3 的杀手锏。它让你在前后端共享一个 TypeScript 函数签名,前端直接"调用"后端函数,不用写 URL、不用手动定义请求参数类型。
再加上 Prisma 和 NextAuth 作为数据层和认证层的标配,T3 栈其实是在回答一个问题:全栈 TypeScript 项目里,怎么用最少的中间表述让类型安全最大化。
1.2 t3code 的实际业务场景
为了后面讲得具体,这里把 t3code 的边界说清楚。它不是一个大型商用系统,而是一个中等复杂度的内部工具:认证模块(邮箱 + GitHub OAuth 登录)、项目模块(创建、归档、删除项目)、任务模块(增删改查、状态流转、按截止日期过滤)、以及一个简单的数据总览页。这个体量对 T3 栈来说非常典型——既有足够多的业务逻辑来验证架构,又不至于大到让 tRPC 的单一代码仓库显得臃肿。
如果你手头的项目是开放的面向公众的大流量 API 服务,或者有明确的第三方调用方(比如移动端 App),T3 栈不一定是最优解,tRPC 更适合前后端都由你控制的场景。我在 t3code 里也刻意保留了这个判断,后面第 7 节会细说。
2. 初始化:create-t3-app 从命令到跑通的完整过程
t3code 的初始化我直接用了官方脚手架。这一步其实非常顺,但有几个选项和细节值得展开说说,因为选错后面会花很多时间返工。
npx create-t3-app@latest t3code2.1 交互式选项怎么选
脚手架会问你安装哪些模块,t3code 我选了全量:Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth、ESLint。另外一个关键选项是 App Router 还是 Pages Router,t3code 选了 App Router。
这里我有明确理由:App Router 是 Next.js 现在的主流方向,服务端组件(RSC)和布局系统的能力比 Pages Router 强不少。t3code 的页面结构是"公共页 + 登录后工作台 + 各模块子页",用app目录下的layout.tsx组织共享布局非常顺手,比如工作台的整体外壳做一次layout,每个页面只管自己的内容。虽然 tRPC 在 App Router 下的客户端组件调用方式要多写一行use client,但这点成本完全值得。
还有一步是问要不要开启eslint和prettier,我都会选上。ESLint 的推荐规则集里包含@typescript-eslint,配合严格模式能在早期帮你挡住大量低级类型错误。
2.2 环境变量和本地数据库的准备
脚手架生成完后,第一件事是处理.env。create-t3-app会生成一个.env.example,把里面涉及的变量复制成.env并填好。
以 t3code 为例,至少需要这几项:
# 数据库连接串 DATABASE_URL="file:./dev.db" # NextAuth 加密密钥,可用 openssl rand -base64 32 生成 NEXTAUTH_SECRET="your-secret-here" NEXTAUTH_URL="http://localhost:3000" # GitHub OAuth 用的两个 id GITHUB_CLIENT_ID="..." GITHUB_CLIENT_SECRET="..."本地开发我一开始用 SQLite(file:./dev.db),理由很简单:零配置,prisma migrate dev跑起来就是本地文件,不用额外起数据库服务。到了部署阶段再切换到 PostgreSQL,后面第 6 节会讲到。很多人一上来就配 Postgres,本地开发还得维护一个数据库容器,对前期快速验证形态来说属于过度工程。
2.3 第一次启动与目录结构解析
跑npm run dev之前,先执行一次数据库迁移和 Prisma 客户端生成:
npx prisma migrate dev npm run dev启动成功后,localhost:3000会有一个带登录状态的首页。这时候看脚手架生成的目录结构,就能理解 T3 栈的代码组织逻辑:
src/app:Next.js App Router 的页面与布局src/server/api:tRPC 的 router 和 procedure 定义所在src/server/auth.ts:NextAuth 配置src/server/db.ts:Prisma 客户端单例src/trpc:服务端调用和客户端封装的工具prisma/schema.prisma:数据模型
我强烈建议新手先花半小时读一遍脚手架自带的src/server/api/root.ts和routers/post.ts示例代码,搞清楚"一个 router 是怎么被挂到根 router 上的",后面自己加模块就非常快了。
3. 数据建模:Prisma Schema 与迁移实操
t3code 的数据模型设计是整件事的地基。T3 栈里,Prisma schema 是唯一的权威数据源,表结构改没改、关系对不对,全看它。这里我直接把核心模型的建模过程讲一遍。
3.1 User / Project / Task 三张核心表的建模
prisma/schema.prisma里默认已经有 User 模型(NextAuth 需要),我在其上扩展了 Project 和 Task,形成最基本的关联关系:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] projects Project[] tasks Task[] } model Project { id String @id @default(cuid()) name String archived Boolean @default(false) createdAt DateTime @default(now()) ownerId String owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) tasks Task[] } model Task { id String @id @default(cuid()) title String status TaskStatus @default(TODO) dueDate DateTime? projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) createdById String createdBy User @relation(fields: [createdById], references: [id]) createdAt DateTime @default(now()) } enum TaskStatus { TODO IN_PROGRESS DONE }几个设计点值得单独说。
主键用cuid()而非自增整数:t3code 是一个偏内部的工具,不担心隐私枚举问题,但 cuid 在分布式环境下不需要查数据库就能生成,Web 端创建任务时客户端的乐观更新也更方便,所以我默认所有模型都用字符串 ID。
两个@relation字段对应不同业务含义:Task 上的createdById记录"谁创建的",Project 上的ownerId记录"项目属于谁"。这两个字段虽然都指向 User 表,但语义完全独立,必须分开建模,不能合并。很多人刚用 Prisma 时会在这里想偷懒,结果后面查"某人负责的任务"和"某人创建的任务"就混掉了。
3.2 一对多与多对多的关系处理
Project 和 Task 是一对多,Task 的projectId外键指向 Project,onDelete: Cascade保证删项目时任务一起删掉,避免留下孤儿数据。User 和 Project 是一对多,但实际业务里"成员"关系通常是多对多——一个项目有多个成员,一个用户参与多个项目。t3code 里我把这个需求简化了:MVP 阶段先只做"项目归属人"概念,多用户协作放到下一迭代。
如果你要做真正的多对多,Prisma 里的标准做法是建一张中间表:
model ProjectMember { userId String projectId String role String @default("member") user User @relation(fields: [userId], references: [id]) project Project @relation(fields: [projectId], references: [id]) @@id([userId, projectId]) }这种"显式中间表"的好处是可以在关系上挂额外字段(比如role)。不要只在 schema 里写User[] projects加隐式关系表,那样后期加字段会非常痛苦。
3.3 迁移流程和种子数据
模型改完,执行:
npx prisma migrate dev --name init这个命令会做三件事:生成迁移 SQL、写入迁移历史、重新生成 Prisma Client。日常迭代我基本只用这一个命令,习惯是每次改 schema 后立刻执行并命名,保证迁移历史是连续的。
种子数据我用的是prisma/seed.ts。脚手架默认不配,需要自己在package.json里加prisma.seed字段,指向编译后的种子脚本。我写了几个测试用户和两三个带任务的项目,方便前端联调。注意next dev跑起来后,种子数据只在本地用,生产环境千万不要跑,否则会污染真实数据。
4. tRPC:从 Router 定义到前端调用的全链路
t3code 的核心业务逻辑全部在 tRPC 里。这是 T3 栈里最值得花时间搞懂的部分,我拆开讲。
4.1 Router 与 Procedure 的基础写法
一个业务模块对应一个 router 文件,比如src/server/api/routers/task.ts:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const taskRouter = createTRPCRouter({ getByProject: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.task.findMany({ where: { projectId: input.projectId, project: { ownerId: ctx.session.user.id }, }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ projectId: z.string(), title: z.string().min(1).max(100), dueDate: z.date().optional(), }), ) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title, dueDate: input.dueDate, projectId: input.projectId, createdById: ctx.session.user.id, }, }); }), });注意到我用了protectedProcedure而不是publicProcedure,它来自src/server/api/trpc.ts:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: ctx.session } }); });流程很简单:请求进来先查 session,没登录直接抛UNAUTHORIZED,否则把 session 注入上下文。核心业务过程不需要自己再处理权限,这就是中间件模式的价值。
4.2 鉴权中间件与受保护路由
为什么要单独封装protectedProcedure?因为 t3code 里绝大多数接口都要求登录,如果每个 query 都手动写一遍"判断 session 是否存在",代码会很脏。把鉴权下沉到 procedure 层后,业务函数里可以直接信任ctx.session.user.id存在,类型系统也保证了你拿到的一定是 User,不是User | null。
这里还有一层更细的权限:任务虽然属于项目,但用户只能操作自己的项目。所以getByProject的 where 条件里同时加了project.ownerId的过滤,而不是光按projectId查。这是很多人容易漏的越权点——只校验"登录了",不校验"这条数据是你的"。
4.3 前端使用与类型推导
前端调用是 t3code 最爽的部分。以任务列表页为例,React 组件里直接:
"use client"; import { api } from "~/trpc/react"; export function TaskList({ projectId }: { projectId: string }) { const { data, isLoading } = api.task.getByProject.useQuery({ projectId }); if (isLoading) return <div>加载中...</div>; return ( <ul> {data?.map((task) => ( <li key={task.id}>{task.title}</li> ))} </ul> ); }没有fetch、没有axios、没有手写响应类型。api.task.getByProject的类型完全由后端 router 推导出来,改后端返回结构,前端编译直接报错。这种体验第一次用的时候很震撼,但本质上只是 TypeScript 泛型推导的功劳。
api对象的来源是脚手架生成的src/trpc/react.ts,它在客户端组件里通过createTRPCReact创建,并自动接入了 React Query。也就是说 tRPC 并没有自己造一套数据获取库,底层用的还是 TanStack Query,缓存、重试、失效策略全都复用 React Query 的能力。
4.4 缓存更新与失效策略
tRPC + React Query 的组合下,创建新任务后需要刷新列表。t3code 里我用了标准的 invalidate 方式:
const utils = api.useUtils(); const createTask = api.task.create.useMutation({ onSuccess: async () => { await utils.task.getByProject.invalidate(); }, });invalidate会让对应 query 失效并重新拉取。这里我踩过一个坑:如果 invalidate 的 key 不匹配,页面不会刷新。比如getByProject和getAll是两个不同的 query,创建任务时如果 invalidate 的是getAll,而页面用的是getByProject,数据就不动,你会以为是 bug。所以建议命名时定一个约定,t3code 里我统一用"操作对象 + 查询维度"命名,比如task.getByProject、task.getAll,创建/更新后精确 invalidate 对应 query。
5. NextAuth 接入:会话、回调与权限控制
认证是这类项目绕不开的部分。t3code 用的是 NextAuth(现在叫 Auth.js),脚手架默认就接好了,但接入过程中有一个回调相关的问题让我排查了半天,值得单独拿出来讲。
5.1 配置 provider 与 session
t3code 支持 GitHub OAuth,配置在src/server/auth.ts:
export const authOptions: NextAuthOptions = { session: { strategy: "jwt" }, providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], callbacks: { session: ({ session, token }) => { if (session.user && token.sub) { session.user.id = token.sub; } return session; }, }, };选择 JWT 策略而非数据库 session,原因有两个:一是 t3code 的用户规模不大,JWT 免去了每次请求查 session 表的开销;二是部署到 Serverless 环境(Vercel)时,无状态会话更省事。
5.2 一个让我卡了半天的 session 回调坑
问题出在前端拿不到session.user.id。当时我在一个客户端组件里写这样的代码:
const { data: session } = useSession(); console.log(session?.user.id); // undefined理论上 NextAuth 会通过回调把token.sub映射到user.id,但实际打印出来session.user的类型里根本没有id字段。原因在于 NextAuth 的 TypeScript 类型定义里,Session["user"]默认只有name、email、image,你虽然在回调里塞了个id,TypeScript 的可选类型却不认,而且运行时如果回调没正确返回,字段就是 undefined。
解决办法有两步。第一步,在src/types/next-auth.d.ts里做模块增强:
declare module "next-auth" { interface Session { user: { id: string; name?: string | null; email?: string | null; image?: string | null; }; } }第二步,一定要确认回调真的执行了。我那次的问题其实是回调写法有误——session回调返回的 session 对象必须原样带上user字段,如果不小心只return { ...session }但没把user.id赋进去,类型没问题但值就是丢的。这个坑表面上是"类型不对",根子却是"回调没生效",排查思路要走两层。
5.3 页面级与接口级的两层保护
t3code 里我分两层做权限控制。
页面级:工作台的布局组件用 NextAuth 的getServerSession()判断登录态,未登录直接重定向到首页:
// src/app/(dashboard)/layout.tsx import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; import { authOptions } from "~/server/auth"; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { const session = await getServerSession(authOptions); if (!session?.user) redirect("/"); return <div className="p-6">{children}</div>; }接口级:也就是第 4 节说的protectedProcedure。两层缺一不可——页面布局保护的是 UI 访问,接口保护的是数据访问,别人即使绕过 UI 直接调 tRPC 端点,也会被中间件拦下来。
6. 部署到 Vercel:从本地到生产的完整踩坑记录
t3code 的部署目标很明确:Vercel。Next.js 应用部署到 Vercel 几乎是无脑操作,但连接数据库、环境变量、构建这几个环节,我还是踩了几个实打实的坑。
6.1 环境变量与数据库连接
本地 SQLite 在生产是不可行的,Vercel 的无服务器环境不提供持久化磁盘。t3code 换成了 PostgreSQL,我用了 Neon 的免费库(当然你也可以用 Vercel Postgres 或其他托管服务)。
部署前必须把生产 DATABASE_URL 配到 Vercel 项目设置里,这一步很多人忘了。更隐蔽的是NEXTAUTH_URL,本地是http://localhost:3000,线上必须改成你的正式域名,否则 OAuth 回调地址不对,GitHub 登录会一直报 redirect_uri 不匹配。
还有一个小细节:Prisma Client 在构建时需要能连数据库吗?不需要。构建阶段跑的是prisma generate,只是生成客户端代码,不连库。但应用运行时连库是需要的,所以环境变量里 DATABASE_URL 必须真实有效。
6.2 构建失败排查表
部署过程中我遇到的报错和解决办法整理成一张表,方便你直接对照:
| 报错现象 | 常见原因 | 解决办法 |
|---|---|---|
PrismaClientInitializationError | 运行时没拿到 DATABASE_URL | 检查 Vercel 环境变量,确认名字和本地.env完全一致 |
Can't reach database server (P1001) | 数据库地址白名单未放行 Vercel 的 IP | 在 Neon/RDS 控制台允许所有 IP 或对应区域 IP |
Error: P1000: Authentication failed | 连接串中密码含特殊字符未转义 | 连接串 URL 编码处理特殊字符 |
Module not found: prisma | 安装依赖时 prisma 在 devDependencies | 确认@prisma/client在 dependencies,prisma在 devDependencies 但 postinstall 脚本要能跑 |
Route ./src/server/auth.ts构建超时 | Serverless 构建内存不足 | 升级 plan 或拆分构建;Vercel 免费版可用NEXT_TELEMETRY_DISABLED=1减少无用步骤 |
第二个是高频坑。很多托管数据库默认只允许特定 IP 连接,Vercel 的 Serverless 函数每次冷启动 IP 可能不一样,所以生产环境基本只能放开 IP 限制或使用支持无服务器接入的连接方式,否则线上会间歇性报 P1001。
6.3 上线后的性能与体验优化
部署成功后,第一轮体验优化也很重要。t3code 上线后我做了三件事:
第一件事是处理首屏加载。App Router 的布局用getServerSession会导致部分页面变成动态渲染,白屏时间变长。我优化方式是只对真正需要登录态的页面启动动态渲染,公开页面保持静态,让工作台外壳的验证逻辑尽量轻。
第二件事是给列表分页。Task 数量上来后,全量查询会越来越慢。虽说 t3code 是内部工具,但"按项目拉全部任务"这种直觉接口在 1000 条数据时就会明显变慢。我给getByProject加了take和skip参数,前端用"加载更多"替代一次性渲染。
第三件事是缓存策略。React Query 的staleTime默认是 0,意味着每次进入页面都会重新请求。t3code 里我把任务列表的staleTime设成了 10 秒,用户切换项目或返回列表时能直接读缓存,体感会顺滑很多。这行配置很简单,但优化效果立竿见影。
7. 收尾:我给后来者留的几点实操建议
项目做完,有几条经验我想单独说说,不是因为它们多高深,而是因为它们能省你未来好几天的排查时间。
第一,类型安全也是技术债的组成部分。t3code 后期重构时,我深刻地体会到一件事:因为 Prisma 到 tRPC 到前端全部类型打通,我改一个模型字段时,所有用到它的地方会在编辑器里一次性报满错误,不会有任何"运行时才发现"的惊喜。这感觉就像有人帮你把所有雷都标好了位置。反过来,如果当初为了省事,在某些边界偷偷用as any,等于亲手埋雷。t3code 里我定的规矩是:任何any都要写明理由并加 TODO,事后统一清理。
第二,tRPC 不是万能的。我用它写了全部内部接口,但项目里如果有"对外提供 API 给第三方"的需求,我不会用 tRPC。tRPC 的强类型优势只在前后端都由你控制的场景下成立,对外 API 还是老老实实用 REST + OpenAPI 更合适。技术选型要跟着需求走,不是跟着潮流走。
第三,本地开发数据库建议用 SQLite 起步。很多新手一上来就配 Docker 跑 Postgres,折腾网络、端口、数据卷,结果业务一行没写。t3code 前期用 SQLite,Prisma 切到 PostgreSQL 的成本其实很低——改一个provider和连接串,重跑一次迁移,绝大多数 schema 不用动。用最小的成本先验证业务形态,再在部署前切换生产数据库,这个顺序我强烈推荐。
第四,也是最后一个小技巧:善用 Prisma 的 Select。很多查询其实不需要返回整行数据,t3code 的"项目列表页"只需要项目的 id、name、任务数量统计。直接在 query 里写_count和select控制返回字段,不仅减少网络传输,还能让 TypeScript 自动推导出更精确的类型。减少不必要的数据拉取,本身就是一种性能优化。
t3code 做完之后,我最大的感触是:T3 栈真正解决的不是"怎么写代码",而是"怎么让团队里每个人都敢安全地改代码"。类型从数据库一路连到点击事件,重构的胆子自然就大了。如果你也正在规划一个新项目,不妨先用这套组合跑一个最小的闭环,感受一下前后端类型一体化的开发节奏——大概率你会回不去手写接口文档的日子。