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 有 codegenOpenAPI 可以生成 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的返回类型PromiseNote[]推断出来并暴露给前端程序。前端调用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: Recordstring, 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-applatest 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 这套结构让我能更笃定地改代码——类型系统会在我改错时第一时间喊停而不是等测试环境跑挂了才被发现问题。如果你也在搭全栈项目不妨拿一个小功能先试一下这套组织方式运行三五天之后你大概就能感受到类型安全优先和功能优先但顺手写类型之间的差别。