资讯详情 用T3 Stack从零构建代码片段管理工具:全栈开发实战记录
📅 2026/10/27 22:54:56
如果你跟我一样日常写代码时总会攒下一堆“以后说不定用得上”的代码片段散落在微信收藏、飞书笔记、本地 txt 甚至聊天记录里那你大概能理解我为什么会做t3code。t3code是我近期折腾的一个全栈开源项目用T3 StackNext.js、TypeScript、tRPC、Prisma、Tailwind、NextAuth写的一个代码片段管理工具核心功能是让我能把零散的代码片段统一收集、打标签、分类、搜索并且支持公开分享给团队或社区。这篇文章是完整的项目记录适合想学习T3 Stack如何落地一个小工具的同学也适合正在纠结“技术选型怎么定、前后端怎么串起来”的人。先交代一下背景我平时会在群里帮人解答问题经常需要贴一段示例代码也会在写业务时反复用同几个工具函数。每次都要去翻历史记录或者打开一个又臭又长的本地文档搜索。当时就想做一个自己的 snippet 仓库用网页访问分类清楚支持语法高亮还能一键复制。于是就有了t3code这个名字——T3 Stack code直白又贴切。1. 从“笔记软件里搜不到代码片段”说起t3code的立项背景1.1 我的核心痛点代码碎片化存放做过几年工程的人都知道代码片段和正式项目代码不一样。片段是“半成品”是需要在某个瞬间快速调用的小零件。我用过印象笔记、语雀、GitHub Gist甚至在自己博客里开了一个分类放代码。问题是本地笔记软件不支持语法高亮贴上去一团黑白Gist 可以高亮但搜索能力弱不支持多维度的分类语言、场景、标签团队内部的文档平台太重写个 snippet 还要审批流程常用浏览器书签、微信“文件传输助手”更乱时间一长根本找不到。所以我想与其继续忍受碎片化不如花一个周末做一个专用工具。这就是t3code的起点。1.2 为什么叫t3code以及功能范围定夺T3 Stack 是社区里比较热门的一个全栈组合Next.js 负责页面和服务端渲染tRPC 负责端到端类型安全的 API 调用Prisma 负责数据库操作Tailwind 负责样式NextAuth 负责认证。名字里带 T3又是写代码的平台所以叫 t3code。我在立项时给自己定了 MVP 范围避免做到半途放弃功能优先级说明登录/注册必做使用 NextAuth先支持 Discord 登录新增/编辑代码片段必做填标题、语言、内容、标签可选公开列表与搜索必做按标题/内容/语言/标签筛选公开分享页可后续迭代未登录用户也能看公开片段复制代码按钮必做点击一键复制代码高亮必做服务端渲染高亮前端零闪烁我当时给自己的时间是四天一天搭环境和数据模型一天写 API 和前端页面一天处理认证和部署留一天填坑。实际上花了六天其中部署坑占了一半时间这部分后面会详细讲。2. create-t3-app的一站式体验脚手架搭建与目录结构拆解2.1 初始化命令与交互选项T3 Stack 官方提供了create-t3-app脚手架这是一个非常省心的起点。我用的命令是pnpm dlx create-t3-applatest t3code注意这里用的是pnpm dlx不是npm create。因为pnpm dlx不需要额外安装全局包跑完即走比较干净。如果你机器上没装 pnpm也可以用npx但建议还是先把 pnpm 装上核心逻辑是包依赖用符号链接管理装包速度快很多。脚手架会问你要启用哪些模块我当时的勾选项是TypeScript必须T3 的灵魂tRPC必须类型安全全靠它Prisma必须数据层Tailwind CSS必须写样式快NextAuth必须认证ESLint Prettier建议选省得后面被代码风格烦。还有一个“App Router”选项要注意。T3 早期版本默认用 Pages Router现在推荐 App Router。我选了 App Router因为它支持 Server Component后续做 SSR 页面更顺。2.2 生成后的目录结构解读生成完的项目目录大致是这样t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── auth/[...nextauth]/ │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── snippets/ │ ├── server/ │ │ ├── api/ │ │ │ └── root.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── server/api/routers/ tRPC路由 │ ├── styles/globals.css │ ├── trpc/ │ │ ├── client.ts │ │ ├── react.tsx │ │ └── server.ts │ └── env.js └── package.json第一次看到trpc/server.ts和trpc/client.ts的时候我有点懵tRPC 不是不需要写 API 路由吗为什么还要分 server 和 client后来才明白这里所谓的“server”是指定义路由时用的初始上下文以及initTRPC实例不是 HTTP 服务端“client”则是指浏览器端发起查询的封装。两者各自持有类型引用就能实现自动推导。2.3 依赖版本锁定与 Node 版本一个新项目最容易翻车的点是 Node 版本。T3 Stack 要求 Node 18 及以上新版可能要求 20如果还在用 16安装时会报错。这个坑我踩过一次服务器上自带 Node 16跑pnpm build直接挂掉最后用nvm install 18解决。建议写.nvmrc文件锁版本18.18.0还有一个容易被忽略的next-auth的 beta 版本和trpc/next的版本存在兼容问题如果直接从官网拷代码可能遇到类型错误。最好让create-t3-app生成的最初版本作为基线不要手贱全部升级到最新。我一开始把 Next.js 升到了 14.2发现server.ts里的createTRPCContext类型会报错花了大半天才定位到是版本问题。3. Prisma数据建模与tRPC查询给代码片段一个“家”3.1 数据模型设计用户、片段、标签代码片段管理的核心模型无非三个用户、片段、标签。不过要在关系上模拟“多对多”的标签关系最直接的是中间表。我的schema.prisma长这样model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] snippets Snippet[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Snippet { id String id default(cuid()) title String content String language String default(plaintext) isPublic Boolean default(true) authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) tags SnippetTag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique snippets SnippetTag[] } model SnippetTag { snippetId String tagId String snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag relation(fields: [tagId], references: [id], onDelete: Cascade) id([snippetId, tagId]) }我特意把onDelete: Cascade加上防止删除片段时残留一堆无用的中间表记录。如果你喜欢更简洁的数据结构也可以用 Postgres 的数组字段存标签但那样筛选标签会麻烦一些而且不利于以后做标签统计。多对多表虽然多了一层但查询时用some条件逻辑也直观。3.2 切换数据库 Provider 的坑create-t3-app 默认用的是 SQLite这对本地开发很友好不用装数据库。但部署到 Vercel 的时候SQLite 是无状态的数据没法持久化所以要切换到 PostgreSQL。我在本地把provider改成postgresql后发现连接串必须加上sslmoderequire才能连上 Vercel Postgres。最开始的错误是Error: PrismaClientInitializationError: P1001: Cant reach database server at ...排查了半天确认是 Vercel Postgres 要求使用DATABASE_URL环境变量并且要用他们提供的?sslmoderequire后缀。所以如果直接用现成模板记得在.env里配置完整的连接串DATABASE_URLpostgresql://postgres:passwordyour-host.com:5432/t3code?sslmoderequire迁移命令要分环境。本地开发用prisma migrate dev生产环境部署前执行prisma migrate deploy。迁移文件我习惯提交进 Git这样所有人拉代码后只要执行migrate deploy就行不用手动跑 dev 模式验证。3.3 种子数据让开发环境不至于空空如也没有数据的首页很凄凉所以我写了一个prisma/seed.ts插入几个示例片段和标签。脚本很小但能帮助快速验证页面效果import { PrismaClient } from prisma/client; const prisma new PrismaClient(); async function main() { const user await prisma.user.create({ data: { name: demo, email: demot3code.dev, }, }); await prisma.snippet.create({ data: { title: Fetch with AbortController, content: const controller new AbortController();, language: typescript, isPublic: true, authorId: user.id, tags: { create: [ { tag: { connectOrCreate: { where: { name: fetch }, create: { name: fetch } } } }, ], }, }, }); } main().then(() console.log(seeded));执行种子脚本的命令是prisma db seed但需要先在package.json里配置prisma: { seed: tsx prisma/seed.ts }这个tsx依赖也需要单独装否则会报找不到模块。我也是看文档才发现db seed是读取prisma字段的配置来执行的。3.4 tRPC Router查询片段列表数据模型有了接下来就是通过 tRPC 提供访问接口。tRPC 的好处是前端调用api.snippet.list时所有类型都是自动推导的不用手写类型声明和请求工具。我的 snippet router 代码如下import { z } from zod; import { createTRPCRouter, publicProcedure, protectedProcedure } from ../trpc; export const snippetRouter createTRPCRouter({ list: publicProcedure .input(z.object({ tag: z.string().optional(), search: z.string().optional() })) .query(async ({ ctx, input }) { return ctx.db.snippet.findMany({ where: { isPublic: true, ...(input.tag ? { tags: { some: { tag: { name: input.tag } } } } : {}), ...(input.search ? { OR: [ { title: { contains: input.search } }, { content: { contains: input.search } }, ], } : {}), }, include: { author: { select: { name: true, image: true } }, tags: { include: { tag: true } }, }, orderBy: { createdAt: desc }, }); }), getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { const snippet await ctx.db.snippet.findUnique({ where: { id: input.id }, include: { author: true, tags: { include: { tag: true } } }, }); return snippet; }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string().default(plaintext), tags: z.array(z.string()).default([]), isPublic: z.boolean().default(true), })) .mutation(async ({ ctx, input }) { return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, isPublic: input.isPublic, authorId: ctx.session.user.id, tags: { create: input.tags.map((name) ({ tag: { connectOrCreate: { where: { name }, create: { name } }, }, })), }, }, }); }), });这里有个小技巧search字段用contains时会走 SQL 的 LIKE所以我给标题和内容都加了索引避免数据量大以后查询变慢。sonn 不要在生产库里用contains不带索引一定会后悔。4. NextAuth认证接入与API路由安全让t3code只服务“自己人”4.1 为什么要接入认证以及Provider选择代码片段虽然大多是公开的但新增和修改必须要是登录用户。初期我直接用 GitHub 登录最顺但队友都用 Discord所以先接 Discord Provider。你按自己的需求选就行OAuth 逻辑都是一样的。create-t3-app 已经帮你接好了 NextAuth 的基础配置但要在 tRPC 的上下文里拿到 session还需要手动把 context 串进去。看一下src/server/api/trpc.ts或者server/trpc.tsimport { initTRPC, TRPCError } from trpc/server; import superjson from superjson; import { type CreateNextContextOptions } from trpc/server/adapters/next; import { getServerSession } from next-auth; import { authOptions } from ~/server/auth; import { db } from ~/server/db; export const createTRPCContext async (opts: CreateNextContextOptions) { const session await getServerSession(opts.req, opts.res, authOptions); return { db, session }; }; const t initTRPC.contexttypeof createTRPCContext().create({ transformer: superjson, }); export const createTRPCRouter t.router; export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: ctx.session }, }); });这里的核心思路是每个请求进来先从 NextAuth 拿 session放到 tRPC 的 context 里。然后定义两种 procedurepublicProcedure用于公开查询protectedProcedure则会先检查 session没有就抛UNAUTHORIZED。4.2 扩展 Session 类型拿到用户 ID默认的Session.user类型只有name、email、image我想拿用户的id作为 snippet 外键所以需要扩展类型。创建src/types/next-auth.d.tsimport { DefaultSession } from next-auth; declare module next-auth { interface Session { user: { id: string; } DefaultSession[user]; } }并且要在 NextAuth 的callbacks.session里把 database 用户 id 塞进 sessioncallbacks: { session({ session, user }) { if (session.user) { session.user.id user.id; } return session; }, },注意这里用的是user.id而不是session.user.id因为session是由 token 解码出来的不一定有用户 id。如果你用 JWT 模式可能要找 token 里 sub 字段。我用的是 database sessions所以从 user 对象取。4.3 tRPC 端 TypeScript 类型如何暴露到前端tRPC 的类型安全依赖AppRouter类型被客户端引用。典型的写法是在src/trpc/react.tsx里引入import { createTRPCReact } from trpc/react-query; import { type AppRouter } from ~/server/api/root; export const api createTRPCReactAppRouter();然后在根组件包一个TRPCProvider。这部分 create-t3-app 已经写好了不展开。实际调用时我是这样写的const { data: snippets, isLoading } api.snippet.list.useQuery({ tag: activeTag, search: keyword, });当后端路由签名变化时前端编译期直接报错这比调 HTTP API 强太多了。tRPC 的体验用一句话就是写后端时像写普通函数写前端时像在调用本机函数。5. 高亮、编辑与响应式UI把代码片段展示做成享受5.1 代码高亮方案选型Shiki 胜出代码片段网站最核心的体验就是高亮。我对比了几个方案方案优点缺点我的结论Prism.js轻量、生态广需要前端运行时染色会闪一下不够顺滑highlight.js使用简单样式老气定制性一般放弃Shiki基于 TextMate 语法主题漂亮服务端渲染体积偏大但可以按需加载最终选择Monaco editor功能全支持编辑和补全太重拖慢首屏仅编辑页用我选了 Shiki并在 Server Component 里用codeToHtml生成高亮后的 HTML 字符串直接注入到页面。这样首次渲染时就是带颜色的代码不会有任何闪烁感import { codeToHtml } from shiki; export async function SnippetContent({ code, lang }: { code: string; lang: string }) { const html await codeToHtml(code, { lang, theme: github-dark, }); return div dangerouslySetInnerHTML{{ __html: html }} /; }这里要注意lang必须是 Shiki 支持的 language id比如javascript、typescript、python。如果传入不支持的 ID它会报错。我的处理是在保存片段时对语言做一次白名单校验兜底给plaintext。5.2 输入端的编辑器选择新增/编辑页需要有一个能写代码的输入框。直接用textarea会缺失缩进和行号体验但引入 Monaco 又太重。最终折中方案用monaco-editor/react并且只在编辑页动态 import确保主列表页不加载这个重量级组件import dynamic from next/dynamic; const MonacoEditor dynamic(() import(monaco-editor/react), { ssr: false });ssr: false是必须的因为 Monaco 依赖浏览器 DOM服务端渲染会直接白屏。如果你只需要更轻量的方案也可以试试 CodeMirror 6我后来发现 CodeMirror 对代码片段场景更合适只是当时已经用了 Monaco懒得改。这里提供一个教训不要在 SSR 里引客户端的富编辑器一定要 dynamic import。5.3 复制按钮与其他交互细节代码块右上角放一个复制按钮看似简单但需要处理 copy 兼容和视觉反馈。我直接用 navigator.clipboardfunction handleCopy() { navigator.clipboard.writeText(code); setCopied(true); setTimeout(() setCopied(false), 2000); }如果是在 HTTP 环境下非 localhostnavigator.clipboard可能不可用需要 fallback 到document.execCommand(copy)。这个坑我也遇到了最后封装成一个copyText函数优先用 async clipboard失败时走 textarea 复制。页面整体用 Tailwind 做了个简单的卡片式布局左边侧栏显示标签列表右侧是代码片段流。移动端里侧栏收起来变成一个顶部下拉筛选。这里没有特别复杂的设计但要注意表格和代码块的横滚给pre设置overflow-x-auto避免在小屏幕上把页面撑破。6. Vercel部署复盘那些构建失败和登录异常的坑6.1 环境变量没配全导致的构建失败部署到 Vercel 的第一件坑事就是构建失败。日志刷了很长一串核心错误是Error: Environment variable DISCORD_CLIENT_ID is missingcreate-t3-app 默认会在构建时做环境变量校验env.js所有缺少的变量都会直接 fail。这个设计很严格但对新手不够友好。解决方式是在 Vercel 项目配置里把所有环境变量都填上包括DATABASE_URLNEXT_PUBLIC_VERCEL_URL这个可以留空Vercel 自动注入DISCORD_CLIENT_IDDISCORD_CLIENT_SECRETAUTH_SECRET或NEXTAUTH_SECRETAUTH_URL或NEXTAUTH_URL注意不同版本的 NextAuth 对AUTH_URL和NEXTAUTH_URL的偏好不同。我在 4.x 版本下用AUTH_URL不生效换成NEXTAUTH_URL才恢复了回调跳转。如果发现登录后总是跳回 localhost先检查这个变量。6.2 Prisma generate 在构建时的执行在 Vercel 上如果使用postinstall脚本prisma generate会在依赖安装后自动执行。但有时候因为 pnpm 的 hoisting 问题Prisma Client 生成不到node_modules/.prisma/client导致构建时报Error: prisma/client did not initialize yet. Please run prisma generate我排查到根因是postinstall里只写了prisma generate但我的生产依赖里没有prisma/client直接依赖因为脚手架把它放 devDependencies 了。v 4.x 必须要将prisma/client放到 dependencies 里。回看 create-t3-app 生成的 package.json发现它本来就是 dependencies是我手动整理包时误改。改回来删掉node_modules重新pnpm install就好了。6.3 登录回调地址错误现象的完整排查这个坑最隐蔽。我把站点部署到 Vercel 后点击 Discord 登录跳转到授权页登录成功后却一直转圈最后 URL 变成了https://t3code.vercel.app/api/auth/callback/discord?errorOAuthCallback第一反应是 Discord 开发者平台没配置回调 URL。去后台把回调地址改成https://t3code.vercel.app/api/auth/callback/discord问题依旧。又怀疑是 Session 存储问题数据库表里也有 session 记录。最后反复看了 NextAuth 文档发现环境变量里NEXTAUTH_URL是必填的。我在 Vercel 里只配置了AUTH_URL旧版本 NextAuth 不认。把两个变量都设为生产域名后问题彻底消失。提醒一下NEXTAUTH_URL不能带路径也不要加斜杠。类似https://t3code.vercel.app/这种结尾斜杠也可能导致 callback 拼接出两个斜杠。我最后用https://t3code.vercel.app。6.4 数据库连接池与冷启动优化Vercel 的 serverless 环境会让每个函数实例都创建 Prisma Client。如果不复用连接很容易把数据库的并发连接数打满。我的优化方案是在server/db.ts里做一个全局单例import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const prisma globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error] : [error], }); if (process.env.NODE_ENV ! production) globalForPrisma.prisma prisma;这样在本地热更新时不会每次重新生成 client。对于 Vercel Postgres建议开启连接池模式连接串里的host改成pooler.vercel.com之类的网关地址并设置?pgbouncertrue具体可以看 Vercel 控制台提供的连接串。我没有深挖只是按官方建议改成了DATABASE_URLpostgresql://postgres:passwordyour-host-pooler.example.com:5432/t3code?sslmoderequirepgbouncertrue这个改动把冷启动时的建连时间从 300ms 降到 100ms 左右。7. t3code的后续玩法与我的个人体会部署上线后我给这个项目加了一些小功能RSS 订阅公开片段、通过raw路径返回纯文本内容、给公开页面加简单的 Open Graph 分享卡片。其实整个项目最大的收获不是功能本身而是真正理解了 T3 Stack 各个模块是怎么衔接在一起的NextAuth 负责 session但要通过 tRPC context 传给每个查询Prisma 负责数据但别忘了 migrate、seed、connectOrCreate 这些细节tRPC 让前端调用像本地函数但你需要设计好 procedure 的权限边界。代码本身并不复杂难的是把“正确流程”串起来。如果你也想做一个类似的项目我的建议是先写数据模型再写 tRPC 路由再做页面最后再做装饰性的高亮和分享功能。这样每一步都能单独看到结果。后续扩展方向等团队用起来后我想加一个 AI 自动打标签把 snippets 内容发给大模型让其返回推荐标签再做一个浏览器扩展选中一段代码直接右键“存入t3code”。不过这些属于可预见的“玩意”不急先把基础稳固。最后分享一个小技巧本地跑 t3code 时如果觉得 mock 数据太少可以写一个scripts/generate-random-snippets.ts用 faker.js 随机生成两百条片段就能测试列表性能。个人体会是代码片段管理这类工具功能看着小但真正做到“每天都想用”离不了优秀的搜索和流畅的高亮展示。现在我的微信收藏里已经没有代码碎片了t3code 的公开链接我放在主页上方便随手分享有兴趣的话可以去看看。