
t3code 这个名字最初只是我本地仓库的一个临时文件夹名。当时我想做一个小工具把散落在各个项目、各个文档、甚至聊天记录里的代码片段集中管理起来支持标签归类、关键字搜索、Markdown 预览方便哪天想复用一段老逻辑时能立刻翻出来。命名时懒得琢磨随手敲了 t3code——前面的 t3 指的是社区里很火的 T3 Stack 技术栈组合后面的 code 正好对应代码片段管理这个定位。没想到这个随手起的名字最后真的走完了从原型到上线的一整套流程。这个项目能干什么一句话说清它是个基于 Next.js TypeScript tRPC Prisma 的全栈代码片段管理应用前端只管展示和交互后端逻辑全部通过类型安全的过程调用暴露全程不需要手写接口文档也彻底告别前端改了一个字段类型、后端还没跟上这类合同漂移问题。如果你第一次听说 T3 Stack或者正犹豫要不要把全栈项目迁移到全 TypeScript方案这篇复盘可以当一份参考如果你已经写过不少 React 项目里面那些配置细节和踩坑记录应该更有价值。1. t3code 的定位一个被接口文档漂移逼出来的代码片段管理工具1.1 从痛点出发功能边界尽量收敛做 t3code 之前我手头有几个项目长期面临同一个问题接口文档永远比代码慢半拍。后端改了请求参数文档没更新前端按三个月前的文档联调线上直接 500。线上报错还能发现最怕的是类型对不上但接口能通等到某个边界数据出现才炸。t3code 的功能我一开始就压得很小只做四件事片段列表、标签筛选、全文搜索、增删改查。没有协作权限没有分享链接没有评论系统。原因很简单——工具类产品功能每多一个数据模型、接口、页面三者之间的同步成本就翻一倍而这个项目本身就是为了验证类型安全能否把同步成本压下来。数据模型也刻意保持简单核心就两张表Snippet 保存标题、代码内容、编程语言和创建时间Tag 保存标签名中间通过多对多关系关联。够用就行别想着一步到位。1.2 对比 REST、GraphQL为什么我选了过程调用这是我决定整个技术栈方向时的关键一步。当时我列了一张对比表把方案摊开看方案类型如何流动额外成本适合场景REST 手写文档靠人肉同步文档维护成本高必然漂移团队流程成熟、接口稳定REST OpenAPI 生成需要 codegen且前端和后端各自的生成链独立生成配置复杂类型和运行时仍可能脱节强规范企业级项目GraphQLSchema 驱动类型较完整需要学 resolver、fragment、缓存策略多端、复杂聚合查询tRPC服务端类型直接推送到客户端机制上杜绝漂移几乎为零只是多了一层抽象全栈 TypeScript、前后端同仓库GraphQL 不是不好但对 t3code 这种二十几个接口的小项目来说引入 GraphQL 等于背着巡航导弹打巷战。REST 最成熟可 t3code 的前端代码就放在同一个仓库里既然 TypeScript 已经管住了前端的数据结构为什么不让它顺带把后端也管了tRPC 的核心是把后端路由定义成一个个过程procedure这些过程的输入输出类型会被 TypeScript 自动推断到客户端整个类型链是单向不可断的。就这么定了。2. 选型复盘T3 Stack 的三根支柱和配套方案2.1 TypeScript First、Tailwind、tRPC 各自的角色T3 Stack 是社区里流传的一套组合拳核心思想是能全 TypeScript 就全 TypeScript。它不规定你必须用什么框架但常见的搭法是 Next.js 做应用框架、TypeScript 做语言层、Tailwind CSS 做样式、tRPC 做 API 层。这套组合里TypeScript First 是态度Tailwind 是效率工具tRPC 是真正改变开发体验的变量。为什么把 Tailwind 也列为主角因为 T3 Stack 主张样式不要成为类型的缺口。组件 props 是类型安全的数据是类型安全的如果样式还要靠一整本 BEM 命名规范来约束那类型安全就不是端到端的。Tailwind 的 utility class 方案让样式直接写在 JSX 里配合 TypeScript 对 className 字符串的约束插件层面整个页面的状态到样式映射在一个文件里就能看全。tRPC 的角色我在 1.2 里已经说了这里补充一个容易被忽略的点tRPC 不需要 codegen。对比 OpenAPI 那种写完 schema 再生成类型的链路tRPC 的类型是直接根据函数签名推断出来的。这意味着你永远不会遇到客户端生成了旧版本类型这种问题因为类型根本不存在一个中间产物里。2.2 数据层为什么是 Prisma PostgreSQL 而不是文档数据库数据层我选了 Prisma PostgreSQL没有跟风用 MongoDB。原因很实际Snippet 和 Tag 是典型的多对多关系——一段代码可以带多个标签一个标签可以挂多段代码。Prisma 对这种关系有原生支持迁移也能用 Prisma Migrate 管理schema 即文档。PostgreSQL 是顺理成章的选择。关系型数据库在关联查询上的优势不必多说t3code 的核心查询就是按标签过滤片段这在 SQL 里是标准的 join 操作。如果换成文档数据库多对多要么反规范化冗余标签数组要么手动维护关联表两种都会在数据变多后带来一致性问题。另外 Prisma Client 天然是类型安全的ctx.db.snippet.findMany()返回的结果集合字段名和类型在编译期就是确定的和 tRPC 拼在一起从数据库到浏览器组件这一整条链路没有任何一个环节需要手写类型声明。这是 T3 组合里最强的地方——前后端类型安全常见但数据库结果的类型安全要靠 ORM 层来兜底。3. t3code 工程骨架初始化、目录结构和 tRPC 三段式3.1 create-t3-app 初始化和目录结构这里不推荐从零手搭直接用官方脚手架最省心。我在项目目录里执行了pnpm create t3-applatest t3code交互式选项里勾选了 Next.js App Router、TypeScript、Tailwind CSS、tRPC、Prisma没勾 NextAutht3code 不需要登录减少一层鉴权复杂度数据库选了 PostgreSQL。脚手架跑完目录结构长这样t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts │ │ └── ... │ ├── server/ │ │ └── api/ │ │ ├── routers/ │ │ ├── root.ts │ │ └── trpc.ts │ ├── trpc/ │ │ ├── react.tsx │ │ └── server.ts │ └── styles/ └── tailwind.config.ts有几个文件值得单独说。src/server/api/trpc.ts是整个 tRPC 的入口里面创建了t实例定义了公共过程publicProcedure。root.ts负责把所有业务路由聚合进appRouter。src/trpc/react.tsx是客户端适配层把 tRPC 和 React Query 的 hooks 绑定在一起。3.2 tRPC 的 root、router、procedure类型是怎么一路流到前端的刚开始接触 tRPC 的人最容易被三个概念绕晕root 是总路由表router 是模块化路由procedure 是具体接口过程。我习惯打个比方root 是一本书的目录router 是章节procedure 是章节里的每个小节。t3code 里书只有一章叫snippet但对外的结构仍然是 root → router → procedure。核心代码其实很短。src/server/api/trpc.ts里import { initTRPC } from trpc/server; import type { createTRPCContext } from ./context; const t initTRPC.contextcreateTRPCContext().create(); export const router t.router; export const publicProcedure t.procedure;然后root.ts把 snippet 路由挂上去import { router } from ./trpc; import { snippetRouter } from ./routers/snippet; export const appRouter router({ snippet: snippetRouter, }); export type AppRouter typeof appRouter;就这么点代码前端就能获得api.snippet.all.query()这样的类型安全调用。原理也不玄乎appRouter的类型被 TypeScript 完整保留客户端通过createTRPCClientAppRouter()拿到这个类型后hooks 的入参、返回值就全部确定下来了。你后端 procedure 改成三个参数前端立刻编译报错绝无例外。4. 核心链路实现从 Prisma 模型、Zod 校验到前端 Hook4.1 数据模型与迁移Snippet 和 Tag 的多对多Prisma 的 schema 我很喜欢因为它把表结构写得像一篇散文。t3code 的prisma/schema.prisma长这样generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) directUrl env(DIRECT_URL) } model Snippet { id String id default(cuid()) title String content String db.Text language String default(plaintext) tags Tag[] relation(SnippetTags) createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique snips Snippet[] relation(SnippetTags) }tags和snips两个字段通过relation(SnippetTags)互相指向Prisma 会自动建一张名为_SnippetTags的关联表不需要手写。定义完 schema 之后两条命令搞定建表和类型生成pnpm prisma migrate dev --name init pnpm prisma generate4.2 业务路由一个文件承载整个 snippet 模块的增删改查t3code 的snippetRouter放在src/server/api/routers/snippet.ts。我刻意没拆成多个文件因为二十来个接口拆文件反而是负担。核心是两个 procedureimport { z } from zod; import { publicProcedure, router } from ../trpc; const snippetInput z.object({ title: z.string().trim().min(1, 标题不能为空), content: z.string().trim().min(1, 代码内容不能为空), language: z.string().default(plaintext), tagIds: z.array(z.string()).default([]), }); export const snippetRouter router({ create: publicProcedure .input(snippetInput) .mutation(async ({ ctx, input }) { const snip await ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, tags: input.tagIds.length ? { connect: input.tagIds.map((id) ({ id })) } : undefined, }, include: { tags: true }, }); return snip; }), all: publicProcedure .input(z.object({ tagId: z.string().optional() }).optional()) .query(async ({ ctx, input }) { return ctx.db.snippet.findMany({ where: input?.tagId ? { tags: { some: { id: input.tagId } } } : undefined, include: { tags: true }, orderBy: { updatedAt: desc }, }); }), });Zod 负责输入校验错误信息会以TRPCClientError的标准结构抛回前端。这里有个值得说的细节tagId是单个字符串而不是数组因为页面上的标签筛选预期是单选。但数据模型是多对多所以查询时用tags: { some: { id: input.tagId } }。Prisma 的some语义很直观——存在至少一个匹配的标签。4.3 前端调用、乐观更新与缓存失效前端调用这段我最想强调的是 React Query 的缓存策略。t3code 列表页组件里核心代码就三行use client; import { api } from ~/trpc/react; export function SnippetList({ tagId }: { tagId?: string }) { const { data, isLoading } api.snippet.all.useQuery({ tagId }); const utils api.useUtils(); const createSnippet api.snippet.create.useMutation({ onSuccess: async () { await utils.snippet.all.invalidate(); }, }); // ... }useQuery会把结果缓存在 React Query 里同一个 key 的重复调用不会重复请求useMutation成功后调用invalidate()让旧缓存失效下次渲染自然拉到新数据。很多第一次用的人会漏掉这个 invalidate 步骤结果就是新增成功了列表不刷新然后怀疑是 tRPC 的 bug。其实不是是 React Query 的缓存太尽责——你不主动失效它就永远给你看旧的。如果要做乐观更新代码再复杂一点先在本地写入期望值失败时靠onError里的context.original回滚。小项目里我反而建议先别上乐观更新等列表变得明显卡顿再考虑省得后端校验把本地伪数据打脸的来回折腾。5. 上线前后踩过的坑类型边界、动态渲染和部署冷启动5.1 Server Component 里跑 tRPC把整站拉成了动态渲染这是我在 t3code 上踩的最深的一个坑。最初我把侧边栏标签列表这个查询放在了根布局的 Server Component 里用服务端调用者的方式去拉标签数据。本意是减少一次客户端请求结果页面全部变动态了——Next.js 看到你在布局里用了headers()/cookies()这类请求级 API 后会放弃静态生成所有请求都走服务端渲染。后果在本地开发时看不出来一上生产就明显首次访问变慢缓存命中率下降Vercel 的日志里全是 Requested a page that is not prerendered 的警告。排查思路是这样的先怀疑是 tRPC 的调用方式问题把 Server Component 里的调用注释掉发一版页面恢复预渲染再把调用加回来又变动态。两头一夹定位到是headers()的副作用。解决方案其实不是禁止在 Server Component 里用 tRPC而是别在全局布局里间接引入请求级 API。我最终把侧边栏拆成独立的异步 Server Component它内部使用 tRPC 没问题页面其他静态部分继续走静态生成。Both sides 都保住了。5.2 依赖升级的连锁反应tRPC v11、Tailwind v4 和 React Query v5t3code 开发期间正好赶上几个大版本更新这让我把脚手架给的模板和社区主流教程之间的版本差体会得特别深。tRPC v11 把 procedure 调用改成了点式命名法也就是api.snippet.create.useMutation()而不是旧版的api.snippet.create.mutation()。这个变化不大但网上大量教程还是 v10 语法照着抄就会遇到Cannot read properties of undefined。建议遇到任何 tRPC 报错先看 node_modules 里的实际版本再查对应版本的官方迁移文档。Tailwind v4 换成了 CSS-first 配置tailwind.config.ts被theme指令取代主题色、字体这些配置都写进 CSS 文件里。如果你从 v3 升上来别急着改全项目先跑npx tailwindcss/upgrade看它自动迁移成什么样再手改剩余部分。React Query v5 把isLoading拆成了isLoading和isPending两个概念。isLoading现在只表示没有缓存且正在请求查询刷新时不会触发如果想要请求中的效果用isFetching。这个语义变化会直接影响页面 loading 态的展示逻辑。我因为这些版本差异前后改了三次页面状态逻辑教训是大版本升级别跟着感觉走release notes 值得逐行读。5.3 部署到 Vercel搞定了 SQLite 的坑又遇到连接池的问题最初为了省事我把数据库定为 SQLite本地开发确实舒服一个文件完事。结果部署到 Vercel 后所有写操作全部失败日志里写着database disk image is malformed之类的问题。原因不神秘Vercel 的函数运行在无状态的 serverless 环境里文件系统是临时且只读的SQLite 文件根本存不下来。就算勉强能写两个并发请求各写各的文件副本数据照样丢。这条路走不通后我切到了 PostgreSQL。这时又冒出新问题serverless 函数频繁建立数据库连接把连接数打满日志里出现remaining connection slots are reserved for non-replication superuser connections。解决方法是给 Prisma 配两个 URL——业务连接走连接池迁移和管理任务走直连datasource db { provider postgresql url env(DATABASE_URL) directUrl env(DIRECT_URL) }DATABASE_URL用 Neon 或 Supabase 提供的池化连接串DIRECT_URL用直连串跑迁移。这是 Prisma serverless PostgreSQL 的标准配置如果你也遇到连接数问题优先检查这两个 URL 是不是混用了。6. t3code 的边界与下一步什么场景不适合这种全 TypeScript 方案6.1 别盲目套用 T3对外开放 API、复杂多端生态要慎重t3code 用 T3 Stack 很舒服但我不会建议所有项目都这么选。T3 的前提是前后端同仓库、同语言、同团队。一旦出现下面几种情况T3 的优势会迅速缩水对外开放的 API。第三方开发者不需要你的 TypeScript 类型他们需要的是稳定的 OpenAPI 文档。tRPC 也能导出 OpenAPI但那是额外的一层不如直接以 REST 规范对外。多端复杂消费方。移动端、小程序、桌面端同时消费同一份后端能力时GraphQL 的聚合查询能力比 tRPC 更适合。tRPC 的本质是把服务端函数直接暴露给客户端它的类型红利只在 TypeScript 生态内生效。对原始接口性能极其敏感的场景。tRPC 的请求是 JSON RPC 风格虽然体积不大但对比精心设计的二进制协议效率和灵活性不在一个量级。这类需求一般出现在实时音视频、高并发物联网等方向普通业务项目很少碰到。我的建议是判断标准不要看技术流不流行而是看类型链路能不能贯穿你产品的整个消费链条。链条里只要有一段不是 TypeScripttRPC 的那层螺丝就得换个规格。6.2 后续要补的功课全文检索、鉴权、限流与可观测性t3code 上线后我列了一个还没做完的清单按优先级排了四件事。全文检索。目前搜索用的是 Prisma 的contains查询几百条片段时毫无压力但上万条之后性能会明显下滑。PostgreSQL 有tsvectorSQLite 有 FTS5都是成熟方案。做这个升级时要注意Prisma 对全文检索的抽象很弱大概率要走$queryRaw手写 SQL类型安全要靠自己小心维护。鉴权。当时为了省事没做 NextAuth但代码片段管理工具下一步肯定要支持多设备、多用户。加鉴权时记得把publicProcedure换成带中间件保护的protectedProcedure这是 create-t3-app 里现成的模式别自己发明轮子。限流。tRPC 的过程没有任何内置限流能力暴露在公网等于裸奔。可以用 Vercel 的边缘中间件按 IP 限流或者在 memo 上给 procedure 包一层计数器。这个平时看不出来一旦有人写脚本刷你的接口就懂了。可观测性。SERVER 端错误目前靠平台日志前端错误没有收集。下一步打算接 OpenTelemetry把 tRPC 的 procedure 名称作为 span 名这样每个接口的耗时和错误率都能在仪表盘上看到。说实话t3code 这个项目最值钱的产出不是那几千行代码而是让我彻底养成了让类型牵着开发走的习惯。现在我做任何全栈功能第一步永远是先把输入输出类型定义清楚再有下一步。以前写接口是先想路由再想文档现在写接口是先想类型再想过程——这个先后顺序的反转体验过的人自然明白。如果你也在被接口同步问题反复折磨顺手拿一个小工具项目试一试 T3 Stack不用大张旗鼓把类型安全端到端地走通一次很多长期困扰你的问题会自动消失。