如果你经常在多项目间切换,一定遇到过这种场景:同一个分页组件、同一套日期处理函数、同一段生成报表的 SQL,每次都要翻旧仓库去复制。复制还不算难受,难受的是下次想找的时候,忘了自己到底有没有写过、存在哪个仓库里。t3code 这个项目,就是围绕这个痛点做的——一个基于 T3 Stack 构建的代码片段管理与分享平台,把散落在各个仓库、聊天记录和临时备忘录里的代码,变成可搜索、可分类、可复用的资产。
先交代一下背景。T3 Stack 是这两年全栈圈很火的一套组合拳,核心是 Next.js、TypeScript、Tailwind CSS、Prisma、tRPC 和 NextAuth.js,主打的是一个“端到端类型安全”。t3code 在它之上实现了代码片段的创建、编辑、打标、收藏、点赞、公开分享和私有管理,整套链路从前端表单到数据库查询都是类型安全的,几乎没有字符串拼接接口的松散感。项目适用范围也很清晰:想系统上手全栈开发的前端同学、要搭内部知识库的小团队,以及想低成本做个人工具站的独立开发者。
我自己从开始接到这个标题,到把项目跑通、部署上线,前后折腾了两个版本。第一版用的还是老的 REST 思路,接口、类型定义、文档全都要手写,越写越觉得在重复劳动。后面整个推到 T3 Stack 重来,才发现大部分“粘合代码”根本不必要存在。这篇文章就把完整的设计思路、踩坑过程和可复现的实操步骤写出来。
1. t3code 的项目定位与设计思路
1.1 这个项目到底在解决什么问题
先说一个很典型的日常场景。你写了一个解析 Markdown 的工具函数,当时觉得很通用,顺手丢进了某个项目的 utils 目录。三个月后,另一个项目里要复用,你只能打开那个项目慢慢翻,或者干脆重新写一份。重新写倒不慢,但坏处在于:同类逻辑在不同项目里越变越多,最后谁也说不清哪个版本才是真源头。
市面上的代码片段管理工具其实不少。GitHub Gist 偏向公开仓库同步,对中文用户来说访问体验一般;本地笔记软件适合记录,但大多没有代码高亮、语言筛选、一键复制这类开发者向功能;企业内部 Wiki 又太重,为了一段代码要开一整套文档流程,成本不匹配。t3code 的定位就是居中的那一个:轻量、自托管、足够快、类型安全,既能做个人收件箱,也能做成小团队协作的代码库。
具体到功能层面,我拆了三层需求。第一层是“收”,要快速粘贴一段代码,自动识别语言,能写备注和打标签;第二层是“找”,要看列表时有语言过滤、关键词搜索,看详情时有高亮和复制按钮;第三层是“管”,自己的片段可以设为私有,公开片段可以点赞收藏,管理员概念暂时不要,只需要一个干净的个人中心。这三层需求对应下来的技术难点其实不算多,难的是把每一层都做得顺手,而不是停留在“能跑就行”。
1.2 为什么是 T3 Stack 而不是别的组合
先做个简单科普。T3 Stack 这个名字里的 T3,主流说法是 The TypeScript Toolbox,也就是“TypeScript 工具箱”的意思,由社区创作者 Theo 推广开来。它默认包含六件套:Next.js(服务端渲染与路由)、TypeScript(全栈类型)、Tailwind CSS(原子化样式)、Prisma(ORM 与数据库迁移)、tRPC(前后端 API 连接)、NextAuth.js(登录鉴权)。乍一看组合不少,但每一件都只负责自己的那一块,边界非常清晰。
我第一版之所以想放弃 REST,是因为写接口这件事本身太重复了。一个管理代码片段的平台,无非就是增删改查加几个聚合接口,但用 REST 写下来,前端要写一套请求封装,后端要写一套路由校验,中间还有一套手写的类型定义。两端一旦不同步,跑起来就是幽灵报错。tRPC 的思路是把后端路由当作“函数”来暴露,前端拿到的是一个 TypeScript 推断出来的调用对象,参数、返回值、错误信息全都有类型约束,表面上省的是类型定义,实际省的是心智负担。
选 T3 Stack 还有一个现实原因:它的脚手架对新手极其友好。create-t3-app 会在初始化时让你勾选需要的模块,生成的目录结构、prisma schema、tRPC router 都是社区验证过的标准套路,不是那种随手拼出来的模板。这意味着 t3code 的主体代码可以建立在“被大量项目验证过”的地基上,我们只负责业务逻辑和体验打磨,不用从头趟基础设施的坑。
2. 技术选型与初始化准备
2.1 create-t3-app 的脚手架产物
初始化 t3code 的过程非常标准化,直接用 npm 执行:
npm create t3-app@latest t3code执行后会出现一个交互式选择界面,我按项目需要勾选了下面的组合:Next.js、TypeScript、Tailwind CSS、Prisma、tRPC、NextAuth.js。其中“ESLint”和“Prettier”也建议保留,多花不了几分钟,但后面写代码的时候会舒服很多。
脚手架跑完之后的目录结构,核心部分大概是这样的:
t3code/ ├── prisma/ │ ├── schema.prisma │ └── seed.ts ├── src/ │ ├── pages/ │ │ ├── api/ │ │ │ ├── auth/[...nextauth].ts │ │ │ └── trpc/[trpc].ts │ │ ├── index.tsx │ │ └── snippet/[id].tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── snippet.ts │ │ │ │ ├── tag.ts │ │ │ │ └── root.ts │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ └── db.ts │ ├── styles/ │ └── utils/ │ └── api.ts第一眼看上去,最大的不同是src/server/api/routers这个目录。tRPC 把接口路由抽象成一个个“router”,每个 router 内部写 procedure 而不是传统的 handler。这里的“procedure”可以简单理解为一个可以被前端直接调用的函数,稍后会详细展开。
2.2 环境变量与依赖安装清单
脚手架不会自动替你配置数据库和登录服务,这一步必须手动完成。我在.env里配了下面几个变量:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/t3code" NEXTAUTH_SECRET="你的随机密钥" NEXTAUTH_URL="http://localhost:3000" GITHUB_CLIENT_ID="你的GitHub应用ID" GITHUB_CLIENT_SECRET="你的GitHub应用密钥"NEXTAUTH_SECRET建议用openssl rand -base64 32生成一串随机字符,不要随手打一串简单文本。NextAuth 用它来加密 session cookie,密钥越随机越难被伪造。GITHUB_CLIENT_ID和GITHUB_CLIENT_SECRET来自 GitHub OAuth 应用配置页,回调地址要写成http://localhost:3000/api/auth/callback/github,本地调试阶段这个地址不能写错,不然会被 OAuth 流程直接弹回。
依赖方面,脚手架已经把主体依赖装好了。后面我还额外装了@tanstack/react-query(其实 tRPC 已经内置依赖它)、zod(用于输入校验)、react-syntax-highlighter(代码高亮)。如果你打算自己处理 Markdown 相关的代码片段,还可以装一个highlight.js,但 react-syntax-highlighter 默认就带了不少常见语言,省事。
到这一步,项目已经可以npm run dev跑起来,但首页是默认模板,数据库也还没有建表。真正把 t3code 的肉做出来,从下一节的 Prisma 建模开始。
3. 数据库建模与 Prisma 实战
3.1 模型关系分析
代码片段管理,核心实体自然是 Snippet。但光有一张 snippets 表是不够的,需要一套能支撑业务的关系模型。我把 t3code 的模型拆成了五个部分:用户、片段、标签、点赞、收藏。
用户和片段是标准的一对多关系,一个用户能创建多个片段。片段和标签是多对多关系,因为一个片段可以打多个标签,一个标签也可以关联多个片段,所以中间需要一张隐式关联表。点赞和收藏本质上都是用户与片段之间的关联关系,区别在于业务语义不同,点赞是用户对公开片段的认可,收藏是以后要回看的书签,两者的区分在后续 UI 上会有体现。
关系确定之后,还有一个数据完整性的细节需要考虑:点赞关系必须保证“一个用户对同一个片段只能点一次”。在模型层面可以给Like表加一个联合唯一约束,这样即使客户端重复点击,数据库也会拒绝重复记录,比在代码里 if 判断可靠得多。
3.2 Schema 设计
实际写出来的prisma/schema.prisma核心部分如下:
model User { id String @id @default(cuid()) name String? email String? @unique image String? snippets Snippet[] likes Like[] collections Collection[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Snippet { id String @id @default(cuid()) title String language String @default("typescript") content String @db.Text description String @default("") isPrivate Boolean @default(false) views Int @default(0) author User @relation(fields: [authorId], references: [id]) authorId String tags Tag[] likes Like[] collections Collection[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId]) @@index([language]) } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] } model Like { id String @id @default(cuid()) user User @relation(fields: [userId], references: [id]) userId String snippet Snippet @relation(fields: [snippetId], references: [id]) snippetId String createdAt DateTime @default(now()) @@unique([userId, snippetId]) } model Collection { id String @id @default(cuid()) name String user User @relation(fields: [userId], references: [id]) userId String snippet Snippet @relation(fields: [snippetId], references: [id]) snippetId String createdAt DateTime @default(now()) @@unique([userId, name, snippetId]) }这里有两个容易忽略的点。Snippet 的author和authorId是一对配套写法,@relation指定外键字段;@@index则是对查询频率高的字段加索引,因为列表页一定会按authorId或language过滤,不加索引的后果是数据量上来后查询会肉眼可见地变慢。Tag 的name加@unique是为了防止同一个标签被创建两次,否则列表渲染时会出现“React”和“react”两个同名异形标签的脏数据。
3.3 迁移与种子数据
模型写好后,执行迁移:
npx prisma migrate dev --name init这条命令会创建迁移文件并同步更新数据库结构。如果你之前没有建t3code这个数据库,需要先手动建一个,Prisma 不会替你建数据库。另外,本地开发我建议直接用 SQLite 起步,把DATABASE_URL改成file:./dev.db,等部署到线上再切 PostgreSQL。SQLite 和 PostgreSQL 在 Prisma 里的大部分查询语法是通用的,切换只是改一行环境变量。
种子数据对开发体验的提升非常明显。没有数据的空列表,你连样式对不对都看不出来。我给 t3code 准备了一套 seed 脚本,生成几个不同类型的片段:一个 React 自定义 Hook、一个 Node 脚本、一个 SQL 查询、一个 CSS 技巧,每个都带上标签。这样首页、详情页、筛选栏都有真实内容可看。运行种子的方式是在package.json里加一条命令:
"prisma": { "seed": "tsx prisma/seed.ts" }然后执行npx prisma db seed即可。这里有个小坑:旧版本 Prisma 没有标准的 seed 协议,很多人会在package.json里直接写"seed": "tsx prisma/seed.ts"但忘了挂到prisma配置块下,结果执行报错找不到文件。
4. tRPC 核心链路实现
4.1 通用路由与权限中间件
tRPC 的骨架在脚手架上已经有了,核心文件是src/server/api/trpc.ts。我在里面定义了两个基础过程:publicProcedure和protectedProcedure。前者任何人都能调用,比如浏览公开片段;后者要求登录后才能调用,比如创建、编辑、删除和自己的收藏列表。
export const protectedProcedure = t.procedure.use( ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, user: ctx.session.user } }); } );这段中间件做了两件事:判断 session 里有没有用户,没有就直接抛出UNAUTHORIZED错误;有的话把用户对象塞进 context,后续 procedure 里就能直接拿到当前用户,省得每个接口都重复取 session。
把这段逻辑做成中间件而不是每个接口重复检查,是我在第二个版本里才想明白的优化。第一次写在每个 procedure 里,看起来只多了几行代码,但一旦要调整鉴权逻辑,比如从“登录即可”改成“必须邮箱已验证”,就得翻几十个文件。统一放中间件后,改动只在一处,效果完全一致。
4.2 片段列表与创建
一个典型的 snippet router 会是这样的结构:
const list = publicProcedure .input( z.object({ language: z.string().optional(), tag: z.string().optional(), search: z.string().optional(), cursor: z.string().optional(), limit: z.number().min(1).max(50).default(10), }) ) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { isPrivate: false, ...(input.language ? { language: input.language } : {}), ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), ...(input.search ? { content: { contains: input.search } } : {}), }, take: input.limit + 1, ...(input.cursor ? { cursor: { id: input.cursor } } : {}), orderBy: { createdAt: "desc" }, include: { author: true, tags: true }, }); }); const create = protectedProcedure .input( z.object({ title: z.string().min(1, "标题不能为空"), language: z.string().min(1), content: z.string().min(1, "代码内容不能为空"), description: z.string().optional(), isPrivate: z.boolean().default(false), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { ...input, authorId: ctx.user.id, tags: { connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })), }, }, }); });这里有一个值得展开的细节:limit + 1的游标分页技巧。每次取limit + 1条,如果返回结果多于limit,说明还有下一页,前端拿到多余的这条的 id 作为下一次请求的cursor。相比传统页码分页,游标分页不会因为新增数据导致重复或跳过,尤其在“最新片段”这类排序下更稳。
connectOrCreate是 Prisma 一个非常实用的 API。用户提交标签时可能已经存在,也可能不存在,这个操作会智能地匹配已有标签,没有的则新建,并且在 one query 内完成,不会先查再写的多次往返。
4.3 校验与错误处理
校验这块,我强烈建议每个输入都过一遍 Zod。很多初学者会轻视参数校验,觉得前端表单已经限制过了,后端写不写无所谓。但生产环境的真实情况是:请求可能来自写死的脚本、Postman、爬虫,前端表单那层约束根本不经过后端。tRPC 的实践是每个 procedure 的input都定义 Zod schema,不符合的直接返回BAD_REQUEST,不用写一长串 if。
比较隐蔽的问题是日期和空字符串。比如isPrivate如果不加.default(false),客户端少传一个字段时,Prisma 拿到 undefined 又会触发 NOT NULL 约束报错,这属于运行时才暴露的错误。另一个常见坑是tags数组传了空数组,connectOrCreate会变成一个空操作,这在逻辑上是允许的,但前端列表页会出现一个没有任何标签的片段,显示效果会很奇怪。我的做法是给 tags 也加一个min(0),并且前端提交时如果为空就默认打上“未分类”标签。
5. 认证接入与页面联动
5.1 NextAuth 配置
NextAuth 的配置主要写在src/pages/api/auth/[...nextauth].ts和对应的authOptions里。我用的是 GitHub OAuth Provider,配置大致如下:
export const authOptions = { providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID ?? "", clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "", }), ], callbacks: { session({ session, token }) { if (session.user && token.sub) { session.user.id = token.sub; } return session; }, }, };上面的sessioncallback 是一个很关键但又容易遗漏的点。默认情况下,session 对象里只有用户的 name、email、image,没有数据库里的 userId。但 t3code 的创建、点赞、收藏接口都需要 userId,这个值从 token 的sub字段里拿到,再塞回 session 类型后,前后端都能安全访问。
用 GitHub 登录会让首次访问多一步跳转,但对一个开发者工具来说,这个体验是合理的。它省去了用户名密码的整套注册登录流程,也让隐私边界更清晰:平台本身不存储密码,密码管理交给 OAuth 厂商处理。
5.2 tRPC 上下文串联
为了让 tRPC 的每个 procedure 都能拿到 session,需要在创建 tRPC context 时调用getServerSession。这一步放在src/server/api/trpc.ts的createContext里:
export const createContext = async (opts) => { const session = await getServerSession(authOptions); return { ...opts, session, db, }; };注意,这里的getServerSession必须是 NextAuth 导出的那个,而不是getSession。两者的区别在于:getSession会额外发一次网络请求去读取 cookie,性能开销大且容易读取到旧状态;getServerSession直接读取请求头里的 token,减少一次往返,也更安全。
如果你跟着文章写到一个地方发现ctx.session在 type 上报错,多数情况是 NextAuth 的 session types 没有被正确扩展。需要在项目里加一个types/next-auth.d.ts文件,声明module "next-auth"里的 Session 接口包含user.id字段。这一步不写,代码能跑但类型全是红色波浪线,很影响心情。
6. 前端界面与体验优化
6.1 组件划分与“use client”边界
t3code 的前端我大体分了三类组件:布局组件(导航栏、页脚)、列表组件(片段卡片、标签列表、语言筛选)、交互组件(编辑器、复制按钮、点赞按钮)。在 Next.js App Router 模式下,默认组件都是服务端组件,但涉及点击交互的组件必须手动标"use client"。
这里有一个非常容易模糊的边界:服务端组件里能不能直接用useQuery?不能。React Query 是典型的客户端数据请求库,只能用在客户端组件里。但如果你把整个页面都标成"use client",又会丢掉服务端渲染的好处。我的策略是页面层面保持服务端,把需要交互的部分抽成小颗粒的客户端组件,页面通过 props 把初始数据传给子组件,子组件再交给 React Query 做后续更新。
具体到 snippet 详情页,页面本身用server直接查询数据库展示内容,旁边的点赞按钮拆成LikeButton这个客户端组件。这样首屏渲染依然是 SSR,用户可以立刻看到片段内容,点赞按钮再异步完成后续操作,体验和开发效率两头都占了。
6.2 样式与状态同步问题
Tailwind 用起来很爽,但 t3code 有一个特定痛点:代码高亮的颜色和引入的依赖包样式不够一致。我选了react-syntax-highlighter,默认主题是深色的。如果页面底色是浅色模式,代码块会显得刺眼;切成深色模式,页面各处又需要同步调整。我的处理方式是给代码块组件单独包一层容器,让代码区域的底色始终使用固定深色,不跟随全站主题切换。这不是最优解法,但对一个以代码为主的内容平台,深色代码区反而是符合直觉的视觉焦点。
另一个状态同步问题出在列表页筛选。用户选择“只看 JavaScript”,列表需要立刻刷新。很多初学者会把这个状态直接放到页面组件内部,然后用 useEffect 去触发查询。其实更好用的是 React Query 的 searchParams 状态方案:把语言筛选、标签筛选、搜索词都放到 URL 查询参数里。这样筛选条件天然支持分享链接,用户刷新页面不会丢失筛选状态,鉴权和缓存也都能正常复用。
7. 部署准备与踩坑实录
7.1 部署命令顺序
t3code 的部署链路其实非常短,我自己的部署顺序是这样的:先在服务器或云平台上创建一个 PostgreSQL 数据库,把DATABASE_URL替换成生产地址;然后执行npx prisma migrate deploy让数据库结构迁移到最新;接着npm run build构建产物;最后用平台自带的服务商方式启动应用。
注意区分migrate deploy和migrate dev。dev用于本地开发,会自动把迁移应用到本地数据库;deploy则只按迁移文件逐个执行,适合 CI/CD 环境,不会生成新的迁移历史,也不会基于当前环境交互式询问。部署时一定要用deploy,否则会因为环境上下文差异出现意外行为。
构建阶段最容易报的错是 Prisma Client 与 Schema 不同步。如果改过 schema 只跑了migrate dev,没有重新prisma generate,构建时类型不匹配会直接红灯。我的习惯是在package.json的构建脚本里加入prisma generate前置命令:
"build": "prisma generate && next build",这一步可以避免很多“本地好好的,一部署就红”的尴尬。
7.2 常见异常排查
下面把我在 t3code 开发部署过程中真实遇到过的典型问题整理成速查表,基本覆盖了最常见的坑。
| 现象 | 原因 | 解决方式 |
|---|---|---|
| NextAuth 登录后跳回登录页 | NEXTAUTH_URL没配置或域名带前缀 | 生产环境必须与访问域名完全一致 |
| tRPC 调用返回 UNAUTHORIZED | 客户端没有把 cookie 带过去 | 确认当前调用使用浏览器同源地址,未做跨域代理 |
| Prisma 报错 P1001 | 数据库连接不上 | 检查DATABASE_URL是否填写正确、数据库是否启动、安全组是否放行端口 |
| Prisma 报错 P2002 | 唯一约束冲突 | 大多和 Tag 的@unique有关,检查是否重复 create |
| 页面能访问但样式丢失 | Tailwind 缓存 | 删除.next目录后重新npm run dev |
| react-syntax-highlighter 不渲染高亮 | 传入的语言名与库的自带语言列表不匹配 | 先调用checkForLanguage确认支持,再动态注册语言包 |
除了表格里的机械问题,还有一个心理层面的经验很想分享。tRPC 报错和 REST 报错长得不一样,它会把具体类型信息带在错误结构里,初学者第一次看到密密麻麻的code: "INTERNAL_SERVER_ERROR"会慌。实际上你先去 server 端 log 里翻真正的异常信息,比在前端猜快得多。我自己后来习惯在 router 的 mutation 里临时套一层console.error观察真实报错,问题定位速度提升非常明显。
这些排查经验中,我个人最想强调的还是 Prisma 连接超时这一类问题。很多团队本地用 SQLite 很顺畅,一上生产换 PostgreSQL 就开始出现偶发连接失败,原因是默认连接池配置不适合云数据库规格。在 schema 里调低连接池上限,比如给DATABASE_URL加上?connection_limit=5,能有效避免连接耗尽。这只是个小配置,但踩过的人都知道,它能从“三天一小崩”变成“稳定跑一个月”。
t3code 这个项目做完,我最大的体会是:一套好的全栈框架,省下的不只是写代码的时间,更重要的是把“类型即文档”和“模式即规范”的思维方式固化到了开发流程里。你不需要去背接口文档,不需要担忧类型漂移,改一处模型,前端类型会自动跟着变化。如果你也想搭一个自用小工具,或者想体会一把全栈类型安全带来的爽感,建议从 t3code 这样的代码片段管理项目起步,规模适中,坑位清晰,做完能直接当自己的技术资产长期用下去。