t3code:基于T3 Stack的全栈类型安全项目脚手架实践
最近整理自己的全栈项目脚手架,我给仓库起了一个挺朴素的代号:t3code。起这个名字不是因为它有多高级,而是这个项目里所有代码都围绕 T3 Stack 的核心理念来组织——TypeScript 优先、前后端类型安全打通、业务代码按功能边界收敛。今天这篇文章想把 t3code 背后的完整思路拆开聊:包括目录结构怎么设计、类型如何从数据库一路流到前端组件、实际开发中会踩到哪些坑,以及从 create-t3-app 初始化到第一个 Feature 闭环落地的具体步骤。如果你正在用 Next.js + Prisma + tRPC 写全栈应用,或者准备搭一个新项目脚手架,这篇内容应该能直接帮你省掉不少试错时间。
很多同学听到 T3 Stack 就以为是某个单一框架,其实它是一套技术选型组合:Next.js 负责应用层、tRPC 负责 API、Prisma 负责数据库访问、Tailwind 负责样式,再加上 TypeScript 作为贯穿始终的类型语言。t3code 想强调的并不是这些框架本身,而是"如何围绕类型安全去组织代码"这件事。下面我从问题开始讲。
1. 为什么给这个项目起名 t3code:全栈项目最容易被忽视的类型断层
1.1 我看到的大多数全栈项目代码长什么样
我调研过不少团队的全栈项目,也接手过几个所谓"前后端分离"的老项目。它们的典型结构是这样的:后端在services和controllers里写接口,返回 JSON;前端在src/api目录下维护一份手写的xxx.d.ts,把每个接口的请求参数和响应类型手动声明一遍。表面上看很规范,实际上一旦后端接口改了一个字段名,前端这份手写类型几乎不会同步更新。代码能编译、页面能打开,但运行时某个字段突然变成undefined,然后整个页面白屏。这种问题在联调阶段特别常见,而且排查成本很高,因为你不知道是后端没返回、前端拼错了,还是中间某层过滤掉了字段。
t3code 这个脚手架想解决的核心问题,就是这种所谓"类型断层"。什么叫类型断层?就是同一个业务对象在数据库、后端、前端分别被定义了多次,而且这些定义之间没有自动同步机制。数据库字段叫created_at,后端 DTO 叫createdAt,前端接口类型可能叫createTime。三个名字都对,但互相没有关系,全靠程序员手动维护。项目小的时候还能靠记忆撑住,项目一旦超过两万行,这种维护成本会指数级上升。
1.2 类型断层会带来哪些真实成本
可能有人会觉得"类型不一致,运行时测出来改掉不就行了"。实际远没那么简单。我举一个很普通的例子:一个后台管理系统有 20 个页面,某个订单列表接口原先返回goods_id,后来为了统一命名改成goodsId。后端改起来很容易,前端因为理论上"看不到后端代码",只能靠全局搜索goods_id去猜哪些地方在用。如果前端有人做过数据映射层,可能只改一个文件;如果没有,那就要改十几个文件,而且漏掉任何一个文件都会在线上才爆发。
这还只是字段改名一个场景。更麻烦的是数据结构变化,比如原来meta是对象,现在变成数组。手写类型定义里如果没有跟上来,前端代码根本不会报错,直到某一页调用meta.title才发现拿到的是undefined。这种运行时错误通常伴随复杂业务逻辑,定位到根因可能花半天。所以我在 t3code 里定了一条硬性规则:任何一份业务类型定义都尽量只存在一个源头,其余全部自动推导或自动生成,不在任何地方手动复制类型。
1.3 为什么选 T3 Stack 作为 t3code 的底座
市面上能解决类型同步的方案其实不少:GraphQL 有 codegen,OpenAPI 可以生成 SDK,也可以直接用 protobuf。但 T3 Stack 的思路特别适合中小型全栈项目:它没有引入一套独立的 schema 语言,而是直接用 TypeScript 本身的类型系统去穿透前后端。Prisma 定义数据库模型后可以生成类型,tRPC 路由通过泛型推导把输入输出类型推给前端,前端组件调用api.xxx.useQuery时,拿到的data类型就是后端真实的返回类型。整个过程没有手写类型定义,也没有中间层代码。
t3code 选了这组技术栈,但并没有止步于"把它们装到一起"。框架只解决"类型能同步"的问题,代码怎么组织才是关键。同一段业务逻辑是放在pages/api、components还是services里?一个功能涉及的表单、接口、列表、权限怎么聚合?这些框架都不会替你回答。所以我花更多精力设计了一套按 Feature 聚合的目录结构,这也是 t3code 和默认 create-t3-app 模板最大的区别。
2. t3code 的目录结构:按功能边界聚合,而不是按技术栈分层
2.1 先看目录树
很多人从 create-t3-app 初始化项目后,会沿用它的默认结构,把所有代码都放在src下面,然后随着功能增加越堆越多。t3code 在默认结构上重新做了拆分,核心思路是:先按业务功能划分子系统,再在每个子系统内部按技术角色分目录。我实际用的目录树大概长这样:
apps/web/src ├── app/ # Next.js App Router 页面与路由 │ ├── (marketing)/ │ ├── dashboard/ │ └── api/ ├── features/ │ ├── notes/ │ │ ├── api/ │ │ │ └── note.router.ts │ │ ├── components/ │ │ ├── hooks/ │ │ ├── schemas/ │ │ ├── types/ │ │ └── index.ts │ ├── auth/ │ │ ├── api/ │ │ ├── components/ │ │ ├── hooks/ │ │ ├── schemas/ │ │ └── index.ts │ └── team/ │ ├── api/ │ ├── components/ │ ├── hooks/ │ ├── schemas/ │ └── index.ts ├── shared/ │ ├── config/ │ ├── lib/ │ ├── utils/ │ └── types/ └── server/ ├── db/ │ └── prisma.ts ├── auth/ └── trpc.ts注意这里的features不是传统意义上的页面分类,而是业务模块。notes是一个业务模块,auth是另一个,team是第三个。每个模块内部都有自己的一套 API、组件、Hooks、校验规则和类型,模块之间尽量不要互相引用,而是通过统一的 tRPC 入口或者shared公共层交互。
2.2 "每个 Feature 是一个独立模块"的规则
为了让这种结构真正可维护,t3code 里定了三条很具体的规则。
第一条,每个 Feature 只能通过自己的index.ts暴露公开内容。外部代码可以import { NoteCard } from "~/features/notes",但不允许直接import NoteCard from "~/features/notes/components/NoteCard"。这样做的好处是,模块内部结构调整时,外部调用方完全不受影响。以后想把 NoteCard 从components挪到components/cards,只需要改模块内部和index.ts,其他页面不用动。
第二条,Feature 和 Feature 之间禁止深层引用。比如team模块想展示notes模块的数据,不能直接去import { NoteList } from "../notes",而是应该通过 tRPC 接口调用api.note.list.useQuery()。这么做看起来多了一步,但保证了模块之间的依赖方向是单向的、可控的。否则一旦两个模块互相引用,改起来就会变成一团乱麻。
第三条,每个 Feature 必须有自己的 Validation Schema。我用 Zod 定义请求参数和表单校验规则,比如schemas/createNoteSchema.ts。tRPC 的输入校验、前端表单校验、类型定义都从这一份 Schema 推导。条规则避免有人在 API 层写一套校验、前端表单又写一套校验,校验规则慢慢走叉的情况。
2.3 依赖方向与共享代码的位置
t3code 的依赖方向可以简单概括为:页面→Features→shared/server。页面只负责组合各 Feature 的组件和调用 tRPC 接口;Feature 之间不直接依赖;公共配置、工具函数放在shared;数据库访问和 tRPC 根路由放在server。这个方向一旦确定,后续写代码的时候就只需要问一个问题:这段逻辑属于哪个 Feature?如果属于多个 Feature,那就下沉到shared或者提升到server公共层。
我做了一个简单的依赖规则表,方便团队同学对照:
| 场景 | 允许做法 | 禁止做法 |
|---|---|---|
| 页面要显示某个 Feature 的列表 | 通过该 Feature 的index.ts导入组件 | 直接导入 Features 内部文件 |
| Feature A 需要读取 Feature B 的数据 | 通过 tRPC 调用api.b.query() | 直接 import 对方模块内部逻辑 |
| 多个 Feature 共用一个日期处理函数 | 把函数放在shared/lib再导入 | 在各自模块里复制一份 |
| 访问 Prisma Client | 通过server/db统一导出 | 在新.router 里直接 new PrismaClient() |
这套规则执行一段时间后,最大的感受是:代码的位置变得可预测了。看到一个新需求,你会先判断它属于哪个模块,再决定代码放哪里,而不是在components、utils、services几个顶层目录之间犹豫。
2.4 与常见分层架构的对比
以前我写全栈项目习惯用经典分层:controllers、services、models、components、hooks全部平铺在顶层。功能少的时候看起来清爽,一旦功能变多,每个目录里都会堆几十个文件,你很难快速定位"订单相关的组件"到底在components/order还是components/business/order。更麻烦的是,修改一个功能往往要同时动控制器、服务、组件、Hooks,散落在多个目录里,Pull Request 的 diff 很难读。
t3code 的按 Feature 聚合相当于把"垂直切片"变成了第一级组织方式。一个功能从 API 到 UI 的所有代码都在同一个目录里,改动集中在一处,PR diff 也就很清晰。当然这种结构也不是没有问题:如果模块划分不合理,容易把太多逻辑塞进一个 Feature,反而不如分层清晰。我的经验是,模块边界最好跟业务边界一致,比如"笔记"、"团队"、"认证"这种天然边界,而不是按数据实体去硬拆。
3. 类型从数据库流到前端:t3code 的核心链路
3.1 Prisma Schema 是唯一的类型源头
在 t3code 里,业务字段的"最终解释权"归 Prisma Schema。也就是说,一个用户或者一笔订单有哪些字段,以schema.prisma为唯一事实来源,其他任何地方都不再手动维护。比如笔记模块的模型:
model Note { id String @id @default(cuid()) title String content String authorId String visibility Visibility @default(PRIVATE) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId]) } enum Visibility { PRIVATE PUBLIC }每改动一次这个 Schema,运行npx prisma generate后,Prisma Client 的类型就会自动更新。接下来的关键一跳是:把 Prisma 生成的这些类型接进 tRPC 路由器,让前端能够透明地拿到精确类型,而不是自己再写一遍Note接口。
3.2 tRPC 路由如何自动产生前端可用类型
tRPC 有一个很特别的设计:路由器不依赖单独的接口描述文件,返回值类型和输入类型直接由 TypeScript 推导。我在features/notes/api/note.router.ts里写的代码大致是这样的:
import { z } from "zod"; import { router, protectedProcedure } from "~/server/trpc"; import { prisma } from "~/server/db"; import { createNoteSchema } from "../schemas/note.schema"; export const noteRouter = router({ list: protectedProcedure .input(z.object({ authorId: z.string() })) .query(async ({ input }) => { return prisma.note.findMany({ where: { authorId: input.authorId }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(createNoteSchema) .mutation(async ({ input, ctx }) => { return prisma.note.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, visibility: input.visibility, }, }); }), });这段代码里没有一行手写的interface ListResponse。tRPC 会自动把list的返回类型Promise<Note[]>推断出来,并暴露给前端程序。前端调用api.note.list.useQuery()时,TypeScript 会知道data是一个Note[],每一项有id、title、content、visibility等字段。这就像前后端之间有一条隐形的类型管道,任何一端的类型变化,另一端在编译期就能感知。
3.3 组件里调用 API 的真实代码
很多第一次接触 tRPC 的同学都会问:前端真的不用写类型映射吗?真的不用。我在一个 React Server Component 下的客户端组件里调用api.note.list,代码长这样:
"use client"; import { api } from "~/server/trpc/react"; import { useParams } from "next/navigation"; export function NoteList() { const { data: notes, isLoading } = api.note.list.useQuery({ authorId: "current-user-id", }); const createNote = api.note.create.useMutation({ onSuccess: () => { utils.note.list.invalidate(); }, }); if (isLoading) return <div>加载中...</div>; return ( <ul> {notes?.map((note) => ( <li key={note.id}> <h3>{note.title}</h3> <p>{note.content}</p> </li> ))} </ul> ); }注意两点:第一,api.note.list这个对象上的useQuery方法是自动生成的,字段名都跟路由器里的定义一一对应;第二,notes的类型不需要any,也不需要断言,它就是Note[] | undefined。如果后端把title改成name,这里访问note.title会在 TypeScript 编译阶段直接报错,而不是等页面运行时白屏。这种"用编译器兜底业务变更"的能力,才是 t3code 反复强调类型同步的最终目的。
3.4 GraphQL、OpenAPI 与 tRPC 的类型同步逻辑对比
我经常被问:为什么不用 GraphQL?为什么不用 OpenAPI 生成 SDK?这三者其实都在解决同一个问题,只是思路不同。GraphQL 用一个 schema 描述整个数据图,再用 codegen 生成前端类型,功能很强大,但必须维护一套 GraphQL Schema,而且查询语句本身也是一种额外负担。OpenAPI 需要先写一份openapi.yaml或者通过装饰器自动生成,再生成客户端 SDK,链路较长,前后端类型同步依赖工具链是否勤快。
tRPC 最直接的地方在于没有中间描述文件,类型完全从函数签名推导。代价是它更适合前后端都使用 TypeScript 的项目,而且前后端代码往往在同一个仓库或使用 monorepo。t3code 默认就是这种"同构全栈"场景,所以 tRPC 是最顺滑的选择。如果你未来需要强开放的第三方 API,再单独针对外部接口补充 OpenAPI 文档也不冲突。
4. 复刻 t3code 时踩过的坑:序列化、校验、动态类名
再完美的架构落地时也会踩坑。这里记录三个我实际遇到的、而且网上经常语焉不详的问题,都是复刻 t3code 时容易卡的细节。
4.1 Prisma 查询结果不能直接返回给前端
我一开始偷懒,在 tRPC 路由里直接把 Prisma 查询结果return出去,想着反正类型能穿透。结果前端拿到的日期字符串和本地时区对齐方式很奇怪,而且有时Decimal字段会被序列化成字符串而不是数字。原因在于 Prisma 的日期类型是 JavaScript 的Date对象,JSON 序列化时按 UTC 字符串输出,而Decimal类型为了精度会序列化成字符串。如果你在前端做数值计算,直接把price + 1会得到字符串拼接而不是数字。
解决办法是在返回前做一次明确的 DTO 转换,或者至少把日期格式化成字符串、把 Decimal 转成 number。我通常会在features/notes/api/note.dto.ts里写一个转换函数:
import type { Note } from "@prisma/client"; export function toNoteDTO(note: Note) { return { id: note.id, title: note.title, content: note.content, visibility: note.visibility, createdAt: note.createdAt.toISOString(), updatedAt: note.updatedAt.toISOString(), }; }t3code 的规则是:内部查询可以返回 Prisma 原生对象,但凡是跨过 tRPC 边界的,必须经过 DTO 转换。这虽然多写几个字,但能避免大量序列化陷阱。
4.2 tRPC 服务端调用与客户端调用的上下文差异
t3code 里同时存在两种调用方式:浏览器里的useQuery,以及 Next.js Server Component 里直接调用 tRPCcaller。一开始我以为它们只是写法不同,实际上上下文差异很大。tRPC 的createContext会在每次请求/调用时执行,浏览器调用时你能从req和res里拿 session;服务端调用时,getServerSession依然可用,但某些依赖 Next.js 请求范围的逻辑会拿不到完整上下文。
我踩过的坑是:在某处 Server Component 里用createCaller调用api.note.list,但那个路由依赖ctx.session.user.id,结果发现 session 为空。查了半天,根源是我在createContext里把 session 获取写得太早,优先从req.headers.cookie解析,而 Server Component 调用没有经过标准中间件。解决办法很简单:在createContext里统一调用getServerSession(authOptions),不依赖req,让浏览器调用和服务端调用都走同一套 session 获取逻辑。如果确实需要区分,再显式判断typeof window === "undefined"。
4.3 Tailwind 动态类名被 purge 掉的问题
这个坑其实和类型无关,但使用 T3 Stack 几乎逃不掉。我一开始喜欢写类似bg-${color}-${variant}的拼接类名,比如bg-blue-500、bg-red-500,结果页面颜色始终不生效。原因是 Tailwind 在构建时会扫描源码里的完整类名,像bg-${color}-500这种动态拼接字符串无法被识别,所以不会生成对应 CSS。
t3code 推荐的做法是用一个显式的映射表替代字符串拼接:
const colorClasses: Record<string, string> = { blue: "bg-blue-500 text-white", red: "bg-red-500 text-white", gray: "bg-gray-200 text-gray-700", }; // 使用时直接取完整类名 <button className={colorClasses[color]} />这样 Tailwind 可以扫描到bg-blue-500这些完整字符串,样式才会正常生成。这个问题虽然不起眼,但在项目里会浪费好几个小时。
5. 从 create-t3-app 到 t3code:落地步骤与推荐配置
5.1 用 create-t3-app 初始化
如果大家想按 t3code 的思路搭一个自己的项目,最省事的方式是从官方脚手架开始。执行:
pnpm create t3-app@latest my-app交互式选择时,建议勾选Next.js、tRPC、Prisma、Tailwind CSS、NextAuth.js。TypeScript 是默认选项,一定要保留。脚手架初始化完成后,项目已经具备了最基本的类型安全链路:Prisma Client 生成类型、tRPC 路由暴露给前端、Tailwind 支持类名。后面要做的不是重搭,而是按照 t3code 的目录规则重构src。
5.2 补上的关键配置
初始化之后有几项配置我会顺手补上。第一是tsconfig.json的路径别名,让@/server/*、@/features/*这些路径不依赖相对路径到处飘:
{ "compilerOptions": { "baseUrl": ".", "paths": { "~/*": ["./src/*"], "~/features/*": ["./src/features/*"], "~/server/*": ["./src/server/*"] } } }第二是把 ESLint 的simple-import-sort/imports开起来,让 import 顺序自动统一,减少不必要的 diff。第三是明确规定 Prisma Schema 中模型名、字段名统一用 camelCase,避免 SQL 风格的created_at和 TypeScript 风格的createdAt混在一起。这些配置都很小,但能决定项目在三个月后是否还愿意让人维护。
5.3 第一个可运行的 Feature 闭环
我建议复刻 t3code 时,不要一上来就追求完整,先打通一个 Feature 全链路。拿笔记模块举例,大致步骤是:
- 在
schema.prisma里定义Note模型和Visibility枚举。 - 执行
npx prisma migrate dev --name init-note,生成数据库表并重新生成 Prisma Client。 - 在
features/notes/schemas/note.schema.ts里写 Zod 校验 Schema。 - 写
features/notes/api/note.router.ts,定义list和create两个 procedure。 - 在
server/trpc.ts的 rootRouter 里注册noteRouter,并导出api给前端用。 - 在页面组件里写
api.note.list.useQuery()和api.note.create.useMutation(),完成数据的读和写。
运行pnpm dev,打开页面,你会发现从数据库字段到页面渲染,类型都是闭环的。以后后端想加一个publishedAt字段,先改 Prisma Schema 重新 generate,再改 noteRouter,前端组件会在编译期收到类型更新。
5.4 一些可以继续扩展的方向
t3code 目前更多是一个代码组织范式,不是全家桶。基于这个底座还能扩展不少方向:比如文件上传不要用 tRPC 直接传 base64,而是配合对象存储做预签名 URL;实时通知可以考虑 tRPC subscription 或 WebSocket 网关;需要做单元测试时,可以为 tRPC 路由写超轻量的集成测试,因为整个 API 层就是一组普通函数。我在实际使用中最大的体会是,t3code 这套结构让我能更笃定地改代码——类型系统会在我改错时第一时间喊停,而不是等测试环境跑挂了才被发现问题。如果你也在搭全栈项目,不妨拿一个小功能先试一下这套组织方式,运行三五天之后,你大概就能感受到"类型安全优先"和"功能优先但顺手写类型"之间的差别。