1. 为什么是 T3 Stack:t3code 的整体设计思路
1.1 T3 Stack 到底解决了什么问题
先聊个实际感受。这两年全栈项目最让人头疼的,不是某个框架学不会,而是技术栈之间的“缝隙”太多。前端写接口请求要手拼类型,后端写 API 要单独维护一套 interface,数据库表结构变了,前端代码里还留着一堆已经失效的字段,跑起来全靠运行时碰运气。团队协作时,前后端联调的时间成本甚至比写功能本身还高。t3code 这个项目,核心就是冲着解决这类问题去的。
T3 Stack 本身不是一个新框架,而是一套经过验证的组合:Next.js 做应用框架,TypeScript 做类型体系,tRPC 做端到端类型安全接口,Prisma 做数据库访问,NextAuth.js 做鉴权,Tailwind CSS 做样式。这套组合最大的特点,就是把“类型安全”从数据库一路贯通到页面组件。我在 t3code 里所有的代码都遵循一个原则:数据从 PostgreSQL 里出来时是什么类型,前端组件拿到的就是什么类型,中间不需要任何人手写胶水代码。
用生活里的例子类比:过去做项目像是两个部门之间靠传真机沟通,这边发一份文档,那边照着敲一遍录入系统,敲错了就出错;T3 的方式是两边直接共用同一份电子表格,改动实时同步,格式永远一致。t3code 想要展示的,就是这套“无传真机”协作模式在实际项目里的完整落地情况。
1.2 t3code 的项目定位与模块划分
我最初定 t3code 的时候,就给它设了一个明确的定位:本质上是一个带真实业务逻辑的全栈应用模板。它不是一个只跑通 hello world 的 demo,也不是一个塞了几十个页面、看似丰富实则每个功能都只写了两三行代码的空壳。t3code 是一个可以拿去做二次开发起点的中台雏形,里面包含用户注册登录、项目空间、API 密钥管理、仪表盘统计这几个高度贴近真实场景的模块。
模块划分上,我参考了领域驱动的思路,但没上全套 DDD 那套复杂度。顶层只分了四个领域:identity(用户与鉴权)、project(核心业务实体)、billing(计费与配额)、dashboard(统计聚合)。每个领域内再按“路由层 -> 服务层 -> 数据层”三层组织。
为什么刻意分层?因为 T3 Stack 生态里最容易出现的问题,就是把所有逻辑写在 tRPC router 里。短时间看是爽,一个文件搞定全部,但项目超过两万行后,这种写法会带来灾难性的可维护性负担。t3code 里的 router 只做参数校验和调用编排,实际业务规则全部下沉到 service 层,Prisma 的查询统一封装在 repository 层。这样每一层职责单一,出问题时不用翻遍整个项目猜逻辑。
这个设计也带来一个实际好处:测试变得容易。service 层不依赖 Next.js 的请求上下文,可以脱离 tRPC 直接做单元测试。我在项目里给 service 层写了大概 40 个测试用例,覆盖了配额计算、密钥校验、项目权限判定等核心逻辑,跑一次全量测试用不了几秒。
2. 核心细节拆解:t3code 的关键实现
2.1 类型全链路:从数据库到前端的类型传递
t3code 最核心的体验,就是你在后端定义好一个查询,前端立刻能拿到完整的类型提示。这套能力的关键链路是:Prisma Schema 定义表结构 -> Prisma Client 生成数据库类型 -> tRPC router 暴露查询 -> 前端 useQuery 自动推断返回类型。
拿 project 模块的“获取项目详情”功能举例。先在schema.prisma里定义模型:
model Project { id String @id @default(cuid()) name String slug String @unique description String? ownerId String owner User @relation(fields: [ownerId], references: [id]) apiKeys ApiKey[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }然后 tRPC router 里写:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const projectRouter = createTRPCRouter({ getBySlug: protectedProcedure .input(z.object({ slug: z.string().min(1) })) .query(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ where: { slug: input.slug }, include: { apiKeys: { select: { id: true, name: true, lastUsedAt: true } } }, }); if (!project || project.ownerId !== ctx.session.user.id) { throw new Error("PROJECT_NOT_FOUND"); } return project; }), });这里有个容易被忽略的细节:include里用select限制了返回字段。很多人写 Prisma 查询时不注意字段裁剪,把整个关联表全部查出来,前端根本用不到那些数据,白白增加网络负载和序列化时间。t3code 里每个查询都做了字段级裁剪,这既是性能习惯,也是类型收窄的好办法。
前端使用时,一切自动衔接:
const { data } = api.project.getBySlug.useQuery({ slug: router.query.slug as string }); if (data) { console.log(data.name); // 类型推断为 string }不需要定义任何 API 响应类型,也不需要在 API 层写返回值的 interface。tRPC 通过类型引用直接从 procedure 的返回类型推导出前端 hook 的泛型,这个过程在编译期完成,运行时零成本。实际开发时最大的感受是:我几乎不需要再打开后端代码去看某个接口返回了什么字段,编辑器里的自动补全直接告诉我答案。
要做到这一点,有几个前置条件必须满足:严格使用 TypeScript 的strict模式、tRPC 的/(http|server)/路由必须与前端指向同一个类型源、.env里的DATABASE_URL必须在跑prisma generate前正确配置。任何一个环节断了,类型链路就会变成断头路,前端拿到的就会是 any 或者报错。
2.2 鉴权与用户体系:NextAuth 的集成细节
用户体系是每个真实项目绕不开的环节。t3code 选了 NextAuth.js,因为它与 Next.js 的集成最平滑,并且 tRPC 有官方的适配方案。但 NextAuth 的默认配置只能解决“能登录”的问题,真实项目还需要解决“登录后怎么知道是谁”的问题。
t3code 里使用 CredentialsProvider 加 JWT 会话策略。注意一个关键设计:JWT 的session.strategy设为"jwt",然后在callbacks.jwt里把数据库里的用户信息合并进 token,接着在callbacks.session里把 token 里的用户对象暴露给 session。这一步很重要,因为默认情况下 session 里只有用户 ID 和邮箱,而你往往还需要头像、昵称、角色这些字段。
callbacks: { jwt({ token, user }) { if (user) { token.id = user.id; token.role = user.role; } return token; }, session({ session, token }) { if (session.user) { session.user.id = token.id as string; session.user.role = token.role as UserRole; } return session; }, }另一个关键点是类型拓展。NextAuth 自带类型定义里没有session.user.id和role,直接使用会报错。t3code 在src/types/next-auth.d.ts里声明了类型覆盖:
declare module "next-auth" { interface Session { user: { id: string; role: "USER" | "ADMIN"; } & DefaultSession["user"]; } interface User { role: UserRole; } }不写这个类型声明,后续在 tRPC 的getServerSession里访问用户角色就全是类型错误,模板项目最容易坑在这里。
权限控制方面,t3code 实现了两层守卫。第一层在路由层,middleware.ts 里通过正则匹配受保护路径,未登录用户直接重定向到登录页。第二层在业务层,tRPC 里用protectedProcedure保证必须登录才能访问。两层缺一不可:middleware 管页面访问体验,tRPC 管数据接口安全。只做页面守卫而接口裸奔,等于把你家大门锁了但窗户全开着。
2.3 数据层设计:Prisma Schema 的几个关键点
Prisma 的 Schema 是后端的“宪法”,它同时决定了数据库结构和生成的类型。t3code 里我在 Schema 设计上花的时间最多,因为后期改表结构是成本最高的事。
第一个观点是:主键用 CUID 而不是自增 ID。自增 ID 有几种问题——一是暴露业务体量,别人看你的订单号就知道今天卖了多少单;二是分布式生成不方便;三是合并数据库时容易冲突。CUID 没有这些烦恼,生成也不需要等待数据库自增。
第二个观点是:关系字段的 onDelete 行为一定要显式声明。我见过很多次线上事故,都是删除用户时没有级联删除关联数据,导致外键约束报错或者脏数据残留。t3code 里这样处理:
model User { id String @id @default(cuid()) projects Project[] apiKeys ApiKey[] } model ApiKey { id String @id @default(cuid()) name String keyHash String @unique projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) }关键在最后的onDelete: Cascade。这样删除 Project 时,关联的 ApiKey 会自动清理。虽然 Prisma 默认对必填关系是Restrict,会阻止删除有依赖的记录,但在业务上,用户删项目通常就是希望删干净,而不是被报错挡住。
第三个观点是:把多租户隔离做在数据结构层面。t3code 里几乎所有业务表都有ownerId或projectId外键,查询时强制带上归属条件。这不是数据库层面的强制约束,但通过封装的 repository 层实现了“默认必带”的开发习惯,从源头上避免越权读取。
3. 从零到上线:t3code 的实操过程
3.1 初始化与目录结构规划
创建项目用官方脚手架是最省事的。执行:
pnpm create t3-app@latest t3code交互式选择时,我勾选了 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth。架构选项我选了默认的 App Router(pages router 虽然代码示例多,但 Next.js 新特性全在 app router 上,现在新建项目用 pages router 属于给自己挖坑)。
目录规划是决定项目后期维护体验的第一件事。t3code 在脚手架默认结构上做了几处改造:
src/ components/ # 纯展示组件,不碰数据和路由 features/ # 按业务域划分的功能模块 identity/ server/ # 登录注册相关 service components/ # 用户信息展示组件 project/ server/ # 项目 CRUD 的 service 与 repository components/ # 项目卡片、创建表单 server/ api/ # tRPC 根配置 db/ # Prisma client 实例 styles/features目录是额外加的。脚手架默认把所有组件平铺在components下,业务多了以后非常混乱。按业务域聚合之后,改一个功能时所有相关文件都在同一块区域,心智负担小很多。server目录继续保留,放跨域共享的服务代码。
3.2 第一个完整功能:从 schema 到页面的完整链路
拿“创建项目”功能走一遍链路。不要跳过任何一步,每一步都是后续项目的标准模板。
第一步,定义 Schema 并迁移。把上面那段 Project 模型写进schema.prisma,然后执行:
pnpm prisma migrate dev --name init_project pnpm prisma generatemigrate dev会生成 SQL 迁移文件并执行,generate会更新 Prisma Client 的类型,这两条命令在执行顺序上有硬性依赖,不能反过来。实际项目中我见过有人改完 schema 直接跑 generate 骗过类型检查,但数据库里根本还没有表,部署时直接崩。
第二步,写 service。在src/features/project/server/project.service.ts里定义 createProject:
export async function createProjectForUser(userId: string, data: { name: string; slug: string }) { // 检查 slug 是否唯一 const existing = await db.project.findUnique({ where: { slug: data.slug } }); if (existing) { throw new ProjectError("SLUG_TAKEN"); } const project = await db.project.create({ data: { ...data, ownerId: userId, }, }); return project; }slug 唯一性检查这里有个细节:即使数据库里有@unique约束,也建议在业务层做提前检查。数据库约束是最后防线,业务层检查的意义在于能返回更友好的错误信息,而不是直接抛出一个 500 数据库唯一约束错误。
第三步,接 tRPC router。把 service 包进 procedure:
create: protectedProcedure .input(z.object({ name: z.string().min(2).max(40), slug: z.string().regex(/^[a-z0-9-]+$/), })) .mutation(async ({ ctx, input }) => { return createProjectForUser(ctx.session.user.id, input); }),zod 的 input 校验和 service 的参数校验组成两层防护。zod 这层负责把不合法请求挡在业务逻辑之外,比如 name 太短、slug 含有非法字符等;service 这层负责处理业务规则,比如 slug 重复。两者互不替代。
第四步,前端页面。创建表单组件,用 react-hook-form 收集字段,提交时调用 mutation:
const utils = api.useUtils(); const createProject = api.project.create.useMutation({ onSuccess: () => { utils.project.getMyProjects.invalidate(); router.push("/dashboard"); }, });这里必须强调invalidate的用法。tRPC 的数据缓存是基于 key 的,mutation 成功之后,如果不清除或标记相关 key 的缓存,刚创建的数据不会自动出现在列表页。很多人第一次用 tRPC 都在这卡过:创建完项目,回到列表页发现数据没更新,刷新之后才出现。invalidate就是用来解决这个问题的。
3.3 打包部署与性能优化要点
部署平台我选了 Vercel,不是因为它是唯一选择,而是它和 Next.js 的兼容性最省心。但在部署前有几件事必须做。
第一件:环境变量与迁移策略。默认情况下next build不会自动跑数据库迁移。我的 CI 流程里加了一行:
pnpm prisma migrate deploy注意这里必须是migrate deploy,而不是migrate dev。migrate dev会在迁移过程中生成新的迁移文件并提示你输入名称,这在 CI 环境里会挂死。deploy只负责将现有迁移文件应用到目标数据库,不会生成新文件,这才是生产环境该用的命令。
第二件:数据库连接池。直接使用DATABASE_URL建立连接在 Serverless 环境下容易耗尽连接数。t3code 的 Prisma Client 初始化时使用 Prisma 官方推荐的连接池方式:
const globalForPrisma = globalThis as unknown as { prismadb: PrismaClient }; export const db = globalForPrisma.prismadb ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"], }); if (process.env.NODE_ENV !== "production") globalForPrisma.prismadb = db;核心思想是:开发环境热更新时会反复重新执行模块初始化,如果不把 Prisma Client 挂到 globalThis 上,每次 hot reload 都会创建多个新连接,最终把本地数据库的连接数打满。生产环境则不需要担心这个问题,但还是要控制实例数量。
第三件:静态页面生成策略。不是所有页面都需要实时数据。t3code 的营销页和文档页用了export const dynamic = "force-static",让 Next.js 在构建时直接生成静态 HTML,访问时走 CDN 缓存,后端零计算。只有真正依赖用户数据的页面(dashboard、项目详情)才保留动态渲染。这一策略组合下来,页面首次加载速度有明显提升。实际测试中,静态页 TTFB 从 200ms 左右降到 20ms 以下,用户体验感知非常明显。
4. 实战中的坑与排查心得
4.1 类型报错里最常见的三个根因
tRPC 写完代码一编译就报一堆类型错误,这种场景我几乎天天遇到。排查下来,大部分问题就三个原因。
根因一:Prisma 更新类型没重新生成。改了 schema,但忘了跑prisma generate。这会导致ctx.db.project对应的类型还是旧结构,你新加的字段在类型层不存在。解法很粗暴:先跑pnpm prisma generate,再重开 IDE 的 TS Server。VS Code 经常不会自动加载新生成的类型,Ctrl+Shift+P执行 “TypeScript: Restart TS Server” 可以解决。
根因二:tRPC procedure 的 input 类型和实际入参不匹配。我踩过一个很隐蔽的坑:zod schema 里字段是coerce.number(),前端传的是字符串,虽然运行时能过校验,但 TS 类型推导出来是 number,前端传 string 时编辑器会哗哗报错。凡是遇到 z.input 和传参类型不一致,先去看有没有被z.coerce或者z.preprocess改变类型。
根因三:NextAuth 的 session 类型覆盖没生效。这个前面提过了,不写next-auth.d.ts的类型声明,所有使用session.user.id的地方全是报错。而且 NextAuth 类型拓展的文件必须被 tsconfig include 到,否则声明文件就是一张废纸。
4.2 鉴权失效与 session 过期排查
线上收到“用户明明登录了却显示未登录”的反馈,是个比较经典的问题。排查路径通常是有先后顺序的。
第一步,确认 session 是否真的存在。在受保护路由里加日志打印getServerSession的结果。如果这里返回 null,说明浏览器侧的 session cookie 没有带到服务器。看看是不是前后端域名不匹配,或者 cookie 的 secure 属性在 http 环境下被浏览器拦截。
第二步,确认 JWT 是否过期。token 的 session 策略下,JWT 默认有效期是 30 天。但如果用户长期不访问,token 过期之后,第一次访问会重定向登录页,体验不好。t3code 里我做了滑动过期处理:只要用户有活跃请求,就刷新 token 的过期时间。实现思路是在 JWT callback 里检查剩余过期时间,如果低于阈值就重新签发。
第三步,查 middleware 和 tRPC 的 session 来源是否一致。这是个容易忽略的点。Next.js 的 middleware 运行在 Edge Runtime,tRPC 里的getServerSession运行在 Node Runtime,两者读 cookie 的方式并不完全一样。如果你在 middleware 和 tRPC 里用了不同的 session 校验逻辑,很容易出现“页面能进,但接口 401”的问题。t3code 里两边统一走 NextAuth 的getToken,保证判断口径一致。
4.3 部署后的数据库连接与迁移问题
本地一切正常,部署到线上却疯狂报数据库连接错误,这是新手最容易破防的场景。排查方向集中在两块。
连接串问题。本地开发用的localhost:5432在云端肯定不通。上生产之前,在服务器上先跑一次pnpm prisma migrate status,如果连不上会直接报错,这样能快速确认网络链路和账号权限有没有问题。另外,连接串里不要带?schema=public以外的乱七八糟参数,有些参数在 Prisma 里会造成诡异的连接失败。
迁移状态问题。部署后如果发现数据库表结构不对,最常见的原因是你新加的 migration 没提交到仓库,或者 CI 里根本没有执行migrate deploy。建议在 CI 流程里把迁移和构建串成一条链:先migrate deploy,成功后再next build。这样因为表结构缺失导致的构建错误会在 CI 阶段暴露,而不是用户访问时才炸。
连接池大小问题。线上流量稍微上来一点,数据库连接数就开始告警。除了前面说的 global 实例方案,还可以在 Prisma Client 初始化时限制连接数:
new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL, }, }, })如果跑在 Vercel 的函数环境里,每个函数实例都会创建自己的 Prisma Client,连接数会随着实例数叠加。有些团队会选择把 Prisma Client 放到核心包,或者改用连接池代理,但这会引入额外运维复杂度。t3code 在项目初期没有用代理,只通过限制 Vercel 函数并发数来控制峰值连接数,在量级没上来之前够用。
5. 一点实际体会
把 t3code 从想法到落地整个过程走完后,我最大的感受是:T3 Stack 的价值不是帮你少写几行代码,而是帮你少操几类心。类型系统把接口对接的事从“运行时调试”提前到“编译期检查”,tRPC 把前后端沟通成本压到最低,Prisma 让数据库操作既有类型提示又有迁移管理。这套组合的威力要在超过一定体量的项目里才能体现出来,小项目反而不容易感受到差异。
最后分享一个细节习惯。我在开发 t3code 时给所有 tRPC router 的 procedure 加了统一的错误处理包装,自定义错误类会映射成自定义 code,前端根据 code 决定弹什么提示。否则默认情况下,tRPC 会把所有异常都变成INTERNAL_SERVER_ERROR,用户看到的永远是“Something went wrong”,根本没法做精细化错误提示。这个习惯后来带到了所有项目里,协作效率提升了一个档次。