我从今年上半年开始一直在做一个小型的代码片段管理项目名字就叫t3code。起因很朴素我平时收集了大量零散的代码片段散落在本地 Markdown、语雀、微信收藏和公司的 Wiki 里每次想找一个正则、一个 Docker 启动命令都要翻半天。更烦人的是博客里分享的代码在复制和格式化过程中经常乱掉缩进、转义符号全都变样。后来我决定自己动手做一个围绕 T3 Stack 代码片段管理 的在线工具把录入、检索、分享这几件事一口气打通。这个项目的技术栈非常明确Next.js、TypeScript、tRPC、Prisma、Tailwind外加 NextAuth 做登录。如果你正好想找一个 T3 全栈项目作为参考或者是想做一个类似的开发者工具类网站这篇复盘应该可以帮你少走很多弯路。整个项目做完给我的最大感受是T3 Stack 并不是一套网红技术栈的组装它其实是在逼迫你把每一层的数据契约都想清楚。tRPC 的端到端类型安全不是营销概念它真的能把很多运行时错误提前到编译期。接下来我会按照需求拆解 → 技术选型 → 数据模型 → 核心功能 → 部署优化 → 踩坑记录的顺序完整复盘 t3code 的搭建过程。1. 为什么叫 t3code从随手记代码的痛点说起先聊聊这个项目的起点。我不确定其他人有没有同样的问题但对我来说开发者日常工作中收藏代码片段这件事的体验极其分裂本地文件方式靠文件夹分类用 IDE 打开最大的问题是换电脑以后没有同步手机上看不了。笔记软件方式能记但是不好排版而且大多数人最后都会把代码渲染成截图截图里的代码不能复制、不能搜索。社交收藏方式刷到一条好代码就点赞/收藏但收藏夹本身就是一个垃圾场既不能打标签也不能预览效果。t3code 的定位也因此非常清晰做一个面向开发者自己的代码片段管理站同时支持把片段生成只读分享链接给其他人。它不是要在线上做编辑器也不是要做社区就是自己用、团队内部用的小工具。项目名字很像项目的全部解法T3 Stack 驱动代码优先。需求拆解下来其实只有五个核心模块登录至少支持 GitHub OAuth。录入代码 语言 标题 简短描述 标签。列表按语言、标签、关键字过滤列表页最好能看到代码开头。详情/编辑支持语法高亮并编辑历史内容。分享未登录访客可以通过公开短链接查看指定片段不需要登录。这五个模块看起来很简单但真正开始搭建的时候每一块都会牵出不少问题。比如列表页要看到代码开头这件事就要考虑代码块在首页会被截断成什么样语法高亮要考虑是放浏览器端做还是服务端做直接影响首屏性能和包体积。这些细节我在后面几个章节会展开聊。2. 技术选型复盘与其说是选了 T3不如说是在避免伪全栈很多人把 T3 Stack 理解成Next.js Prisma tRPC Tailwind的固定配方我在做完 t3code 之后更倾向于把它理解成一组互补的约束。每个组件都解决的是一个具体痛点而不是为了组合而组合。2.1 为什么用 App Router 而不是 Pages Routert3code 一开始其实是用 Pages Router 起步的后来翻新成了 App Router。主要原因有两个代码片段在详情页是只读的非常适合服务端渲染SSR 或静态生成App Router 的 Server Component 天然能尽量少往浏览器发 JS。很多工具类页面比如公开分享页希望搜索引擎收录而不是直接进浏览器渲染。如果你也在纠结 App Router 和 Pages Router我的建议是先想清楚你的页面是以用户交互为主还是内容展示为主。像 t3code 这种 70% 是只读内容的站点App Router 的优势非常明显。如果是个管理后台页面几乎全是表单交互App Router 依然能用但那些客户端状态管理的复杂度要提前考虑清楚。2.2 tRPC 为什么能减少一层心里维护成本以前用 REST 接口的时候写一个前端页面通常要经历这几步去后端看路由定义、看请求参数、看返回结构、复制类型定义、写获取数据的代码、处理错误。前后端类型不同步是常态经常出现明明上线前都测过结果一重构就崩的情况。tRPC 的核心价值在于路由定义本身就是类型定义。在 t3code 里我定义一个snippet.listprocedure前端调用trpc.snippet.list.useQuery()返回的类型和 Zod 校验后的类型是同一个改一个字段名编译直接报错不需要等到接口跑一遍才能发现问题。我准备了一个很小的示例展示 tRPC 路由和前端调用的关系// server/api/routers/snippet.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const snippetRouter createTRPCRouter({ list: protectedProcedure .input( z.object({ language: z.string().optional(), tag: z.string().optional(), keyword: z.string().optional(), }) ) .query(async ({ ctx }) { return ctx.db.snippet.findMany({ where: { userId: ctx.session.user.id, }, orderBy: { createdAt: desc }, take: 50, }); }), });对应前端页面const { data, isLoading } api.snippet.list.useQuery({ language: typescript, tag: hooks, });这里有个细节值得特别注意tRPC 返回的数据类型是通过 TypeScript 的类型推断得到的客户端根本不需要自己再写一遍 interface。这就避免了一个项目里出现后端定义一套类型、前端定义一套同名类型的经典问题。2.3 Prisma 选型不是数据库好看而是迁移和类型体验数据层我选了 Prisma PostgreSQL。选择 PostgreSQL 的原因很简单片段数据天然带有搜索和标签过滤需求PostgreSQL 的全文搜索、JSONB 字段、数组字段都很顺手。Prisma 则主要是看中它的三件事Schema 文件就是数据模型文档团队协作时不用翻数据库。Migration 流程可以串到 CI/CD 里。生成的 TypeScript 类型和 tRPC 配合非常自然。Prisma 的 schema 后面会给出完整代码这里先说一个容易忽略的原则不要把标签直接设计成字符串数组就完事。字符串数组做筛选没问题但一旦你要做点击标签查看关联片段的计数统计字符串数组就远远不如关系表舒服。2.4 Tailwind 的收益和约束Tailwind 在这个项目里不是主角但它解决了两个实际问题代码高亮块之外的各种 UI 组件不需要单独写 CSS 文件组件代码里直接看到样式。深浅色模式切换用dark:变体做起来非常快。不过我也要提醒一下Tailwind 不是魔法它最适合的仍然是快速搭出工具类页面。如果你要做重设计感的官网古典 CSS 或 CSS-in-JS 依然有存在价值。t3code 里我用 Tailwind 写了很多列表、表单和面板整体体验是舒服的。3. 数据模型设计从 Prisma Schema 到列表页查询数据模型直接决定了一个工具类项目的骨架。t3code 的模型最初比较简单只有一个Snippet表后来加了Tag和SnippetTag关系表。完整结构如下generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? snippets Snippet[] tags Tag[] accounts Account[] sessions Session[] } model Snippet { id String id default(cuid()) slug String unique title String description String default() language String default(text) code String db.Text isPublic Boolean default(true) views Int default(0) createdAt DateTime default(now()) updatedAt DateTime updatedAt author User relation(fields: [authorId], references: [id]) authorId String index tags SnippetTag[] } model Tag { id String id default(cuid()) name String userId String index user User relation(fields: [userId], references: [id]) snippets SnippetTag[] unique([userId, name]) } model SnippetTag { snippetId String tagId String snippet Snippet relation(fields: [snippetId], references: [id]) tag Tag relation(fields: [tagId], references: [id]) id([snippetId, tagId]) }3.1 slug 字段公开分享页的核心slug字段一开始我觉得只是一个短 ID后来发现它直接决定了公开分享链接的形制。我生成 slug 的方式是通过nanoidimport { nanoid } from nanoid; const slug nanoid(10);用nanoid(10)的好处是长度合适URL 短不需要查重碰撞概率在低量级数据下可以忽略。用户创建一个片段后无论是否公开都会生成一个 stable 的 slug。这样分享链接始终是https://你的域名/s/{slug}而不暴露数据库自增 ID。3.2 标签为什么不用字符串数组开发初期我想省事直接在Snippet里加一个tags String[]。后来加标签统计功能时发现要统计某个标签下面有多少公开片段就得把所有片段拉出来在应用层过滤。于是改成了TagSnippetTag的多对多关系。当你有一个用户维度的数据模型时Tag表一定要带上userId否则所有人的标签都混在一起就成了一种全局标签。工具网站可以这么做但个人/团队工具这么设计长期会有数据污染问题。3.3 列表查询的一处常见性能陷阱列表页要显示片段代码开头。如果直接const snippets await db.snippet.findMany({ ... });前端要渲染snippet.code时整个片段全文都会被传过去。如果用户存了一个 5000 行的脚本列表页一次拉 50 个Payload 就很可观了。t3code 的做法是在查询里创建一个预览片段字段用数据库函数截断而不是前端截断const snippets await db.snippet.findMany({ where: { authorId: session.user.id }, select: { id: true, title: true, language: true, slug: true, createdAt: true, codePreview: true, }, });codePreview是视图里的一个生成列或者我通过 Prismaraw查询拼接。最差的情况也可以用 Prisma 的take 内存截断但只要片段数量上去这个字段就不能省。4. 核心功能实现录入、检索、语法高亮和分享页模型定好了功能实现才是真正花时间的地方。这一章我会把 t3code 里四个核心功能的技术方案完整拆开。4.1 创建片段的完整链路创建片段的体验我希望尽量快标题默认用代码的第一行注释语言自动根据代码内容猜用户只需要粘贴代码点保存。为了做到这一点创建表单的提交函数是这样的const createSnippet api.snippet.create.useMutation(); const handleSubmit (values: SnippetFormValues) { createSnippet.mutate({ title: values.title || guessTitle(values.code), code: values.code, language: values.language, tags: values.tags, isPublic: values.isPublic, }); };这里guessTitle只是一个辅助函数function guessTitle(code: string): string { const firstLine code.split(\n)[0]?.trim() ?? ; if (firstLine.startsWith(//) || firstLine.startsWith(#)) { return firstLine.replace(/^[#\/\s]/, ).slice(0, 60); } return 未命名片段 new Date().toLocaleDateString(); }这里的重点不是猜标题多聪明而是表单交互要减少用户的摩擦。代码片段工具核心竞争力不应该是多一个漂亮的编辑器而是粘贴后只要能选中语言保存动作越少越好。服务端路由也很直白create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), code: z.string().min(1), language: z.string().default(text), tags: z.array(z.string()).default([]), isPublic: z.boolean().default(true), }) ) .mutation(async ({ ctx, input }) { const authorId ctx.session.user.id; const snippet await ctx.db.snippet.create({ data: { slug: nanoid(10), title: input.title, code: input.code, language: input.language, isPublic: input.isPublic, authorId, }, }); if (input.tags.length 0) { await Promise.all( input.tags.map((name) ctx.db.tag.upsert({ where: { userId_name: { userId: authorId, name } }, create: { userId: authorId, name }, update: {}, }) ) ); await ctx.db.snippetTag.createMany({ data: input.tags.map((name) ({ snippetId: snippet.id, tagId: await ctx.db.tag .findUnique({ where: { userId_name: { userId: authorId, name } }, }) .then((t) t!.id), })), }); } return snippet; });createMany里嵌套 await 的方式其实效率不好更好的做法是先查出 tagId 集合再一次性创建关联。实际项目里我用的是后者。这里保留前一种写法是想提醒大家链式 await 在循环里看起来能跑数据量上来后早晚会后悔。4.2 检索功能先做够用再考虑全文搜索t3code 的检索并不复杂按语言、标签、关键词过滤即可。关键词匹配我用了最简单的方案先查标题和描述keyword: z.string().optional(), // 在 query 里组装 where where: { userId: ctx.session.user.id, ...(input.keyword ? { OR: [ { title: { contains: input.keyword, mode: insensitive } }, { description: { contains: input.keyword, mode: insensitive } }, ], } : {}), ...(input.language ? { language: input.language } : {}), }对于个人工具级别几百到几千个片段这个查询完全够。如果未来片段数量很大建议用 PostgreSQL 的tsvector做全文索引。其实加一个searchVector字段和 GIN 索引也很简单ALTER TABLE Snippet ADD COLUMN searchVector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector(english, coalesce(title, )), A) || setweight(to_tsvector(english, coalesce(description, )), B) || setweight(to_tsvector(english, coalesce(code, )), C) ) STORED;然后通过 Prisma$queryRaw查询。不过我不建议一上来就上全文搜索因为大多数场景下把标题和描述过滤做到位已经解决了 80% 的问题。4.3 语法高亮服务端渲染而不是浏览器端再高亮这是 t3code 整个项目里最值得讲的一个技术决策。一开始我直接用react-syntax-highlighter因为配置简单。但后来发现列表页在手机端打开时JS 加载很大因为高亮组件打包时会把一堆语言定义塞进主 bundle。于是我改成了用 Shiki 高亮文本在 Server Component 里提前生成 HTML 字符串浏览器端完全不加载高亮相关 JS。// app/s/[slug]/page.tsx import { codeToHtml } from shiki; export default async function SnippetSharePage({ params, }: { params: { slug: string }; }) { const snippet await findPublicSnippetBySlug(params.slug); const highlightedCode await codeToHtml(snippet.code, { lang: snippet.language, theme: github-dark, }); return ( div h1{snippet.title}/h1 div dangerouslySetInnerHTML{{ __html: highlightedCode }} / /div ); }dangerouslySetInnerHTML这个词看着吓人但 Shiki 会把代码做安全转义同时把高亮标记用span包裹。只要代码内容本身来自数据库且是在服务端渲染XSS 风险是可控的。你在使用任何 code-to-html 库时都要确认它内部做了 HTML 转义。这里有个前提渲染分享页时不能直接把用户代码塞进 dangerouslySetInnerHTML 不做处理一定要用 Shiki 这类库它内部输出的是转义后的 HTML。这个方案带来的性能提升非常明显分享页的 HTML 静态可缓存、SEO 可爬、浏览器 JS 几乎为零。缺点也很明显Shiki 的语言包在服务端解析时有一定 CPU 开销所以我把公开片段页面做了缓存或者设置export const dynamic force-static给公开链接开启静态生成。4.4 公开分享页的权限控制分享页的权限模型我做了三种状态访问规则isPublic true任何人可访问实时计数isPublic false且作者登录作者可访问isPublic false且访客未登录404不提示存在在 tRPC 里实现未登录访问公开数据要单独定义一个 publicProcedure 而不是 protectedProcedure。这个细节很多人会忽略如果你把分享页的数据加载也放在protectedProcedure里那么未登录访客就永远看不到公开片段。公开分享页获取数据的函数长这样export async function findPublicSnippetBySlug(slug: string) { return db.snippet.findFirst({ where: { slug, isPublic: true, }, include: { tags: { include: { tag: true } } }, }); }注意我不能直接查where: { slug, OR: [{ isPublic: true }, { authorId: ... }] }因为数据库查询时根本不知道当前请求来自哪个用户。我的做法是在 Server Component 里先通过getServerSession()拿到用户再决定用哪种查询。这正好体现了 Next.js 混合渲染的一个优势服务端可以先做身份判断再决定数据源。5. 依赖注入、路由组织与真正可维护的 API 结构做完核心功能后我开始回头审视代码组织。T3 Stack 的目录结构看似固定但项目一大路由文件会变得很长。t3code 最后用了一种很顺手的组织方式。5.1 把 tRPC Router 拆细我不赞成把几十个 procedure 塞进一个 router。t3code 的最终目录长这样server/ api/ root.ts trpc.ts routers/ snippet.ts tag.ts user.ts search.tsroot.ts里把子路由合并export const appRouter createTRPCRouter({ snippet: snippetRouter, tag: tagRouter, user: userRouter, search: searchRouter, });这样前端调用依然是api.snippet.create、api.snippet.list、api.tag.list语义清晰文件也不用无脑膨胀。5.2 鉴权中间件的写法T3 Stack 默认用 NextAuth所以我直接在 tRPC 上下文里注入 session// server/api/trpc.ts export const createTRPCContext (opts: { headers: Headers }) { return { session: getServerSession(), db, headers: opts.headers, }; }; const t initTRPC.contexttypeof createTRPCContext().create(); export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED, message: 请先登录, }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });这里有个容易被忽略的点tRPC 的next({ ctx })返回的 ctx 会覆盖原 ctx所以后续 procedure 里的ctx.session.user是类型安全且非空的。如果直接在 router 里写ctx.session?.user.id你还要各种判断很容易遗漏。5.3 服务端组件调用 tRPC 要谨慎我在项目里发现了一个很关键的分界tRPC 更适合客户端组件而 Server Component 直接调用 Prisma 更香。原因很简单Server Component 里没有必要走 HTTP/RPC 层直接调用 Prisma 查询就是一次函数调用链路更短。tRPC 路由更多是给交互型页面用的表单提交、数据变化后的重新获取。如果你在 Server Component 里调用 tRPC又会引入createCaller还需要处理请求上下文收益并不明显。所以 t3code 实际是两套并行的架构公开分享页、详情页、首页列表这种偏向展示的页面直接靠 Server Component Prisma 查询编辑器、设置、标签管理这种交互密集的页面靠 tRPC。这样分工以后心智负担小很多。6. 部署与性能实测Vercel、Prisma 连接池和高亮包的体积博弈t3code 的部署链路上没有用太花哨的方案代码放在 GitHub 仓库推送到 Vercel 自动构建。数据库放在 Neon 的 PostgreSQL 上因为免费层对冷启动比较友好。缓存我用了 Vercel 自带的 CDN 缓存。6.1 部署时的环境变量T3 Stack 的env.mjs严格校验环境变量我列一下最终需要的核心变量变量用途DATABASE_URLPrisma 连接字符串NEXTAUTH_SECRETNextAuth 加密密钥NEXTAUTH_URL站点的正式 URLGITHUB_ID/GITHUB_SECRETGitHub OAuth 应用凭证记住NEXTAUTH_SECRET不能和代码一起提交到 Git 仓库。本地开发可以用openssl rand -base64 32生成一个然后放到.env。Vercel 的 Environment Variables 面板里也要单独配一份。6.2 冷启动和 Prisma 连接池Serverless 环境最大的坑就是数据库连接被频繁创建和销毁。PostgreSQL 的连接是在DATABASE_URL上配置的如果你想在 Vercel 上使用 Neon连接串通常长这样postgresql://user:passwordhost/db?sslmoderequirepooledtrue加上pooledtrue才走连接池。我第一次部署时忘了带这个参数北京凌晨的冷启动直接把我数据库连接数打满整个站 504 了快十分钟。这个教训挺深刻的连接串上的参数不能被忽略。6.3 高亮包体积的博弈上面我提到了用 Shiki 做服务端高亮它带来的一个隐性问题就是构建时的语言包体积。如果我直接写import { codeToHtml } from shiki;它默认会一次性加载几十上百种语言定义构建产物会变大冷启动也会变慢。解决方案是按需加载import { createHighlighterCore } from shiki/core; import { createOnigurumaEngine } from shiki/engine/oniguruma; import js from shiki/langs/javascript.mjs; import ts from shiki/langs/typescript.mjs; import css from shiki/langs/css.mjs; import html from shiki/langs/html.mjs; import bash from shiki/langs/bash.mjs; const highlighter await createHighlighterCore({ langs: [js, ts, css, html, bash], engines: { javascript: createOnigurumaEngine(import(shiki/wasm)) }, });这样构建时只打包我常用的 5 种语言。用户若传到一种没注册的语言我再给一个text兜底。我做这个优化后构建体积大概下降了 40%。如果你的工具只面向自己或团队按需加载是最划算的优化。6.4 实测结果我用自己的个人片段库测试过大概有 600 条片段分布在 10 种语言里。实际体验是列表页 TTFB首字节约 200ms本地网络环境下的 Vercel 边缘节点。分享页因为做了静态缓存TTFB 可以到 60ms 以内。创建/编辑操作的响应时间约 150ms瓶颈主要在 Prisma 写入和 GitHub OAuth 回调。这个数据对于一个个人工具来说是够用的不需要上额外的 CDN/反代也不需要在代码里做太激进的内存缓存。7. 印象最深的六个坑每个都是代码层面的细节教训最后记录几个这次项目里真正让我花过时间的坑。这些东西翻官方文档不一定能直接看到但一旦遇到排查成本都很高。7.1 NextAuth 的NEXTAUTH_URL会吃掉一半排错时间本地开发时NextAuth 会尝试读取NEXTAUTH_URL。如果你没有设置它有时却能正常工作因为 Next.js 会推断 localhost但部署到 Vercel 后如果NEXTAUTH_URL没有正确设置为生产域名GitHub OAuth 回调就会神秘失败。第一反应是 GitHub 应用回调地址写错了实际是环境变量没传全。建议项目初始化时就把所有环境变量写进部署平台不要等到出现奇怪问题再排查。7.2 tRPC 错误在 Server Component 里不会自动中断如果你在 Server Component 里用了createCaller调一个 tRPC procedure它抛出的TRPCError不会自动变成 4xx 页面而是会直接冒泡成 React 渲染错误。最后我统一约定面向 Server Component 的查询不用 tRPC直接用服务端函数返回 null 或自定义错误对象。这样页面流程更可控。7.3 Prisma 嵌套写入太多会导致 N1创建片段时如果每传一个标签都去findUnique一次再用createMany标签数量一多就会变成 N 次查询。优化方式是一次性取回用户现有标签 IDconst existingTags await ctx.db.tag.findMany({ where: { userId: authorId, name: { in: input.tags }, }, select: { id: true, name: true }, });然后用existingTags做映射只对真正不存在的标签做 upsert。数据量不大时感觉不出来但代码风格上会更健康。7.4 公开页面的 NOINDEX 问题访客公开分享页我默认设了两个 meta 标签export const metadata { title: t3code - 代码片段分享, robots: { index: true, follow: true, }, };不过后来我仔细想了一下对于包含敏感内部代码哪怕只是路径和服务器名的页面其实应该默认noindex或者提供禁止搜索引擎收录的选项。我的处理是个人默认index团队分享默认noindex。这只是个小细节但很影响内容安全。7.5 代码内容里的 CRLF 会破坏高亮行数我在 Windows 上整理的不少旧片段是 CRLF 换行。Shiki 基于行号渲染时CRLF 的\r在 HTML 中可能会导致行内多余空白。处理方式很简单入库前统一做一次 normalizeconst normalizedCode code.replace(/\r\n/g, \n);这个操作看似多余但当你共享给别人别人复制过去的代码里全是\r残留时会很影响体验。7.6 session 更新不及时导致权限判断异常NextAuth 默认的 session 有效期比较长用户改了角色或者账号状态后session 可能不会立刻更新。t3code 里我每次在protectedProcedure中只判断是否登录没有判断角色是否变了。但如果你要做团队版权限记得在 NextAuth 的sessioncallback 里刷新用户信息而不是只读取token.sub。下一步我想加的功能现在的 t3code 已经能满足我个人的日常使用但做完之后我也想到几个接下来很自然的功能扩展方向支持按代码片段生成源码归档下载方便直接放到项目里。增加复制代码按钮并统一处理 HTML 转义让复制后的代码完全保持缩进。给公开分享页增加过期时间适合临时把一个片段发给同事却不希望链接永久有效。用全文搜索替换现在的contains查询毕竟代码库里搜代码最后一定要落到语义搜索。我在实际迭代中最深的体会是工具类项目不要去追求大而全而是要把保存代码 → 找到代码 → 分享代码这个闭环做到足够顺滑。t3code 这个名字以后应该也会继续演进去但核心思路不会变代码片段不是一个需要复杂管理的资产它是一个需要被快速拿出来用的生活用品。