t3code:T3全栈模板如何打通类型安全与业务分层 先说结论如果你和我一样每次开新全栈项目都要在 Next.js、TypeScript、tRPC、Prisma 之间重新接线那么 t3code 就是一个可以少走弯路的起点。它不是官方 create-t3-app 的替代品而是我在维护多个业务项目后整理出来的一套实践模板保留 T3 技术栈端到端类型安全的核心同时把服务端组件、认证上下文、数据更新缓存这些容易写乱的部分做成固定套路。这篇文章聊聊 t3code 为什么这样设计、核心代码怎么组织以及实测里我踩过的几个坑。适合正在搭全栈模板、对类型安全有执念、或者刚接触 T3 技术栈的读者。1. 为什么我会做一个叫 t3code 的项目模板1.1 从“复制祖传项目”到“自己维护接线规范”很多团队的老项目里都藏着历史包袱早期为了赶进度把业务逻辑全堆在页面文件里后来抽象了一层 lib再后来引入 tRPC 就乱了一个 router 文件长了上千行schema 散落各处连数据库字段改名都要全局搜索好几遍。我手上几个项目就是这么过来的每次新项目启动最省事的办法是复制那个最完整的仓库然后删掉不需要的页面。但删除过程本身就是一场冒险删掉一个依赖另一处隐式引用马上崩改了一个环境变量构建时才发现还有一处硬编码。往往项目还没写业务先花一下午清理环境配置、过时的依赖和冲突的类型定义。t3code 就是想解决这个复制粘贴问题。它不是传统意义的脚手架——脚手架生成一次就结束了后续改动还是会散。我更愿意把它看成一套“接线规范”数据库表、API、页面组件、客户端缓存这四层之间用什么方式连起来类型走到哪一步都由同一个模式约束。新项目从模板出发业务代码是干净的老项目也能照着它重构一步步把跳层调用改回来。我最初给自己定的目标是从拉取模板到跑通一个带登录、带数据库、带列表页面的最小业务控制在十分钟以内。现在 t3code 基本能做到。1.2 命名与定位t3code 就是“T3 技术栈的代码组织”t3code 这个代号拆开看就是 T3 Stack Code。T3 技术栈通常指 Next.js、TypeScript、tRPC、Tailwind CSS、Prisma 这套组合核心吸引力是端到端类型安全数据库表结构变了TypeScript 编译会直接报错不用靠记忆维护接口文档。但 T3 官方 create-t3-app 生成的目录其实偏扁平沿用 starter 里示例的写法适合小项目快速起步业务一旦复杂router、schema、组件之间怎么分层就需要自己立规矩。我整理的 t3code 就是在 create-t3-app 基础上额外定义了一套模块化目录和边界规则。它面向两类人一类希望把全栈模板沉淀为团队标准的开发者另一类是刚接触 tRPC、想看到一个完整链路如何工作的新手。它不做业务框架也不封装黑盒所有代码都摊开你看得见每条数据是怎么走的。如果某天你不认同某个约定完全可以只改自己的模块其他部分不受影响。2. 从 create-t3-app 出发默认配置里我改了什么2.1 保留的技术栈及联动关系先放一张我保留的选型表方便对照层技术作用应用框架Next.jsApp Router页面、路由、服务端组件语言TypeScript strict全链路类型约束ORMPrisma数据库映射生成类型APItRPC前后端同构的远程调用缓存TanStack Query异步状态管理和服务端缓存样式Tailwind CSS原子化 CSS认证NextAuth.jssession 管理与 OAuth 接入这层组合的逻辑是Prisma 生成的类型可以作为 tRPC 的输出类型来源tRPC router 的输入用 zod 在运行时校验校验规则和 TypeScript 类型可以在前后端共享页面里用 TanStack Query 的 hooks 自动把 tRPC 调用变成带缓存的状态。每次数据从 MySQL 或 PostgreSQL 里查出来直到渲染成 UITypeScript 类型都没有断过。这一点是 t3code 的立身之本也是我选型时最先确认的前提数据库层必须能生成强类型API 层必须能复用这些类型否则就退回到手写接口文档的旧路。2.2 我调整掉的五个默认习惯官方模板并不是所有默认项都适合长期维护我做了五处调整第一把 tRPC router 从“按模型堆文件”改为“按业务域拆分”。官方 create-t3-app 示例里的 post router 适合演示但真实项目里一个用户域除了用户信息还有积分、关注关系全堆在 user.ts 里会爆炸。t3code 改成 features 目录每个目录自治router、schema、组件都放在一起。第二环境变量集中校验。以前 DATABASE_URL 漏配置往往要运行到一半才报错。我在 src/env.js 里加了一层 zod 校验启动前统一检查缺哪个变量直接列出而不是让 Prisma 或 NextAuth 给出一个上下文无关的报错。第三NextAuth 的 session 策略改为只在数据库存 sessionJWT 里只放必要信息。这样多实例部署时不会出现 session 不同步代价是每次请求多一次查询但业务上值得。第四给 prisma generate 加了 postinstall 钩子。这个后面细说也是协作踩坑最多的地方。第五把全局错误处理统一到 tRPC 中间件里而不是每个 resolver 里 try/catch。业务层只管抛 ZodError 或自定义业务错误中间件负责格式化响应和记录日志。调整的原则很简单凡是“每个业务模块都会重复做”的事情都往基础设施里收凡是“只有某个模块才关心”的事情都往 features 里放。模板代码不是越少越好而是要让新业务代码保持重复、直白、不绕弯。2.3 没有做选择的两个地方我也看到网上很多人给 T3 模板加 monorepo、加 storybook、加 docker 编排但 t3code 刻意没有默认集成。原因不是它们不好而是模板一上来就引入太多概念新人很难分清哪些是业务必须、哪些是工程化增强。monorepo 我保留最小支持src 内部按 domain 分但并没有拆成多个 package。等团队真的面临独立发布需求时再拆也不迟。还有一个地方是状态管理。T3 官方推荐使用 TanStack Query 处理服务端状态再用 useState 或 zustand 处理客户端状态。我默认只存 TanStack Query不配置全局 store。因为大部分全栈应用里真正需要共享的客户端状态很少一上来就上 store最后往往变成缓存和业务状态混在一起非常难调试。真有需要框架也能随时加。3. t3code 的目录层级与端到端类型流3.1 一眼能看懂的目录结构t3code ├── src │ ├── app # Next.js 页面路由 │ │ ├── (auth) # 登录、注册相关布局 │ │ └── dashboard │ ├── features # 业务域按域独立 │ │ ├── posts │ │ │ ├── api # tRPC resolver 和 router │ │ │ ├── components # 页面用到的组件 │ │ │ └── schemas # zod 输入校验 │ │ └── users │ ├── server # 服务端基础设施 │ │ ├── api │ │ │ ├── trpc # tRPC 初始化、context、中间件 │ │ │ └── root.ts # 聚合所有子 router │ │ └── db # Prisma client 单例 │ ├── styles │ └── env.js # 环境变量校验 └── prisma └── schema.prisma这个结构最核心的一点是把“基础设施”和“业务模块”分开。src/server 下的东西基本很少改动属于平台src/features 下每个目录是垂直切片数据库访问、API 逻辑、UI 组件都离得很近。新人加入项目时第一眼就能判断“我要改的帖子功能在哪”不需要从 app 目录一路翻到 lib 再翻到 api。这也是 t3code 和 create-t3-app 默认结构最大的差异默认结构适合展示技术栈t3code 适合承载真实业务。3.2 类型安全如何从数据库流到页面这一节用一个小例子说明 t3code 的类型流。假设 posts 表有 title 和 content 两个字段model Post { id String id default(cuid()) title String content String authorId String author User relation(fields: [authorId], references: [id]) createdAt DateTime default(now()) }运行prisma generate后Post 类型可以被 tRPC router 引用。现在写一个查询 resolverimport { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; import { db } from ~/server/db; export const postsRouter createTRPCRouter({ list: protectedProcedure .input(z.object({ cursor: z.string().nullish() })) .query(async ({ input }) { const posts await db.post.findMany({ take: 20, skip: input.cursor ? 1 : 0, cursor: input.cursor ? { id: input.cursor } : undefined, include: { author: { select: { name: true } } }, orderBy: { createdAt: desc }, }); return { items: posts, nextCursor: posts[20]?.id }; }), });注意返回的 items 类型完全由 Prisma 推断出来我没有手写。前端调用时const { data } api.posts.list.useQuery({ cursor: null }); // data?.items 的类型Post { author: { name: string } }[]如果哪天后端把 content 字段改成 contentHtml前端编译立刻失败而不是等到运行时才发现字段缺失。这就是 t3code 最看重的链路价值。我第一次体会到这种爽感是在一次数据库重构里把 user 和 profile 表合并改完 schema前后端差不多二十个文件直接编译报错沿着报错一路修完没有漏掉任何引用。换成传统 REST这种改动至少要抓一个测试环境跑一遍才能放心。3.3 tRPC router 如何避免“一团乱麻”业务一多人们容易把 router 写成一个巨大的对象所有 procedure 平铺在同一个文件里。t3code 的做法是每个域一个子 router在 root.ts 里聚合import { createTRPCRouter } from ~/server/api/trpc; import { postsRouter } from ~/features/posts/api/posts.router; import { usersRouter } from ~/features/users/api/users.router; export const appRouter createTRPCRouter({ posts: postsRouter, users: usersRouter, }); export type AppRouter typeof appRouter;这样前端调用链路由前缀自然分组api.posts.list、api.users.profile。如果你还想更细可以在域内继续开子 router比如posts.comments.list但建议不要超过两级否则调用链太长IDE 自动补全也会失去优势。还有一个容易被忽视的好处子 router 之间可以相互引用但引用方向要控制好。比如 posts router 可以调用 user router 的某个查询来补充作者信息但千万不要让 users router 反过来依赖 posts router否则业务域之间的耦合会悄悄长出来。3.4 页面层如何安全消费类型页面层消费类型有两种方式客户端组件用 hooks服务端组件用 createCaller。很多人第一次接触时会被“我到底该在组件里 useQuery 还是直接拿数据”绕晕。t3code 的约定是如果这个页面需要交互、需要数据刷新就走客户端 hooks如果这个页面是内容型、偏静态就走服务端 caller 取一次数据渲染完交给客户端。这样既不会在服务端组件里触发 hooks 错误也不会让每个页面都变成巨大的 client component。4. 核心业务模块串联认证、查询、更新一条龙4.1 把 NextAuth 的会话塞进 tRPC Contextt3code 里的认证不是孤立的而是渗透到每个查询。关键代码在 tRPC context 里import { getServerSession } from next-auth; import { authOptions } from ~/server/auth; import { db } from ~/server/db; export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerSession(authOptions); return { db, session, ...opts, }; };然后在 trpc.ts 里定义 protectedProcedure它是普通 procedure 加上一层会话检查中间件export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new Error(UNAUTHORIZED); } return next({ ctx: { session: ctx.session } }); });这样 posts.list 只需用 protectedProcedure未登录请求会被中间件拦下。相比之下很多人习惯在页面里判断 session 再决定调不调接口那只能挡住 UI挡不住直接请求 API 的人。t3code 把认证边界放在 procedure 层面所有需要登录的 API 都从这一层统一保护才叫真正的认证边界。这个细节在单体模板里看着不起眼一旦将来要接第三方客户端或写自动化测试你就会发现 API 层自带鉴权有多省事。4.2 一个完整业务 CRUD从 schema 到组件接着 posts 模块再看更新操作。t3code 的更新流程是组件里先调用 mutation hooks成功后让相关查询缓存失效UI 自动刷新。核心代码const utils api.useUtils(); const createPost api.posts.create.useMutation({ onSuccess: async () { await utils.posts.list.invalidate(); }, }); function submitNewPost() { createPost.mutate({ title: hello, content: world }); }posts.create 的定义用 zod 做输入校验import { z } from zod; export const createPostSchema z.object({ title: z.string().min(1).max(100), content: z.string().min(1), }); export const postsRouter createTRPCRouter({ create: protectedProcedure .input(createPostSchema) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), });运行时校验、权限判断、数据库写入在这一个 resolver 里完成客户端不需要再重复校验。你可能会问为什么不让组件直接调用 Next.js 的 route handler因为那样要额外维护 API 路由和请求/响应类型tRPC 把这一层透明化了调用就是函数调用类型不会丢。模板的价值就在这种地方它不是送你一个函数而是帮你在源头减少一类问题。4.3 客户端更新后的缓存同步TanStack Query 常用的坑是 mutation 成功后没有让 list 查询失效导致新数据不出现。t3code 把 invalidate 写进每个 mutation 的 onSuccess 里而不是组件里临时写。因为如果每个页面各写各的改一处漏一处写在业务模块的 hooks 里可以做到“新增后刷新列表”这个行为全局一致。我见过很多项目刚开始没问题后来加了一个列表筛选条件忘记把失效范围带上用户提交新内容后列表一直停留在旧状态就是这类问题。某些高频场景比如列表加载很慢全量 invalidate 会闪烁。可以退一步用 setData 手动把 mutation 返回值写入缓存api.posts.create.useMutation({ onSuccess: (newPost) { utils.posts.list.setData({ cursor: null }, (old) ({ items: old?.items ? [newPost, ...old.items] : [newPost], nextCursor: old?.nextCursor ?? null, })); }, });这是针对乐观更新的优化。日常项目建议先跑通 invalidate等真的慢了再考虑手写缓存。提前优化是模板最容易犯的错t3code 默认把最简单可靠的版本给你把复杂选项留到需要时再打开。4.4 SSR 或静态生成时怎么预取数据除了客户端 hookst3code 也支持在服务端预取数据。一种做法是直接用 createCaller另一种是用 TanStack Query 的 prefetch 把数据写进 hydration。我大多数时候用前者因为代码更直白服务端 caller 拿到数据后传给页面组件配合useState做初值减少一次请求。但如果你要做的页面里有大量依赖同一批数据的交互组件prefetch hydration 能让数据在客户端激活时立即可用。两种方式都会接触才能真正理解类型安全在哪一层断掉。5. 实测里绕不过去的坑服务端组件、类型生成与环境变量5.1 服务端组件里别直接用 query hooksApp Router 普及后我见过不少人尝试在 Server Component 里直接写api.posts.list.useQuery()一运行就报错。原因很简单TanStack Query 的 useQuery 是客户端 hooks依赖 React context服务端组件环境没有这个运行上下文。t3code 提供两条路要么把该组件标记为 client component 并用 hooks要么在服务端直接调用 tRPC 的 caller把取好的数据作为 props 穿下去。服务端调用 caller 的代码大概是import { appRouter } from ~/server/api/root; const caller appRouter.createCaller(await createTRPCContext({ headers: req.headers })); const posts await caller.posts.list({ cursor: null });这条路适合对 SEO 敏感、又不想引入额外 API 请求的页面。需要权衡的是caller 是直连函数调用绕过了 HTTP 层意味着不会经过超时、限流所以不要把太重或不可信的外部调用放到这条链路。我习惯在模板注释里写明这一点提醒后来的人别把这里当成通用 API 层。5.2 类型生成时机postinstall 与 pre-committ3code 团队里最容易出现的协作事故A 改了 prisma schemapush 到仓库B pull 下来直接tsc --noEmit一堆类型错误。原因是 B 的 node_modules 里还是旧版 Prisma Client。解决办法是在 package.json 里加{ scripts: { postinstall: prisma generate, precommit: prisma validate tsc --noEmit } }postinstall 保证 npm install 之后类型一定是新的precommit 保证提交前 schema 语法和类型检查都会过一遍。注意 postinstall 在某些 CI 缓存 node_modules 的场景下可能不执行所以 CI 流程里最好显式跑一次prisma generate。这个坑我们实测定过缓存一开功能都正常但类型就是旧特别迷惑人。排查了半天还以为是环境问题最后才发现是 CI 把 node_modules 整块缓存了连 postinstall 都被跳过了。5.3 部署时环境变量的作用域陷阱t3code 用 zod 做环境变量校验这是好事但也会把问题暴露在 build 期如果构建机的环境变量不完整Next.js 编译直接失败。常见错误是把 DATABASE_URL 放在运行时变量里而构建时虽然不需要真实连接数据库但 Prisma 的某些生成步骤可能读取变量CI 里就会出现 missing environment variable。我的习惯是构建阶段需要的变量统一放在 build env运行阶段需要的变量放在 runtime env两边重复也没关系别指望 Next.js 会自动帮你合并。还有一个客户端环境变量前缀问题只有NEXT_PUBLIC_开头的变量会被注入浏览器像 NEXTAUTH_SECRET 这种绝对不能加前缀加了你就是把密钥发到每一个访客的浏览器里。这个属于老生常谈但我每次看到新项目都会再强调一次。t3code 的 env schema 里也会区分这两类变量在文件顶部写清楚防止有人顺手把敏感配置放到 NEXT_PUBLIC 里。5.4 另外两个容易被忽略的小问题Tailwind 的内容扫描配置要包含 features 目录。如果你和我一样把业务组件迁移到 src/features 下但 tailwind.config 的 content 只写了一个具体目录漏掉新目录样式就会“随机消失”很难排查。建议统一用./src/**/*.{ts,tsx}别自作聪明。遇到过一个人把 content 精确到了两个目录后来加了一个 chart 目录整个图表页面的样式全没了他还以为是 Tailwind 升级的问题。另一个是 eslint 和 prettier 的版本冲突。create-t3-app 的默认模板对 eslint 版本比较敏感直接升级到 eslint 9 可能会遇到 parser 不兼容。t3code 目前的处理是把 eslint 版本锁在模板经过验证的版本上不追最新。工具链的价值在于稳定不在于每次都踩第一个坑。毕竟模板存在的意义是让团队成员少踩坑而不是成为新技术的试验田。5.5 网络层错误处理别漏最后补一个不常被注意的点tRPC 默认的 HTTP 错误处理比较简洁但业务里经常需要区分“参数校验失败”“未登录”“无权限”“服务端异常”。t3code 在中间件里统一映射这些错误给前端返回稳定的结构export const errorMapper (error) { if (error instanceof ZodError) { return { statusCode: 400, message: 参数错误 }; } if (error.message UNAUTHORIZED) { return { statusCode: 401, message: 请先登录 }; } return { statusCode: 500, message: 服务端异常 }; };前端在 onError 里只根据 statusCode 弹提示而不是每个页面各写一套。这样哪怕以后要接接入层错误格式也不会乱。如果你一开始没做这层映射后面加接口时到处复制错误处理代码会更痛苦。最后说几句个人体会。t3code 最初只是我自己的一个实验仓库用来验证“能不能把业务模块从页面里彻底切出去”。用了大半年最大的收获不是省了几次脚手架时间而是团队讨论代码位置时有了统一依据数据库改动往 prisma 放API 逻辑往 features/api 放页面只负责组合数据。这种边界感比任何框架特性都重要。如果你也想做一个属于自己的模板建议不要一开始就堆满功能先把一条用户故事从头到尾用类型安全的方式跑通再慢慢加认证、权限、部署。t3code 的内容我也会持续整理但核心原则不变看得见的类型流拆得开的业务域。