☰
用T3 Stack从零构建代码片段管理工具:全栈开发实战记录
2026/10/27 22:54:44 网站建设 项目流程

如果你跟我一样,日常写代码时总会攒下一堆“以后说不定用得上”的代码片段,散落在微信收藏、飞书笔记、本地 txt 甚至聊天记录里,那你大概能理解我为什么会做t3code。t3code是我近期折腾的一个全栈开源项目,用T3 Stack(Next.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-app@latest 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"后,发现连接串必须加上sslmode=require才能连上 Vercel Postgres。最开始的错误是:

Error: PrismaClientInitializationError: P1001: Can't reach database server at ...

排查了半天,确认是 Vercel Postgres 要求使用DATABASE_URL环境变量,并且要用他们提供的?sslmode=require后缀。所以如果直接用现成模板,记得在.env里配置完整的连接串:

DATABASE_URL="postgresql://postgres:password@your-host.com:5432/t3code?sslmode=require"

迁移命令要分环境。本地开发用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: "demo@t3code.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.ts):

import { 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.context<typeof 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 里。然后定义两种 procedure:publicProcedure用于公开查询,protectedProcedure则会先检查 session,没有就抛UNAUTHORIZED。

4.2 扩展 Session 类型,拿到用户 ID

默认的Session.user类型只有name、email、image,我想拿用户的id作为 snippet 外键,所以需要扩展类型。创建src/types/next-auth.d.ts:

import { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }

并且要在 NextAuth 的callbacks.session里把 database 用户 id 塞进 session:

callbacks: { 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 = createTRPCReact<AppRouter>();

然后在根组件包一个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.clipboard:

function handleCopy() { navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 2000); }

如果是在 HTTP 环境下(非 localhost),navigator.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 missing

create-t3-app 默认会在构建时做环境变量校验(env.js),所有缺少的变量都会直接 fail。这个设计很严格,但对新手不够友好。解决方式是在 Vercel 项目配置里把所有环境变量都填上,包括:

  • DATABASE_URL
  • NEXT_PUBLIC_VERCEL_URL(这个可以留空,Vercel 自动注入)
  • DISCORD_CLIENT_ID
  • DISCORD_CLIENT_SECRET
  • AUTH_SECRET(或NEXTAUTH_SECRET)
  • AUTH_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?error=OAuthCallback

第一反应是 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之类的网关地址,并设置?pgbouncer=true,具体可以看 Vercel 控制台提供的连接串。我没有深挖,只是按官方建议改成了:

DATABASE_URL="postgresql://postgres:password@your-host-pooler.example.com:5432/t3code?sslmode=require&pgbouncer=true"

这个改动把冷启动时的建连时间从 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 的公开链接我放在主页上,方便随手分享,有兴趣的话可以去看看。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询