基于T3 Stack的全栈代码片段管理工具设计与实践 t3code 这个名字是我给自己最近一个全栈项目起的代号。T3 对应 T3 Stackcode 则点明了它的核心用途管好代码片段。起因是一次真实的团队事故我们在群里排查线上问题同事把一段报错代码直接粘进聊天窗口缩进全部丢失最关键的一行被一刀截断其他人只能反复追问“这里是不是少了个括号”。我当时就在想如果有一个工具能把代码优雅地展示出来生成一个临时链接这场沟通至少能省掉十分钟。于是我利用业余时间花了几周前后写了两个版本。第一版是纯前端数据存在 localStorage 里刷新换个浏览器就找不到第二版才是现在的 t3code——一个基于 Next.js 全栈开发支持用户登录、代码片段管理、标签筛选、公开分享链接并且自带语法高亮的在线工具。这篇文章会从头到尾复盘这个项目为什么选 T3 这套技术栈、核心模块怎么落地、部署到生产前踩了哪些坑以及上线用了一个多月后的真实感受。如果你正准备用 T3 Stack 做全栈项目或者想给团队搭一个内部小工具这篇内容可以直接当参照系。1. 项目缘起需求范围与技术选型的真实思考1.1 需求是怎么一步步收敛的很多人做小工具容易犯一个毛病想到一个需求就往上加最后做成一锅烩。t3code 一开始也差点走偏我在纸上列了十多个功能包括实时协作、AI 提示、代码评论差点把自己累死。后来硬生生砍回三条 MVP 主线创建代码片段粘贴代码、选语言、填标题最好还能加几个标签。管理我的片段登录后看到自己创建过的所有片段分享过的、私有的一目了然。生成公开分享链接一个短链接发给别人就能以最舒服的排版方式查看代码不需要对方登录。这三条跑通之后我再逐步补了公开浏览页、语法高亮主题切换、软删除和分页。明确“不做什么”和“做什么”同样重要这决定了项目边界也决定了我后续所有技术选型的范围。1.2 为什么是 T3 Stack而不是 Express REST 或者 GraphQL这个项目全部代码只有一个代码库、同一个 TypeScript 类型系统前后端团队并不存在“分离”的需求。在这种情况下传统做法是 Next.js 提供 API Route前端手写 fetch 请求再在接口层定义一遍类型加上一个 Express 中间层反而让链路变长。T3 Stack 的核心思路是让类型在整个应用里自然流动。服务端用 tRPC 定义 router 和 procedure客户端调用时所有输入输出类型自动推导不需要手写 API 客户端也不需要维护 OpenAPI spec。像我这种一个人同时写前端和后端的场景省下来的工作量非常直观。下表是我当时对比过的三种姿势方案类型安全额外代码量适合场景REST fetch需要手写类型定义中对外公开 API、多客户端GraphQL强但需要 schema 维护高多数据源聚合、复杂查询tRPC极强零生成步骤低前后端同仓库、内部工具数据库层我选了 Prisma。理由有两方面一是它的 schema 文件非常直观改了模型马上能脑补出表结构二是迁移流程成熟prisma migrate dev在本地开发时几乎是零成本的。TailwindCSS 则让我在写样式时不需要频繁切换 CSS 文件对迭代速度有明显帮助。需要说明的是T3 Stack 并不是银弹。如果你的应用将来要做大规模外部开放 API或者要服务非 TypeScript 语言的前端tRPC 的优势会大打折扣。但 t3code 定位就是“团队内部/个人全栈小工具”它刚好卡在 T3 最舒服的场景里。2. 项目骨架从 create-t3-app 到可运行的初始版本2.1 脚手架初始化与选项取舍T3 官方提供了配套脚手架我第一次跑的时候用的是create-t3-app。命令很简单pnpm create t3-applatest t3code交互式选项里会问要不要启用 Tailwind、tRPC、Prisma、NextAuth、Drizzle 等等。我当时的选择是Next.js默认 App Router、TypeScript、Tailwind、tRPC、Prisma 全开NextAuth 先勾上因为后面做“我的片段”一定需要登录Drizzle 我选了否因为团队长期用 Prismaschema 编辑体验更直观。这里给一个小建议create-t3-app生成了非常完整的目录结构第一次看会觉得复杂但很多文件在初期根本不用动。核心入口就几个src/server/db.ts负责数据库连接src/server/api/root.ts组装所有子路由src/trpc/server.ts和src/trpc/react.ts负责服务端和客户端的 tRPC 初始化。如果你只是搭一个工具型应用先跑通默认的postRouter示例再把它删掉替换成自己的业务 router是最平滑的路径。2.2 环境变量与本地数据库的配置t3code 的本地开发环境早期用的是 SQLite这是 create-t3-app 默认支持的选项配置也非常轻。.env文件大概长这样DATABASE_URLfile:./dev.db NEXTAUTH_SECRETlocal-dev-only-secret NEXTAUTH_URLhttp://localhost:3000 AUTH_GITHUB_ID AUTH_GITHUB_SECRET这里有一个非常容易踩的坑SQLite 的DATABASE_URL必须写成file:./dev.db这种格式不能直接填一个普通路径。我第一次往里填了./dev.dbprisma db push直接报错后来看了 Prisma 文档才发现file:协议前缀是关键。本地开发时我用prisma db push快速同步 schema 到 SQLite它不做迁移历史记录对于原型阶段足够用等到项目接近上线我换成了 PostgreSQL并改用prisma migrate dev生成正式的迁移文件。这里想提醒一句千万不要在开发中途频繁切换数据库否则迁移文件会非常混乱。我在 t3code 上是早期就确定了“SQLite 起步、生产迁到 Postgres”的路线切换过程还算可控。2.3 脚手架里哪些文件值得细读刚初始化完时我花了半个小时读生成代码并不是浪费时间。有几个文件值得每一个想深度使用 T3 的人认真看src/server/api/root.ts所有子 router 在这里集合理解它就知道 tRPC 是怎么把一堆能力组合成 APP 的。src/trpc/server.ts服务端 tRPC 初始化定义了publicProcedure和protectedProcedure。src/app/api/trpc/[trpc]/route.ts这是整个 App Router 和 tRPC 的桥接层没有它客户端请求就进不来。src/server/auth.tsNextAuth 配置和服务端 session 校验逻辑。大多数人拿到脚手架的第一反应是直接开写业务代码但我的习惯是先完整读一遍生成的文件。因为 tRPC 的类型推导依赖上下文类型在createTRPCContext里少注入一个 session后面所有的前后端联调都会出现问题到时候再回头找根因比一开始读一遍目录要贵得多。3. 数据模型与 tRPC 路由类型安全项目的核心3.1 Prisma Schema用户、片段、标签之间的关系t3code 的业务模型对照需求非常直接一个用户可以创建很多个 Snippet每个 Snippet 可以关联多个 TagTag 也可以被多个 Snippet 使用。下面是精简后的 Prisma Schemamodel User { id String id default(cuid()) name String? email String? unique createdAt DateTime default(now()) snippets Snippet[] } model Snippet { id String id default(cuid()) title String content String db.Text language String default(text) theme String default(github-light) isPublic Boolean default(false) shareId String? unique expiresAt DateTime? deletedAt DateTime? createdAt DateTime default(now()) updatedAt DateTime updatedAt author User relation(fields: [authorId], references: [id]) authorId String tags Tag[] } model Tag { id String id default(cuid()) name String unique snippets Snippet[] }这里我故意用 Prisma 的隐式多对多也就是只写Snippet.tags Tag[]和Tag.snippets Snippet[]让 Prisma 自动生成中间表。好处是查询时直接include: { tags: true }就能拿到标签列表不需要手动管理中间表代码会很简洁。shareId字段是给公开分享链接用的它是唯一索引这样同一片段生成分享链接后不会因为重复而冲突deletedAt是软删除字段后面会专门讲。content用db.Text是为了容纳长代码块SQLite 的TEXT类型其实对长度没有严格限制但写成db.Text在 Postgres 上语义更明确。3.2 路由设计查询、变更和权限的拆分t3code 的 tRPC 路由我按业务模块拆而不是按页面拆。核心是snippetRouter里面定义了所有围绕 snippet 的操作。下面是一个代表性片段export const snippetRouter router({ listPublic: publicProcedure .input( z.object({ cursor: z.string().nullish(), language: z.string().optional(), tag: z.string().optional(), }) ) .query(async ({ ctx, input }) { return ctx.db.snippet.findMany({ where: { isPublic: true, deletedAt: null, ...(input.language ? { language: input.language } : {}), ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), }, include: { author: { select: { name: true } }, tags: { select: { name: true } }, }, orderBy: { createdAt: desc }, take: 20, skip: input.cursor ? 1 : 0, cursor: input.cursor ? { id: input.cursor } : undefined, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().default(text), theme: z.string().default(github-light), tags: z.array(z.string().min(1).max(30)).default([]), isPublic: z.boolean().default(false), }) ) .mutation(async ({ ctx, input }) { return ctx.db.snippet.create({ data: { ...input, authorId: ctx.session.user.id, tags: { connectOrCreate: input.tags.map((name) ({ where: { name }, create: { name }, })), }, }, }); }), });几个设计点值得展开所有输入都用 zod 校验。tRPC 与 zod 是天然搭配输入类型在服务端校验同时作为前端推断的源头。比如title设定了min(1).max(100)前端表单不写任何额外校验函数也能在提交前就拿到同样的类型约束。protectedProcedure是我改造过的过程它在执行前检查 session未登录直接抛UNAUTHORIZED错误。创建、修改、删除这类操作全部走它。查询列表用游标分页。数据量小的时候skip也行但一旦片段多了cursor的稳定性和性能都更好。这个函数内部的skip: input.cursor ? 1 : 0是因为 Prisma 的 cursor 分页默认会包含当前游标项需要手动跳过。3.3 类型是如何在前后端之间流动的t3code 最让我省心的环节就是“前后端类型不会脱节”。在客户端组件里我可以这样用const { data, fetchNextPage, hasNextPage } api.snippet.listPublic.useInfiniteQuery({ language: typescript, }); const createSnippet api.snippet.create.useMutation();当我调用api.snippet.create.useMutation()时mutate需要传的参数类型就是我在路由里定义的 zod input 类型返回的数据类型就是服务端 mutation 返回的类型。如果服务端增加一个字段前端在编辑器里立刻能看到类型缺失如果服务端改了字段名前端直接变红。有人可能觉得这跟 GraphQL 的 codegen 差不多但体验上的差异是tRPC 不需要预生成步骤所有类型推导发生在编译期基于 TypeScript 的 import 结构。对t3code这种纯内部项目来说它是把开发体验从“写代码时小心翼翼”提升到了“写完前端基本上不可能拼错字段名”。这一节我还想分享一个后期体会路由设计不要拆得太碎。我一开始把tagRouter单独拆出来后来发现 tag 基本只在 snippet 列表页作为筛选条件使用单独拆出去意味着多维护一个上下文和几个重复查询。后来我直接把它收敛到 snippetRouter 内部。T3 的 tRPC 是类型安全的过度拆分不会提升安全性只会增加思维负担。4. 编辑器与高亮实现代码展示体验的关键4.1 编辑侧接入 Monaco但要绕开默认 CDNt3code 的编辑页需要用户直接粘贴代码我希望提供接近 VS Code 的编辑体验所以选了 Monaco Editor。接入本身用社区封装好的monaco-editor/react很容易import Editor from monaco-editor/react; Editor height60vh defaultLanguage{language} value{content} onChange{(value) setContent(value ?? )} themevs-dark options{{ fontSize: 14, wordWrap: on, minimap: { enabled: true } }} /;但有一个坑必须提前说monaco-editor/react默认会从 jsdelivr 的 CDN 加载 Monaco 的脚本。如果你的部署环境对公网依赖敏感或者用户网络访问 CDN 慢编辑器会卡在加载状态很久。我当时在本地没问题部署到线上后同事反馈编辑器有时打不开排查发现是公网 CDN 偶尔超时。解决办法是把 Monaco 的静态资源打包到本地用loader.config指定资源路径import * as monaco from monaco-editor; import { loader } from monaco-editor/react; loader.config({ monaco });这样会显著增加 bundle 体积所以我用next/dynamic把 Editor 组件做了动态加载只有进入编辑页时才加载对应代码首页、列表页完全不受影响。这一套组合下来编辑器的加载速度稳定了很多。4.2 展示侧用 Shiki 做语法高亮并处理好 XSS分享出去的片段是给其他人看的展示端的语法高亮质量直接影响观感。我试过 Monaco 的只读模式也试过react-syntax-highlighter最后留在 t3code 里的是 Shiki。Shiki 的模型是用 TextMate 语法做高亮生成的 HTML 颜色准确度不错视觉上很接近 VS Code。在 Next.js 服务端我的做法是直接调用codeToHtmlimport { codeToHtml } from shiki; const html await codeToHtml(content, { lang: language, theme: theme, });然后把生成的 HTML 字符串传给展示组件。这里有一个安全细节因为在服务端已经做了一次高亮转换前端如果直接用dangerouslySetInnerHTML渲染一定要确保后端在生成高亮前对代码内容做了转义否则代码里如果包含script之类的内容会有注入风险。Shiki 内部做了转义处理但我在抽出来做成一个工具函数时还是会先跑一层 escape 逻辑兜底这是任何代码展示类产品都不能省略的一步。还有一个性能和设计上的取舍因为codeToHtml是异步的如果在详情页每次都现场生成响应时间会偏高。我后来把高亮结果作为一种可缓存的字符串字段存进了数据库分享链接第一次访问时生成后续访问直接读取配合 CDN 的缓存策略首屏速度提升非常明显。4.3 语言字段与主题字段要单独存储早期版本我偷懒保存片段时只存content展示时用文件名后缀猜语言结果分享出去的链接经常高亮失败——比如.tsx文件在部分场景下会被猜成ts泛型箭头函数的T会被解释成 JSX 标签。后来我改成了显式存储language字段创建片段时让用户在下拉框里选择默认给text防止误判。主题字段也是同样道理。既然 Shiki 支持非常多主题还不如把theme写进 snippet 记录里创建者选了“One Dark Pro”分享出去的人看到的也是同一套风格。列表页我甚至加了一个小下拉框允许读者临时切换主题但并不是真的修改底层数据只是前端实时切换渲染。这个小功能上线后被同事夸了几次成本却很低。5. 授权、分享链与软删除容易被忽视的产品细节5.1 NextAuth 登录与 tRPC 权限拦截t3code 的登录体系基于 NextAuth我最初只接入了 GitHub OAuth因为团队本来就用 GitHub 账号一个 provider 足够不想一上来维护一堆第三方登录配置。NextAuth 的接入步骤看起来很多但只要理顺了核心其实是一份配置文件加一个协议路由。使用create-t3-app时它会自动生成基础结构我只需要把AUTH_GITHUB_ID和AUTH_GITHUB_SECRET填进.env就能跑通登录流程。真正的关键点在 tRPC 上下文每次请求进来要在createTRPCContext里把 session 注入进去。t3code 的protectedProcedure长这样const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user?.id) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session } }); });这个 middleware 是整个权限体系的闸门。写任何需要登录才能调用的操作时只要把它从publicProcedure换成protectedProcedure权限控制就完成了。你可能会问 session 为什么不是直接放请求头里非得注入 context因为 tRPC 的 context 就是一次调用的全局环境路由内部所有 procedure 都能拿到这比每个函数内部手动取 session 要干净得多。5.2 分享链接如何生成和过期公开分享是 t3code 最常用的功能。实现上我并没有把 snippet 的id直接暴露出去而是单独生成一个shareId原因有两个id是自增的在 SQLite 中即使是 cuid 也有规律别人可以通过遍历 id 看到你的私有片段。shareId可以让链接更短、更好记也更符合“一次性链接”的直觉。生成方式用的是 nanoidimport { nanoid } from nanoid; const shareId nanoid(10);分享时可以设置过期时间。如果expiresAt为空链接永久有效如果设置了那么访问分享页时要做一次判断const snippet await ctx.db.snippet.findFirst({ where: { shareId, deletedAt: null, OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], }, });访问过期的链接时前端会收到一个NOT_FOUND我会统一渲染成一个“链接已失效”的提示页。这里有一个细节不要把过期的链接返回给用户再让前端去判断时间应该在数据库查询层就把过期记录过滤掉否则“已过期”和“可能还活着”两种状态会在前端造成混乱的判断。5.3 软删除比硬删除更适合内容类工具t3code 刚做的时候删除功能我直接调prisma.snippet.delete()物理删掉数据。后来有一次我误删了一个准备用于技术分享的片段才发现这类内容型工具需要容错空间于是改成了软删除。做法是把deletedAt字段维护好所有列表查询都带上deletedAt: null条件只有明确调用的“彻底删除”才会真正从数据库移除。如果只做到这一步软删除和硬删除就没有本质区别所以我还做了一个很轻的“回收站”视图它只查询deletedAt不为空的片段并提供恢复操作。恢复操作极其简单update: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { return ctx.db.snippet.update({ where: { id: input.id, authorId: ctx.session.user.id }, data: { deletedAt: null }, }); }),软删除的代价是每次查询都要多写一个条件但换来的是误删后可恢复的安全感。对工具型应用来说我觉得非常值。真正要考虑的是如果项目数据量大到需要定时清理那么应该加一个定期任务把软删除超过 30 天的记录物理清除避免垃圾数据积压。这个我目前还没做但已经列在待办里了。6. 构建部署与性能调优上生产之前必须处理的坑6.1 部署到 Vercel 时的环境配置细节t3code 的部署平台选的 Vercel原因是 Next.js 在 Vercel 上的集成体验最好免费额度也够用。但部署并没有想象中那么简单尤其是数据库和环境变量。本地我用 SQLite生产换成了 PostgreSQL。数据库我一开始想用 Vercel Postgres后来因为项目也有本地跑的需求就选了一个独立托管的 Postgres 数据库这样开发测试和生产数据库的 API 是统一的标准协议不会绑定特定平台。关键是要在 Vercel 的项目设置里把生产环境变量补齐DATABASE_URLpostgres://... NEXTAUTH_SECRET生成的随机密钥 NEXTAUTH_URLhttps://你的域名 AUTH_GITHUB_ID... AUTH_GITHUB_SECRET...这里最容易出问题的两个点NEXTAUTH_SECRET不能跟本地开发一样用一个固定字符串必须用随机值。生成方式很简单openssl rand -base64 32。AUTH_GITHUB_ID和AUTH_GITHUB_SECRET在 GitHub OAuth App 里的回调地址要填线上的完整地址比如https://你的域名/api/auth/callback/github。我第一次部署完登录一直失败就是因为回调地址填成了localhost:3000。Vercel 部署时还需要注意 Prisma Client 是否在构建阶段正确生成。常见的做法是在 package.json 的build脚本里加prisma generate next build并在部署平台的 release/start 阶段执行prisma migrate deploy。我第一次直接沿用默认 build 脚本结果线上报 Prisma 客户端找不到模型就是因为没有在服务器环境里跑过prisma generate。6.2 列表页的 N1 查询怎么治t3code 的公开浏览页上线后数据量不大但访问时我发现响应时间有点不对劲。查了下日志原来是最朴素的 N1 问题列表查询先取了 20 条 snippet然后每一条 snippet 又要单独查一次 author 和 tags总计 1 20 20 41 条 SQL。Prisma 有肉眼可见的解决方案——includeconst snippets await prisma.snippet.findMany({ where: { isPublic: true, deletedAt: null, }, include: { author: { select: { name: true } }, tags: { select: { name: true } }, }, orderBy: { createdAt: desc }, take: 20, });这个改动看似简单但效果非常明显1 条带 JOIN 的查询替代了 41 条查询。后端响应时间从 600ms 降到 80ms 左右。需要注意的是include并不是越多越好嵌套过多会让单条查询变得特别复杂如果业务上确实不需要某个关联数据就不要 include。t3code 的列表页只需要展示作者名和标签名所以我在select里只挑了必要字段而不是把整个 author 对象带上。分页方面我采用了游标分页而不是传统的页码分页。原因在于能避免一种常见体验有人往公共列表里不断插入新片段时页码会跳动用户翻页会看到重复内容。游标分页虽然在“跳转到第 3 页”这种需求上不直观但对信息流类场景非常合适。t3code 是典型的信息流浏览所以游标方案是对的。6.3 公开页与私有页的渲染策略差异Next.js 是混合渲染框架但我在 t3code 里没有一股脑全用 SSR。公开浏览页的数据是所有用户共享的访问模式是读多写少渲染时机用revalidate做增量静态再生成很合适export const revalidate 60;这意味着每 60 秒Next.js 会重新验证一次页面缓存旧版本的同时在后台生成新版本用户几乎无感知。实现后公开页的 TTFB 降了不少Vercel 的免费额度也不容易被动态渲染耗尽。私有“我的片段”页则不适合缓存因为它依赖登录用户的 session每个用户看到的都是不同内容。这里我让页面保持动态渲染tRPC 请求也正常走服务器。如果一个页面需要缓存但数据又是用户纬度缓存设计会非常复杂。我的建议是不要为了性能把不该缓存的页面强行塞进缓存里否则后续会出现用户 A 看到用户 B 数据的事故。7. 实际使用一个多月后的复盘7.1 哪些功能大家真的在用t3code 上线一个多月团队里用的最多的功能是分享链接。以前在群里贴代码要么格式乱掉要么消息过长被截断现在直接丢一个短链接点开就是高亮排好版的代码手机上也很好读。其次是标签筛选团队的周会分享材料会按react、debug、review这类标签归档翻起来很方便。公开浏览页的访问量其实不高但它承担了“最近大家都在分享什么”的作用。有的人甚至会通过浏览公开片段来找灵感这有点超出我最初的预期。我觉得这是一个内容工具自然长出来的社区属性不是刻意设计的。7.2 哪些设计被我推翻过复盘中我发现有两处早期设计是过度工程。第一是标签系统。最初我照着博客站的思路设计了完整的标签 CRUD、标签页、标签云结果使用强度完全撑不起这套系统。后来我把标签简化为创建片段时直接输入的字符串配合connectOrCreate自动关联不再单独维护标签管理页面。“好用”的定义不是功能多而是刚好能解决痛点。第二是强制登录。最初我用 GitHub 登录作为唯一入口因为不希望出现匿名垃圾内容。但后来团队同事反馈有时候只是临时想让别人看一段代码登录成本太高于是加了“匿名创建”的选项不登录也能创建片段只是不能在个人列表里管理。这个改动让分享链接的生成率明显提高。匿名片段默认只读不能编辑也没法和用户绑定这是权衡后的取舍。7.3 接下来想扩展的方向t3code 目前还保留着工具型项目的克制后续有几件事我想继续做导入导出支持 Markdown 和 JSON方便用户把代码片段批量迁移出去本地草稿缓存防止编辑页面意外刷新导致内容丢失还有片段版本历史这样分享出去的链接即使后来改了内容旧版本也还能查得到。最后说一点个人体会像 t3code 这样的项目最大的收获不是“我用了 T3 Stack”而是通过完整走一遍全栈开发我真正体会到类型安全如何改变开发习惯。以前写后端接口前端等到联调阶段才会发现字段名拼错现在 tRPC 让这类错误在编译期就暴露了。你如果也想做一个类似的小工具我最大的建议是先缩小需求范围跑通一个最小闭环再用类型系统把前后端好好串起来。这个过程踩的坑比看十篇教程都有用。