前几天有个朋友跟我吐槽,说新接手的全栈项目“能跑,但没人敢动”,一加需求就报一堆类型错误,改一个字段,页面要崩三个地方。我挺能理解这种感觉,因为两年多前我自己也是这样,项目跑得欢,代码却像盘根错节的藤蔓。后来我在多个基于 Next.js、TypeScript、tRPC、Prisma 和 Tailwind 的 T3 技术栈项目里,慢慢沉淀出一套代码组织和类型串联的方式,对外代号就叫 t3code。
t3code 不是脚手架,也不是某个 npm 包。它是我对“T3 技术栈项目该怎么摆代码、怎么穿类型、怎么避开常见坑”的一套经验总结。刚用 create-t3-app 上手的人,可以拿它当参考;已经跑了一两年的老项目,也可以拿它当重构清单。这篇文章不聊“怎么跑通 hello world”,而是聊模板没有告诉你的东西:业务一变多,代码应该放在哪,tRPC 的 router 该有多厚,Prisma 和 Zod 的类型为什么老对不上,以及我在实际项目里踩过的那些坑和对应的处理链路。
如果你以为 t3code 只是把文件换个目录,那会失去它一半的价值。它真正解决的是“全栈项目怎么长期保持类型安全、边界清晰、可改动”的问题。下面我按自己整理的经验,从骨架、完整链路、踩坑记录和团队化四个层面展开。
1. t3code 的动机:T3 模板能跑,但不够用
用 create-t3-app 初始化一个项目,体验确实很顺。它帮你把 Next.js、tRPC、Prisma、Tailwind 和 NextAuth 接好,跑起来几乎是零成本。但默认结构的设计目标,是让你快速启动一个 demo,而不是帮你治理中期复杂度。刚开始新增一个功能,你还能在src/server/api/routers/里按模板加一个文件;等到功能一多,router 文件开始往几百行走,类型定义散落在各个地方,前端从api.*拿到的数据和后端期望的输入越来越难对上。这个时候,“能跑”已经不够了。
1.1 从“能跑”变成“能一直跑”
我见过太多项目前三个月风平浪静,第四个月开始出现一类很典型的问题:某个字段在 Prisma 里是String?,在 Zod 里写成了optional(),传到 tRPC 后 TypeScript 报string | null不能赋给string | undefined。看起来只是一个小类型问题,但当你项目里有二十几个 router 时,类似的报错会被放大很多倍,修起来异常痛苦。
还有一个同样典型的场景:页面通过服务端组件直接查数据库,后来又要加权限判断,于是在页面里临时拼了一段getServerSession的逻辑。一次两次没事,等页面越来越多,你会发现权限判断、数据筛选、缓存更新这些事情全都混在页面组件里。此时代码已经不是“写”出来的问题,而是没人敢“改”。
T3 技术栈的最大优势是所有环节都用 TypeScript,类型可以在数据库、API、前端组件之间串联。但这种优势不是自动发生的,需要一套明确的组织约定,让类型顺着一条清晰的路走。t3code 的动机,就是把这条路固定下来。
1.2 t3code 的四个关键词
我整理 t3code 时,脑子里反复出现四个词:类型先走、功能切片、router 薄、变更可查。
- 类型先走:先定义清楚输入和输出的类型,再写 UI。Prisma schema 是数据库事实源,Zod schema 是 API 边界,前端组件不自己发明新的业务类型。
- 功能切片:同一个业务功能相关的 schema、router、组件、hooks 尽量放在同一个目录里,而不是按 server、client、component 这种大层去堆。
- router 薄:tRPC 的每个 procedure 只做参数校验、权限判断、必要的业务操作和返回结果。复杂逻辑抽到独立的 service 文件中,别让 router 变成百行大杂烩。
- 变更可查:新功能的操作链路有明确步骤,CI 里有类型检查和 lint 检查,任何破坏类型同步的改动都能在合并前暴露出来。
这四个词后面会反复出现。它们不是贴在墙上的口号,而是我在实践中一点一点踩出来的约束。
2. 代码骨架怎么摆:按业务切片代替按层堆
create-t3-app 默认给的目录结构,实际是按“运行环境”来切的:src/server放服务端逻辑,src/components放组件,src/pages或src/app放页面。这种结构在小项目里没问题,但业务变大之后,你会遇到一种很别扭的情况:一个“文章功能”,schemas 放在一个目录,routers 放在另一个目录,UI 组件又放在第三个目录。改一个功能要在四个地方跳来跳去,协作成本会逐步上升。
t3code 的思路是把业务功能作为第一层目录,运行环境作为内部细节。每个 feature 自带 schema、router、components 和 hooks,只有跨功能共享的东西才放到公共层。
2.1 我在多个项目里验证过的目录结构
下面这棵树是我目前新项目都会采用的基准结构,你可以直接抄过去用:
src/ app/ (main)/ posts/ page.tsx post-detail.tsx settings/ page.tsx features/ posts/ post.schema.ts post.router.ts post.service.ts components/ PostList.tsx PostItem.tsx hooks/ usePostList.ts comments/ comment.schema.ts comment.router.ts comment.service.ts components/ server/ api/ trpc.ts root.ts db.ts auth.ts lib/ utils.ts logger.ts注意,app/目录里只放页面壳子和布局,具体业务组件都在对应的features/里。这不会破坏 Next.js 的 App Router 约定,只是让app/变得非常薄。一个页面需要什么组件,直接从features/posts/components里 import 进来就行。这样,你从 URL 结构能快速看出有哪些页面,从features/能快速看出有哪些核心业务域。
2.2 app 目录与 features 目录的分工
很多人第一次看到这种结构会问:app/目录和features/目录服务端组件调用谁?简单理解就是:app/负责匹配 URL,features/负责真正的页面能力。
举个例子,文章的详情页在app/posts/[id]/page.tsx里,这个页面文件只做三件事:
- 读取
params.id; - 调用
post.router或post.service获取数据; - 把数据交给
PostDetail组件渲染。
至于文章内容怎么排版、评论列表怎么展示、点赞按钮的状态怎么管理,那都是features/posts内部的事。app/不关心这些细节,只保留一个薄薄的胶水层。
好处是当你要删某个功能时,直接删除features/posts整个目录,再清理一下app/里的引用,代码库就能干净移除。相反,在按层切分的老结构里,删除一个功能意味着你去 server 目录删 router、去 types 目录删类型、去 components 目录删组件,少一个都可能留下隐患。
2.3 公共代码怎么收口
新手很容易做的一件事,是建一个src/utils或者src/types目录,把不知道放哪里的东西都塞进去。几周后这个目录会变成垃圾场,里面什么都有,谁也说不清哪些是公共的。
t3code 对公共代码只有一个原则:只有被两个及以上功能使用的代码才允许放进src/lib,并且按用途命名文件,比如format-date.ts、parse-query.ts、logger.ts。只被单个功能使用的辅助函数,就直接留在那个功能目录内部。这样,你永远不会出现一个名字叫utils.ts的 800 行大杂烩文件。
这里的判断标准不是“未来可能会用”,而是“现在就真的在重复”。业务没到第三次重复之前,先忍一忍,复制一份不会死。过早抽取公共模块,往往让抽象变成负担。
3. 类型安全如何贯通:从 Prisma 字段到浏览器组件
如果说目录结构是 t3code 的骨架,那么类型贯通就是它的中枢神经。T3 技术栈最迷人的地方,是理论上从数据库表字段到前端组件 props,全程可以被 TypeScript 推导出来。但我在很多项目里看到的情况是:数据库一个结构、Zod 一个结构、前端 props 又手工定义一遍,三套类型之间的转换全靠手写,出错只是时间问题。
3.1 Prisma schema 与 Zod schema 的先后关系
先说一个重要结论:先写 Prisma schema,再写 Zod schema,最后才考虑 UI 组件。
Prisma schema 是整条链路最底层的“事实源”,它决定了数据在数据库里是什么形态,Prisma Client 生成的所有类型都从这里来。Zod schema 是 API 边界的输入校验,但它不应该脱离数据库结构凭空定义。你在写 Zod 时,脑海里应当有 Prisma 模型做参照,尤其是空值表达:Prisma 里的String?对应的是string | null,Zod 里如果写成z.string().optional(),得到的是string | undefined,两者在类型上无法完全等价。
我在 t3code 里通常这样处理:
// features/posts/post.schema.ts import { z } from "zod"; export const createPostSchema = z.object({ title: z.string().min(1).max(120), content: z.string().min(1), published: z.boolean().default(false), coverImage: z.string().nullable().optional(), }); export type CreatePostInput = z.infer<typeof createPostSchema>; export type UpdatePostInput = CreatePostInput.partial();这里coverImage同时用了nullable()和optional(),就是为了和 Prisma 的String?完全对齐:你可以不传这个字段,传了可以为null,但绝不会是undefined。这个细节看着小,实际作用很大,它能让 tRPC 的调用方和 Prisma 查询结果在类型上直接相通。
3.2 tRPC router 的厚度控制
tRPC 的 router 是服务端的主要入口。t3code 对 procedure 有一个“五步走”约定:
- 校验输入:
input: createPostSchema; - 确定会话:
protectedProcedure或主动检查 ctx 里的 session; - 检查权限:比如只有作者本人能改自己的文章;
- 执行核心业务:查询数据库、写数据库、调第三方服务;
- 返回最少数据:不要顺手把一堆无关字段塞进去。
如果第 4 步的逻辑复杂到超过 10 行,我会拆到post.service.ts里。router 文件只保留“流程控制”,不保留“具体实现”。这样写的好处,是测试的时候可以直接测 service,而不必启动一整套 tRPC server。
下面是一个简化版的例子,假设我们要实现“保存文章”:
// features/posts/post.router.ts import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createPostSchema, updatePostSchema } from "./post.schema"; import { createPost, updatePost } from "./post.service"; export const postRouter = createTRPCRouter({ create: protectedProcedure .input(createPostSchema) .mutation(async ({ ctx, input }) => { return createPost({ db: ctx.db, authorId: ctx.session.user.id, ...input, }); }), update: protectedProcedure .input(updatePostSchema) .mutation(async ({ ctx, input }) => { await ensureCanEdit({ db: ctx.db, userId: ctx.session.user.id, postId: input.id }); return updatePost({ db: ctx.db, ...input }); }), });你可以看到,每个 procedure 最多几十行,核心逻辑都在 service 里,router 只是把验证、权限、调用顺序串起来。这让 tRPC 的类型推导保持在非常轻量的状态,也让 review 代码的人能一眼看出一个接口到底做了什么。
3.3 在 Server Component 与 Client Component 里分别怎么调
Next.js App Router 下,tRPC 可以有两种调用方式,t3code 里我把它们分得很清楚。
服务端组件里,直接用createCaller调用,不需要经过 HTTP 网络层,避免一次多余的网络往返:
// app/posts/page.tsx import { createCaller } from "~/server/api/root"; import { db } from "~/server/db"; import { auth } from "~/server/auth"; import { PostList } from "~/features/posts/components/PostList"; export default async function PostsPage() { const caller = createCaller({ db, session: await auth() }); const posts = await caller.post.list({ page: 1 }); return <PostList initialData={posts.items} />; }客户端组件里,用api.post.list.useQuery()这类 hooks,配合initialData或者HydrationBoundary,既能享受 React Query 的缓存和更新能力,又不会在首屏出现空白。
// features/posts/hooks/usePostList.ts "use client"; import { api } from "~/trpc/react"; export function usePostList(initialData?: PostListItem[]) { return api.post.list.useQuery( { page: 1 }, { initialData: initialData ? { items: initialData, nextPage: null } : undefined } ); }这里的关键是区分场景:服务端组件负责“首屏可信数据”,客户端组件负责“交互后的动态更新”。不要把这两件事混在一起,否则你会发现首屏要么空转一下,要么拿到的数据缓存策略非常难以预测。
3.4 一个“收藏文章”功能的全链路示例
只看理论容易飘,我拿一个最常见的“收藏文章”功能把链路串一遍。
首先在 Prisma schema 里加一个显式的多对多关联:
model Post { id String @id @default(cuid()) title String favoritedBy UserFavoritePost[] } model UserFavoritePost { userId String postId String user User @relation(fields: [userId], references: [id]) post Post @relation(fields: [postId], references: [id]) createdAt DateTime @default(now()) @@id([userId, postId]) }执行迁移后,在features/posts/post.router.ts里加两个 procedure,一个用来查看我收藏了哪些文章,一个用来切换收藏状态:
favorite: protectedProcedure .input(z.object({ postId: z.string() })) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.userFavoritePost.findUnique({ where: { userId_postId: { userId: ctx.session.user.id, postId: input.postId, }, }, }); if (existing) { await ctx.db.userFavoritePost.delete({ where: { userId_postId: { userId: ctx.session.user.id, postId: input.postId } }, }); return { favorited: false }; } await ctx.db.userFavoritePost.create({ data: { userId: ctx.session.user.id, postId: input.postId }, }); return { favorited: true }; }),前端按钮组件里,调用这个 mutation,成功后让 React Query 的缓存失效:
const utils = api.useUtils(); const favoriteMutation = api.post.favorite.useMutation({ onSuccess: () => { utils.post.list.invalidate(); }, });整个链路里,你不需要在任何地方手写“收藏记录长什么样”这个类型,类型从 Prisma 一路推导到前端。如果你在某个环节手写了重复类型,十有八九就是未来 bug 的温床。
4. t3code 实战里最常踩的坑,处理方案一次说清
任何一套规范只有跑在真实项目里才会显形。下面这四个坑是我在多个 T3 项目里反复遇到,并且最终在 t3code 里固化了处理方案的。
| 典型问题 | 产生原因 | 推荐处理方案 |
|---|---|---|
| Prisma 的 nullable 和 Zod 的 optional 对不上 | 空值表达不一致,类型推导出两套语义 | 统一使用nullable().optional(),必要时用transform |
| protectedProcedure 里 session.user 为 null | getServerSession的返回类型包含 null | context 层做拦截,procedure 里先抛 TRPCError |
| mutation 后列表不更新 | React Query 和 Next.js 双缓存叠加导致失效不及时 | 同时调用invalidate与revalidatePath |
| 客户端首屏数据短暂空白 | React Query 没有拿到服务端预取数据 | 服务端通过 createCaller 预取,并用 initialData 或脱水传给客户端 |
4.1 Prisma nullable 字段让 Zod 类型对不上
这个坑我第一次遇见时,觉得 TypeScript 是不是疯了。数据库字段是String?,我在 Zod 里也写了z.string().optional(),为什么一接 tRPC 就报Type 'string | null' is not assignable to type 'string | undefined'。
排查链路是这样的:
- 先看 Prisma 生成类型,确认数据库模型中该字段类型是
string | null; - 再看 Zod schema,发现
z.string().optional()推导出string | undefined; - 对比后发现,二者都表示“可以不填”,但 TypeScript 眼里它们不是同一种空。
解决方式很简单:要么在 Prisma 侧约定避免使用可空字段,改成默认值字符串;要么在 Zod 侧用z.string().nullable().optional()。我一般选择后者,因为它只需要改 schema 而不改动数据库,并且能同时兼容“不传”和“传 null”两种前端行为。
如果只是个别字段不匹配,还可以用preprocess或者transform把null转成undefined。但这类转换写多了会让 schema 很乱,建议 Group 到 shared nullability 工具里,统一处理。
4.2 protectedProcedure 里 session.user 被推断为 null
另一个高频问题是,你已经用了protectedProcedure,但在 procedure 内部写ctx.session.user.id时,TypeScript 仍然报“user is possibly null”。原因在于createContext里 session 的类型是Session | null,而protectedProcedure的守卫函数只负责抛错,不一定能让 TypeScript 在后续代码中把session.user收窄为非空。
我在 t3code 里的做法是,把“会话是否存在”的判断放在一个独立的中间件函数中,并且在函数末尾返回一个新的 context:
// server/api/trpc.ts import { TRPCError } from "@trpc/server"; export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user, }, }, }); });这样,在后续的 procedure 中,TypeScript 能准确知道ctx.session.user是非空对象,不用每次写一长串if判断。这个中间件逻辑是所有需要登录态接口的基础,属于一次性投资,收益极高。
4.3 mutation 后列表不更新
第三个坑,也是团队协作时最容易争吵的坑:提交了 mutation 之后,列表数据没有变。
我遇到过这样一条完整排查链路:
- 前端点了按钮,Network 面板看到 POST 请求成功;
- 调用了
utils.post.list.invalidate(),Network 里也确实发出了新的 GET 请求; - 但页面还是旧数据;
- 查看 Server Component 的日志,发现
caller.post.list()并没有被重新执行。
根因在于 App Router 的缓存机制。服务端组件如果使用了默认 fetch 行为,页面可能会命中缓存,导致即使 React Query 重新请求了客户端接口,服务端渲染部分也没有更新。如果页面里还用了getServerSession(),还可能触发整条路由被当成静态渲染,session 变化也不会体现在服务端结果里。
解决方式有两层:
第一层,客户端 mutation 成功后,确保调用了正确的invalidate,并且await它完成:
const utils = api.useUtils(); const mutation = api.post.favorite.useMutation({ onSuccess: async () => { await utils.post.list.invalidate(); }, });第二层,如果列表页是被服务端组件渲染的,还需要在 mutation 成功回调中调用revalidatePath或revalidateTag,让 App Router 的缓存一并失效:
onSuccess: async () => { await utils.post.list.invalidate(); revalidatePath("/posts"); };只做前半段,你只是解决了 React Query 的缓存,没解决 App Router 的缓存。只做后半段,页面刷新是新的,但交互侧的数据还是旧的。两者一起做,才能让“操作后立刻看到新结果”这个直觉成立。
4.4 客户端首屏数据空白的假警报
还有一个很误导人的现象:新项目里,客户端组件用useQuery拉数据,首屏会白一两秒,但 Network 面板里请求明明返回了 200。第一反应往往怀疑 stack 顺序配错了,实际大多不是。
原因通常是 React Query 没拿到服务端预取的数据。你写了一个usePostList的 hook,但页面是服务端组件,没有把服务端已经查好的initialData传进来。React Query 只能等客户端挂载后再发起一次请求,所以首屏自然是空的。
从用户体验来看,这是可感知的闪烁;从开发体验来看,这会让人以为数据没调通。排查链路是:
- 确认页面是 Server Component 还是 Client Component;
- 如果是 Client Component,检查有没有通过 props 传入
initialData或通过HydrationBoundary进行脱水; - 如果只依赖
useQuery,确认staleTime没有没必要的过长,导致数据一直被认为是旧的。
我个人的统一方案是:能服务端预取的一律服务端预取,用createCaller拿首屏数据,然后通过 props 交给 client 组件作为initialData。这样首屏无空白,用户操作后的更新再交给 React Query 管理,两边各管一段,互不干扰。
5. 把 t3code 变成团队默认:生成器、CI 和文档
最后这部分,是想让这套经验不只停留在个人舒适区。如果团队里五六个人同时在一个 T3 项目上开发,没有工具和文档约束,t3code 很容易形同虚设。
5.1 用一个小脚本生成 feature 骨架
各种重型代码生成器很好,但 t3code 的建议是轻量为好。不同团队的 Prisma 模型、权限规则、命名习惯都不同,生成器只需要产出骨架,不需要产出业务逻辑。
我通常会在scripts/new-feature.mjs里放一个极简脚本:
import fs from "node:fs"; import path from "node:path"; const name = process.argv[2]; if (!name) { console.error("请传入功能名,例如:node scripts/new-feature.mjs posts"); process.exit(1); } const dir = path.join("src/features", name); fs.mkdirSync(path.join(dir, "components"), { recursive: true }); fs.mkdirSync(path.join(dir, "hooks"), { recursive: true }); const schemaTemplate = `import { z } from "zod";\n\nexport const ${name}Schema = z.object({});\n`; const routerTemplate = `import { createTRPCRouter } from "~/server/api/trpc";\n\nexport const ${name}Router = createTRPCRouter({});\n`; fs.writeFileSync(path.join(dir, `${name}.schema.ts`), schemaTemplate); fs.writeFileSync(path.join(dir, `${name}.router.ts`), routerTemplate);跑一次node scripts/new-feature.mjs comments,就能得到一个统一的comments功能目录。脚本本身没有魔法,但它保证了每个新功能的第一姿势都是对的。等团队成员都熟悉这套结构后,不跑脚本自己手建目录问题也不大,但“新功能默认建在features/下”这条约定必须写进文档。
5.2 CI 里加三道闸门
代码生成只是起点。要想长期稳住类型贯通,CI 必须设置明确检查。我给 t3code 项目配置了三道闸门,缺一不可:
tsc --noEmit:任何类型不匹配都会拦在合并之前。Next.js 项目跑next build也会做类型检查,但显式跑一次tsc --noEmit能把错误信息展示得更直接,也方便在 CI 里单独定位。eslint:配合eslint-plugin-react-hooks、@tanstack/eslint-plugin-query等插件,能拦住很多 hooks 依赖项写错、query key 漏写之类的问题。prisma validate:检查 Prisma schema 语法和模型关系是否合法,避免合并了一个无法生成 client 的改动。
很多人会忘了prisma validate。其实这个命令非常快,适合跑在每个 PR 上。它的价值不在于捉 bug,而在于让团队每次修改 Prisma schema 时都下意识先验证一遍,减少“我本地能跑但别人拉下来生成失败”的情况。
5.3 把约定写进仓库,而不是口头传播
t3code 落地的最后一块,是文档。我不建议把它写成长篇技术博客放在 wiki 上落灰,而是建议放到仓库的CONTRIBUTING.md或docs/architecture.md里,并且只保留“新功能该怎么动手”这一条最核心的流程。
下面是我在项目里常用的版本,非常短:
新功能默认五步走:
- 改 Prisma schema,执行迁移;
- 在对应 feature 目录里写 Zod schema 和类型;
- 写 tRPC router,复杂逻辑抽到 service;
- 在服务端组件或客户端 hooks 中接入数据;
- 跑
tsc --noEmit、eslint,然后提交。
这份文档不需要解释“为什么”,文件夹目录已经说明了大部分原因。新成员入门时,看一遍目录结构,再做一次“加一个下拉框”的简单任务,就能在半小时内进入节奏。比起在 IM 群里反复回答“这个放哪”,文档才是一次性的解药。
如果你也准备在自己的 T3 项目里引入 t3code,我的建议是先不要动老代码,选一个正在新增的功能,按 features 目录和五步走试一次。尝到类型顺着链路自己走的甜头之后,再回来收拾历史包袱。
t3code 不是什么银弹,它只是把“类型安全、边界清晰、重复少的代码”这件事制度化。我这两年最深的体会是:全栈项目最后崩掉,极少是因为性能,更多是因为没人敢改。而没人敢改的原因,就是文件和类型之间没有约定。把这两点解决掉,剩下的无非是业务事。