
我最近在维护一个叫 t3code 的小项目最初真的只是图自己查代码方便写过的订单状态机、文件上传分片、SSE 重连逻辑散落在三四个仓库、几百个 Markdown 笔记里还有不少躺在聊天记录中。等下次要用的时候翻聊天记录比重新写一遍还痛苦。后来我干脆用 T3 Stack 把它做成一个代码片段管理工具后端、前端、鉴权、数据库全走一套 TypeScript 类型系统从录入到检索再到分享形成闭环。t3code 这个名字没啥花哨含义技术栈是 T3业务是做 code snippet 的管理合在一起就是它。如果你也在做类似的全栈工具或者想找一个能真正练手 T3 Stack 的场景这篇文章比较适合你。我会把项目从立项到上线的完整思路写清楚包括技术选型为什么这么定、数据库模型怎么设计、tRPC 接口怎么组织以及部署到 Serverless 环境后踩过的几个坑。项目本身不大但覆盖的点很全很多问题都是真实跑过之后才发现的。1. 为什么做 t3code团队里缺的不是代码是能检索到的代码1.1 需求的起点片段散落在飞书、IDE、聊天记录里先说背景。我所在的小组经常处理相似的业务逻辑比如根据用户等级计算折扣、导出大 Excel 时控制内存峰值、定时任务里做幂等去重。这些逻辑每个项目都略有不同但核心思路一致。按道理应该沉淀成公共代码片段但实际情况是老项目的代码还在仓库里但没人记得具体是哪个文件有人把片段贴到飞书文档里文档权限混乱没做过全文索引个人 IDE 的 snippet 只能自己用换个电脑就没了偶尔有人分享 Gist但 Gist 默认是公开的稍微带点内部逻辑就没人敢发。所以我希望 t3code 能解决几个很具体的问题录入一个片段后一秒钟之内能按语言、标签、关键词定位到它片段可以标记为私有也可以生成分享链接给同事每个片段附带简述和使用注意点这样半年后看也不会一脸懵。做产品的人会立刻想到这不就是又一个 Gist 吗对也不对。Gist 更适合公开小代码团队内部用有两个硬伤一是没有“代码片段库”的目录感二是搜索能力很弱。我要做的是面向团队内部少量人使用的私有工具核心不是“发出去”而是“存下来、找得到”。需求定位清楚后项目边界也顺势定了暂不做代码运行评测、不做多端离线同步、不做复杂的权限体系先把“增删改查 全文检索 分享”这条主链路打穿。1.2 为什么项目要叫 t3codeT3 Stack 这个组合在 TypeScript 圈子里不算陌生Next.js 做应用框架tRPC 做端到端类型安全的 API 层Tailwind 管样式Prisma 管数据库NextAuth 做鉴权底层全部由 TypeScript 串起来。因为 t3code 本质上服务于“代码”而且所有数据都是纯文本非常适合用这套技术栈快速验证。在正式动手之前我也考虑过其他方案纯 Next.js REST API需要在前后端各写一套类型定义接口一多很容易两边不同步Next.js GraphQL类型安全很爽但 GraphQL 的学习成本和服务器复杂度对这个体量的项目偏高用现成的开源服务如 Outline、Docmost功能确实强但定制成本不低而且很多都假设你有自建基础设施的前提。t3code 选择 T3 Stack 的核心逻辑其实只有一句话让类型从数据库一路流到 UI中间不需要我手动维护任何 API 契约。这个“不手动维护”在几十个片段的时候感受不明显一旦接口数量膨胀到几十上百个优势非常明显。后面我会具体讲 tRPC 是怎么做到这一点。2. t3code 的核心选型T3 Stack 五件套怎么搭才不浪费2.1 用 create-t3-app 一分钟初始化项目T3 Stack 的官方脚手架是 create-t3-app它和别的脚手架最大区别是会让你交互式勾选组件而不是一股脑全装。我初始化 t3code 的命令是pnpm dlx create-t3-applatest t3code接下来选择时我勾了 Next.js App Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth.js、ESLint 和 Prettier。这里有两个选择值得说明第一App Router 是 Next.js 近两年的主线方向虽然服务端组件和客户端组件的边界需要一点时间适应但新项目没有历史包袱直接用是最省的。第二数据库层面我建议先用 SQLite 起步。t3code 在开发阶段数据量很小SQLite 不需要额外起服务file:./db.sqlite就能跑。等需要部署到 Serverless 环境时再切换到 PostgreSQL这个切换我在后面部署章节详细说。脚手架初始化完以后目录结构大概是这样的t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ └── routers/ │ │ └── db.ts │ └── trpc/ │ ├── server.ts │ └── react.tsx ├── .env ├── package.json └── next.config.jscreate-t3-app 默认已经帮你配好了 Prisma Client 单例在src/server/db.ts、tRPC 的 server 和 react 两套入口、NextAuth 的初始化骨架。所以第一分钟你不需要理解所有细节只需要知道每个文件是干什么的然后往里填业务即可。2.2 五件套各自的职责与取舍很多初学者会问tRPC 和 REST、GraphQL 到底差在哪用一句话概括REST 是“接口即约定”GraphQL 是“查询即约定”tRPC 是“函数即约定”。你不需要写 OpenAPI 文档不需要手写请求类型前端拿到的就是和后端定义一致的 TypeScript 类型。我整理了 t3code 用到的五个核心组件以及它们各自承担的职责组件在 t3code 里的角色我为什么选它Next.js App Router页面结构、路由、服务端渲染一个框架同时覆盖前端和后端接口部署模型简单TypeScript全链路类型检查数据库返回类型可以一直穿透到组件减少低级错误tRPC前后端 API 层无需维护接口文档新增查询就自动暴露给前端Prisma数据库访问层schema 即文档迁移和改表都很直观NextAuth登录与会话和 tRPC context 融合度高Google/GitHub 登录开箱即用Tailwind CSSUI 样式写小工具类不需要切到 css 文件找样式上下文这里我想特别强调一下 tRPC 的一个使用原则不要为了 tRPC 把所有逻辑都塞到 API 层。在 t3code 里list、create、update、delete这些需要持久化的操作走 tRPC mutation/query 没问题但像“把 Markdown 转成 HTML”“高亮代码”这类纯计算应该放在服务端渲染阶段或者独立工具函数里不要让客户端每次都调一次接口。这样能让 tRPC 接口保持精简也不容易产生额外网络请求。2.3 环境准备里最容易被忽略的版本问题如果你打算照着复现 t3code环境版本最好先固定一批Node.js 20 LTS 及以上 pnpm 8 及以上 Prisma 6.x Next.js 14.2 及以上 TypeScript 5.x最容易翻车的是两个地方。一个是pnpm版本和 Next.js 的依赖解析方式偶发会出现.pnpm目录下包路径不对的问题最常见的解决办法是rm -rf node_modules pnpm install重新装一遍不要盲目升级大版本。另一个是 Tailwind CSS 和 PostCSS 的兼容如果你拉到的脚手架版本比较旧注意看tailwind.config.ts里的content是否把src/**/*.tsx和src/**/*.ts都包含进去漏掉某一个目录很可能导致样式时有时无。环境变量方面create-t3-app 会生成一份.env.example我复制成.env后做了这样几项配置# 开发数据库 DATABASE_URLfile:./db.sqlite # NextAuth 相关本地随便填个足够长的随机字符串 NEXTAUTH_SECRETlocal-dev-secret-change-me NEXTAUTH_URLhttp://localhost:3000这两行看起来简单但它们决定了后续很多坑的方向NEXTAUTH_SECRET 不一致会导致生产环境登录会话失效DATABASE_URL 不区分开发和生产会导致误操作到真实数据库。建议从开发第一天就把环境变量按环境切分不要图省事只用一个。3. 数据库模型与 tRPC 接口端到端类型安全的完整落地过程3.1 schema.prisma 设计片段、标签、用户的关系t3code 的业务对象其实不复杂用户、代码片段、标签、收藏夹。但关系设计上我做了几个刻意选择写出来给你参考。model User { id String id default(cuid()) name String? email String? unique snippets Snippet[] collections Collection[] } model Snippet { id String id default(cuid()) title String content String db.Text language String description String? isPublic Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) tags Tag[] collections Collection[] } model Tag { id String id default(cuid()) name String unique snippets Snippet[] } model Collection { id String id default(cuid()) name String userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) snippets Snippet[] }几个设计点解释一下主键我没有用自增整数而是用cuid()。原因有两个一是避免通过 ID 猜测系统里有多少条片段二是未来要扩展多节点写入时cuid 冲突概率远低于自增 IDSnippet的isPublic默认是false也就是说默认所有片段都只有自己可见分享是主动行为。这个设计保证了“贴代码”这个行为的心理门槛足够低不会因为怕泄露而干脆不记Tag的 name 设置成unique新建片段时如果是已有标签就直接关联不重复建标签Collection相当于用户自建的文件夹和Tag的区别在于它有明确的用户归属只有自己能增删里面放了哪些片段。写完 schema 后执行npx prisma migrate dev --name init npx prisma generatemigrate dev会生成迁移文件并同步更新数据库generate会生成 TypeScript 客户端类型。这一步做完你就能在编辑器里体会到 Prisma 类型提示的好处——比如写错了字段名保存的瞬间就会标红。3.2 tRPC Router 的代码骨架t3code 的接口层主要分成两部分一部分处理片段列表和详情一部分处理片段的新增、修改、删除。以新增片段为例我的snippetrouter 是这样的// src/server/api/routers/snippet.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure, } from ~/server/api/trpc; const createSnippetSchema z.object({ title: z.string().min(1, 标题不能为空), content: z.string().min(1, 代码内容不能为空), language: z.string().min(1), description: z.string().optional(), tagNames: z.array(z.string()).default([]), isPublic: z.boolean().default(false), }); export const snippetRouter createTRPCRouter({ list: publicProcedure .input( z.object({ language: z.string().optional(), tag: z.string().optional(), keyword: z.string().optional(), }) ) .query(async ({ ctx }) { return ctx.db.snippet.findMany({ where: { OR: [{ isPublic: true }, { authorId: ctx.session?.user?.id }], }, include: { tags: true, author: { select: { name: true } }, }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(createSnippetSchema) .mutation(async ({ ctx, input }) { const tagNames [...new Set(input.tagNames.map((t) t.trim()))]; const tagConnections tagNames.map((name) ({ name })); return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, description: input.description, isPublic: input.isPublic, authorId: ctx.session.user.id, tags: { connectOrCreate: tagConnections.map((tag) ({ where: { name: tag.name }, create: { name: tag.name }, })), }, }, }); }), });这里最有价值的部分是connectOrCreate。它做的事是如果标签已存在就直接关联如果不存在就顺便创建一条。这比“先查标签再创建”少了一次网络往返而且代码更简洁。再解释一下publicProcedure和protectedProcedure的差别publicProcedure允许未登录用户访问列表里公开的片段protectedProcedure要求必须有登录会话在中间件里会校验ctx.session拿不到就抛UNAUTHORIZED。t3code 里所有写操作都应该是登录后行为所以统一用protectedProcedure。3.3 前端组件怎么调用 tRPCt3code 的前端采用“列表页用服务端组件交互区用客户端组件”的混合模式。这样做的原因是列表数据对 SEO 要求不高但首屏希望尽快看到内容而新增、编辑这种交互必须由客户端触发。在客户端组件里调用 tRPC 的写法是use client; import { api } from ~/trpc/react; export function SnippetList() { const [list] api.snippet.list.useSuspenseQuery({}); return ( ul {list.map((item) ( li key{item.id} code{item.language}/code p{item.title}/p /li ))} /ul ); }如果你使用useSuspenseQuery需要配合Suspense边界使用否则页面会一直转圈。如果不想用 Suspense也可以换成useQuery但就要自己处理 loading 和 error 状态。新增片段之后想让列表刷新不必去 reload 页面tRPC 提供了缓存失效机制const utils api.useUtils(); await api.snippet.create.mutateAsync({ title: form.title, content: form.content, language: form.language, tagNames: form.tags, }); utils.snippet.list.invalidate();invalidate会把 tRPC 内部缓存的list查询标记为过期下次渲染时自动重新请求。这里有个常见误解invalidate是异步的但你不一定要await它。如果不关心列表是否立刻刷新就让它自己跑如果你希望列表马上是最新建议await utils.snippet.list.invalidate()等它会话再给用户反馈。4. 真实踩坑复盘连接池、缓存与中间件的三连击4.1 坑一开发环境一切正常部署后偶发 500t3code 在本地跑了一个多月都没问题Schema 改过五六次数据也建了不少。结果一部署到 Vercel隔三差五就报PrismaClientInitializationError错误信息大致是PrismaClientInitializationError: P1001: Cant reach database server at ...第一次看到这个报错我以为是连接串写错了反复检查了十几遍都没问题。后来才意识到问题不在连接串而在 Serverless 的实例生命周期Vercel 的每个请求可能由不同的运行实例处理如果每次实例启动都创建新的 PrismaClient短时间内会打爆数据库连接数。本地开发因为是一个长驻进程所以永远触发不了这个问题。标准解法是让 PrismaClient 复用同一个实例。create-t3-app 已经预设了这么一段代码但你得确认它真的生效了// src/server/db.ts import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }这段代码的思路很简单开发环境热更新可能会导致模块重复加载所以把实例挂到globalThis上生产环境虽然不挂但每个 Serverless 实例内部还是会尽量复用。如果走到这里还偶发 500那就不是单例问题而是数据库连接池容量问题。我最终换成了 PostgreSQL 的池化连接参数DATABASE_URLpostgresql://user:passwordhost:5432/t3code?pgbouncertrue加上pgbouncertrue后Prisma 会知道你的连接走的是 PgBouncer从而限制连接存活时间避免连接耗尽。4.2 坑二tRPC 查询结果像是被“缓存”了上线后有个现象很奇怪新增一个片段列表不更新刷新页面也不一定有要过几秒或者再刷新一次才能看到。一开始我怀疑是 tRPC 缓存于是加了一堆invalidate仍然没有根治。后来我把问题拆开排查链路是这样的第一步直接查数据库确认数据已经写入了 —— 数据库没问题第二步在浏览器里直接访问 tRPC 的接口地址返回的结果是最新的 —— 后端接口没问题第三步看页面请求 Load 面板发现页面根本没有发新的 tRPC 请求 —— 问题出在前端。再深挖发现是 Next.js App Router 的客户端 Router Cache 在起作用。Next.js 会把 RSC Payload 缓存到客户端如果你的页面没有显式声明动态渲染它可能把数据当作静态内容缓存住。举个例子我在app/snippets/page.tsx里没有加任何动态标记Next.js 可能把首次请求的结果缓存下来之后从缓存里取。解决方式有三种按推荐程度排序在相关的 route 上加export const dynamic force-dynamic;这告诉 Next.js 这个路由每次请求都动态渲染不做静态缓存。在调用list的页面或布局中读取 cookies 或 headers 这样的动态 APINext.js 会自动把它标记为动态渲染。mutation 完成后调用router.refresh()强制刷新当前路由的服务端组件数据。这个命令在 App Router 里很实用但它会刷新整个路由代价比invalidate大。最终我把动态渲染标记加到了片段详情页而列表页保留了一部分静态缓存只对“最近更新”区域做动态刷新。这样即保证了新数据能出现又不会每个请求都重新渲染整棵树。4.3 坑三NextAuth 的 session 在 tRPC context 里拿不到这个坑让我印象最深。t3code 在本地登录没问题但部署到 Vercel 之后凡是protectedProcedure都报未授权。登录接口本身正常浏览器里能看到 session cookie但到 tRPC 的 context 里getServerSession拿到了null。排查时我一度怀疑是 cookie 跨域问题后来把环境变量逐行对比发现问题出在NEXTAUTH_SECRET本地的.env和生产环境的 Vercel 环境变量里存了不同的值。NextAuth 的 session 是加密签名的如果部署后的 secret 和登录时不一致服务端就无法验证 session 的合法性。这在本地不会发生因为本地永远只有一套配置。另一个容易忽视的点是 tRPC context 的写法。在 create-t3-app 的新版本里服务端渲染场景下你需要确保 context 里拿到的是同一个 session 对象。我的最终代码是这样// src/server/api/trpc.ts import { initTRPC, TRPCError, } from trpc/server; import { getServerSession } from next-auth; import { authOptions } from ~/server/auth; import { db } from ~/server/db; export const createTRPCContext async () { const session await getServerSession(authOptions); return { db, session }; }; const t initTRPC.contexttypeof createTRPCContext().create(); export const createTRPCRouter t.router; export const publicProcedure t.procedure; const enforceUserIsAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure t.procedure.use(enforceUserIsAuthed);这里的关键是 middleWare 会把 session 解构后传给下一个中间件保证后续逻辑中一定存在ctx.session.user。如果你的 context 里session本身就是null那直接再看 session 来源是不是 NextAuth 的authOptions没有正确配置 provider先解决登录问题再排查 tRPC。我把这三个坑整理成了一张自查表方便你踩雷时快速对照现象可能原因优先检查部署后偶发 500PrismaClient 被反复创建 / 连接池耗尽全局单例、池化连接参数新增数据后页面不刷新Next.js Router Cache / tRPC 缓存未失效动态渲染标记、invalidate、router.refresh()所有受保护接口报未授权NEXTAUTH_SECRET 不一致 / session 获取失败环境变量、getServerSession的前后一致性5. 部署到生产环境的细节与后续优化5.1 首次部署环境变量与 migrate 流程t3code 的部署目标选了 Vercel因为 Next.js 项目在 Vercel 上基本零配置。不过这并不代表没有注意点。首次部署时我踩了这样一个流程坑在本地建好了 SQLite 数据库代码推上去之后远程环境照样连不上数据库。原因很简单Vercel 的 Serverless 文件系统是只读的SQLite 这种单文件数据库根本没法持久化。所以生产环境必须换成 PostgreSQL。我的迁移步骤是这样的在 Supabase 或 Neon 上创建一个 PostgreSQL 实例拿到连接串本地schema.prisma确认provider postgresql重新生成迁移文件执行npx prisma migrate deploy在生产数据库上执行迁移在 Vercel 项目设置里配置三个环境变量DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL部署后访问/api/auth/session确认登录会话正常。这里必须强调一个和本地开发的差异本地开发时我习惯直接用prisma db push同步 schema因为它不产生迁移文件改表速度快。但生产环境千万别用db push一是它不可追溯二是多人协作时没人知道数据库到底被谁改过。规范的做法是prisma migrate deploy它会按迁移文件顺序执行保证每个环境的结构一致。5.2 性能与资源控制小项目也要有优化的意识t3code 的数据量短时间内不会很大但我在写的时候还是埋了几个优化点免得以后改起来伤筋动骨。第一个是列表分页。目前的list接口直接返回全部片段量大了会有两个问题网络包变大、数据库查询变慢。我给list加上了cursor分页模式list: publicProcedure .input( z.object({ cursor: z.string().optional(), take: z.number().max(50).default(20), }) ) .query(async ({ ctx, input }) { return ctx.db.snippet.findMany({ take: input.take 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: desc }, }); }),take: input.take 1是判断是否还有下一页的技巧如果返回的记录数大于take就说明还有下一页前端再把最后一条记录的 id 作为 cursor 继续拉。第二个是代码高亮。t3code 里片段内容可能比较长如果在前端引入一个重量级高亮库首屏会明显变慢。我选择把高亮逻辑放到服务端来做用 Shiki 在页面渲染时把代码转成带样式类名的 HTML前端只负责展示。这样用户的浏览器不加载高亮库代码高亮也不会闪烁。第三个是全文搜索。小项目阶段用 Prisma 的contains勉强够用where: { OR: [ { title: { contains: keyword } }, { content: { contains: keyword } }, ], }但超过几千条之后contains的性能就会开始捉急。如果后续集成真正的全文检索我会优先考虑 PostgreSQL 的pg_trgm扩展再进一步才会上独立的搜索引擎。这些都不用现在做但 schema 和接口层面要预留好扩展点别把自己写死。5.3 后续规划从“自用工具”到“团队可用平台”t3code 现在满足了我的核心诉求快速记录、快速检索、可分享。但你要说它是个好产品还差得远。我自己整理的后续优化方向大致有三块第一体验层面的补充分享链接支持有效期过期后自动失效片段支持 Fork也就是在别人公开片段的基础上另存一个自己的版本增加收藏夹的模糊搜索。第二数据层面的扩展把 Markdown 说明和代码内容拆开支持下载 Markdown 文件快照提供浏览器扩展在 GitHub 或 GitLab 页面上右键即可把选中代码存入 t3code。第三工程层面的完善给 tRPC 接口加上请求日志和错误上报用 Playwright 写几条核心路径的 e2e 测试把数据库迁移流程 CI 化每次 schema 变更自动生成迁移文件。很多项目做到“自用 OK”就停了但如果你真的打算给团队用稳定性和数据安全就必须排在前面。比如片段内容对团队可能是核心资产导出和删除都必须有明确的操作记录这些后续大概率得用一张 audit log 表来支撑。最后再分享一个我个人的操作习惯所有代码片段录入时强制要求填language和description两个字段。前者是为了筛选方便后者是为了让别人包括未来的自己能在一分钟内理解这段代码解决的是什么问题。这个习惯比任何技术选型都重要因为工具再顺手也架不住你存进来的是没有上下文的半成品。t3code 这个项目让我真正体会到了“类型安全 统一录入规范”带来的长期价值也建议你从自己的高频重复代码开始先存它十个片段感受一下检索效率的变化再做下一步功能的优先级判断。