t3code 是我从去年下半年开始折腾的一个全栈项目,简单来说,它就是一个基于 T3 技术栈(Next.js + TypeScript + tRPC + Prisma + Tailwind CSS)开发的个人代码片段管理与协作分享工具。很多朋友第一次听到这个名字会问,代码收藏工具那么多,GitHub Gist、Notion、语雀、在线编辑器一抓一大把,为什么还要自己写一个?我做完 t3code 之后的体会是:在团队协作、快速沉淀、灵活检索的场景下,一个你自己能完全掌控数据结构和业务逻辑的小工具,往往比大而全的通用平台顺手得多。项目做到现在,已经能支撑我日常每天几十条代码记录的存取,也能给团队内部做小范围的知识库试用,今天把完整的思路、实现细节和踩坑记录都放出来,给想自己搭一套全栈应用的朋友做个参考。
1. 项目定位与整体设计思路
1.1 想解决什么问题
t3code 要解决的核心问题,其实就一句话:让代码片段被高效地记录、检索和复用。
我自己的使用场景是这样的:白天写业务代码时,经常会冒出一些可复用的写法,比如一条复杂的 SQL、一段封装好的 TypeScript 工具函数、一个踩过坑之后才总结出来的配置片段。以前我习惯把它们丢进 GitHub Gist,但 Gist 对私密性和标签管理支持得比较弱,搜索起来也不够灵活。换成 Notion 之后呢,表格结构确实灵活,但记录一段代码还得先排版,写完之后还要担心代码块样式被编辑器破坏。至于本地文档,那就更原始了,跨设备同步基本靠网盘,检索全靠记忆。
所以 t3code 从第一版的设计目标开始就很明确:一个专门的代码片段工作台,要有标签体系、全文搜索、公开/私有开关、分享链接,以及尽量干净的代码展示体验。它不是要做成第二个 Gist,而是做成本人或团队积累技术资产的地方。项目名里的 t3 一方面指技术栈 T3(TypeScript、tRPC 等),另一方面也暗含 Third,意味着这是继文档文件和笔记软件之后,第三种代码留存方式。
1.2 功能边界与取舍
做这类工具的难点不在于功能多,而在于克制。t3code 的功能边界,我在规划时就定得很死:
- 核心功能只有三个:存取片段、按标签和全文检索、分享公开页面
- 登录方式用账号密码即可,不做复杂的第三方登录集成
- 代码编辑用简单的文本域加高亮展示,不做在线 IDE 那种实时提示
- 不搞评论、点赞、收藏数这些社交化功能
- 部署目标锁定 Vercel 这种 serverless 平台,不在自建服务器上耗时
这样的取舍有几个直接好处。第一,开发周期短,从脚手架到可用版本我只花了一个多月的业余时间;第二,数据结构清晰,不会因为功能堆叠导致数据库越改越乱;第三,部署和运维成本极低,完全不需要关心服务器进程和备份策略。后面所有关于架构和代码的讨论,其实都建立在"这个项目就是要小而美"这个大前提上。
2. 技术选型拆解:t3code 为什么选这套组合
2.1 Next.js App Router:一整套页面与接口的承载层
选择 Next.js 做应用框架,最主要的原因是它让"页面 + 接口"这件事变得非常简单。t3code 虽然是一个前后端一体化的应用,但对我来说根本不需要拆分两个独立项目再处理跨域、环境变量、构建部署这些额外复杂度。Next.js 的 App Router 天然支持服务端组件和客户端组件的分层,我可以在同一个项目里既写好静态展示的页面,又写好需要用户信息的动态交互页面。
实际开发中,我用了 App Router 的 route handlers 来承接 tRPC 的 HTTP 请求,页面路由则按功能模块划分:首页是片段列表,/snippet/[id]是详情页,/share/[id]是公开分享页,/settings是个人设置。这种目录结构非常直观,几乎不需要查文档就能知道哪个文件对应哪个页面。
2.2 tRPC:把 API 调用变成函数调用
如果说 Next.js 是骨架,那 tRPC 就是 t3code 的神经中枢。在没有 tRPC 的常规全栈项目里,前端调用后端通常要经历这几步:写接口、定义参数、处理序列化、处理错误、前端口头约定字段。接口一多,类型不一致的问题就层出不穷,今天后端改了字段名,前端可能三天后才发现。
tRPC 的做法是把整个服务端 API 定义成一个类型安全的对象,前端引入这个对象的方法时,入参和出参的类型都是自动推导出来的。简单说,以前调用接口是发 HTTP 请求,靠文档理解参数;用 tRPC 之后,调用接口就像调用本地函数,参数不对直接在代码层面就报错,不给你运行时才出错的机会。
这套机制在我维护 t3code 的过程中帮了很大忙。比如我重构"保存片段"这个接口时,把入参从tags字符串数组改成了tagNames,改完服务端定义后,前端调用处立刻就能看到类型不匹配的红线,不用手动去翻调用链,也不会出现线上运行到某个页面才炸掉的情况。
2.3 Prisma:数据库操作不再写模板代码
数据库层我选了 Prisma,原因也很实际:t3code 在开发阶段用 SQLite,部署到线上用 PostgreSQL,Prisma 的 schema 定义可以在这两种数据库之间无缝切换,只需要改provider字段即可。这就意味着本地调试时不需要装数据库服务,一行命令就能起环境,而上了生产环境又能直接换用更可靠的 Postgres。
Prisma 另一个好处是自带迁移系统。每次修改schema.prisma后执行prisma migrate dev,它会自动生成迁移 SQL 并应用到本地数据库,整个流程不需要手写一句建表语句。生成的类型定义还能直接导入到 TypeScript 代码里,查询结果天然具备完整类型提示。
2.4 Tailwind CSS:样式低成本覆盖
样式方案没有太多纠结,直接用 Tailwind CSS。项目里需要自定义样式的场景其实不多,绝大部分都是列表排版、表单控件、标签样式这类常规 UI。Tailwind 的原子类配合响应式前缀,能在不写一行 CSS 文件的情况下覆盖几乎所有布局需求。而且,t3code 用到了代码高亮,高亮样式本身是独立引入的主题 CSS,跟 Tailwind 没有任何冲突,这也是我比较满意的一点。
3. 核心模块与关键实现
3.1 数据库设计:三张表撑起全部业务
t3code 的数据库只有三张核心表:用户表、片段表、标签表,外加一张多对多关联表。我用 Prisma schema 定义如下:
model User { id String @id @default(cuid()) email String @unique name String? password String? snippets Snippet[] createdAt DateTime @default(now()) } model Snippet { id String @id @default(cuid()) title String content String language String @default("text") visibility String @default("private") // "private" | "public" author User @relation(fields: [authorId], references: [id]) authorId String tags SnippetTag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId]) @@index([visibility]) } 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]) tag Tag @relation(fields: [tagId], references: [id]) @@id([snippetId, tagId]) }这段设计里有几个细节值得说明。第一,片段表和标签表用SnippetTag关联表来维护多对多关系,而不是直接在Snippet上挂一个tags数组,这样才能在数据库层面保证标签的唯一性和查询效率。第二,visibility字段我直接用了字符串"private"/"public",没有做枚举类型,因为当前业务逻辑里只有两种可见性,枚举带来的扩展性现在用不上。第三,@@index([authorId])是必须加的,因为在个人片段列表页里,最核心的查询就是根据用户 ID 取数据,没有索引会导致数据量上来后查询越来越慢。
3.2 tRPC 路由设计:从列表到保存的完整链路
t3code 的 tRPC 路由分成两层:根路由合并用户路由和片段路由,片段路由包含list、get、save、delete、update五个核心方法。这里贴一下保存片段和列表查询的关键实现:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc"; export const snippetRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ keyword: z.string().optional(), tag: z.string().optional(), }) ) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { authorId: ctx.session.user.id, visibility: "private", ...(input.keyword ? { OR: [ { title: { contains: input.keyword } }, { content: { contains: input.keyword } }, ], } : {}), ...(input.tag ? { tags: { some: { tag: { name: input.tag } } } } : {}), }, include: { tags: { include: { tag: true } } }, orderBy: { updatedAt: "desc" }, }); }), save: protectedProcedure .input( z.object({ id: z.string().optional(), title: z.string().min(1), content: z.string(), language: z.string().default("text"), visibility: z.enum(["private", "public"]).default("private"), tagNames: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { const tagNames = [...new Set(input.tagNames.map((t) => t.trim()).filter(Boolean))]; const tags = await Promise.all( tagNames.map((name) => ctx.db.tag.upsert({ where: { name }, update: {}, create: { name }, }) ) ); if (input.id) { await ctx.db.snippetTag.deleteMany({ where: { snippetId: input.id } }); return ctx.db.snippet.update({ where: { id: input.id }, data: { title: input.title, content: input.content, language: input.language, visibility: input.visibility, tags: { create: tags.map((tag) => ({ tagId: tag.id })), }, }, }); } return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, visibility: input.visibility, authorId: ctx.session.user.id, tags: { create: tags.map((tag) => ({ tagId: tag.id })), }, }, }); }), });这里有一个非常容易出错的点:tag.upsert用的是标签名字做唯一键,但注意我在save之前对tagNames做了去重和去空格处理,如果用户输入了"React"和" react "这两个标签,程序最终只保留一个"React"。不加这一步,Prisma 的upsert在并发场景下可能因为重复标签插入而报唯一约束冲突。
另外,更新片段时我先把原来的snippetTag关联全部删除,再重新建立,这个方案在数据量小的场景下性能完全没问题,而且逻辑简单不易出错。如果以后片段数量上了十万级,可以考虑改成增量的标签对比,但对于 t3code 这个体量,没必要提前优化。
3.3 认证方案:轻量密码登录就够了
认证我没有引入复杂的 OAuth 体系,而是直接用账号密码注册登录,会话状态用 JWT 存到 HttpOnly Cookie 里,交给 NextAuth 管理。用 NextAuth 的一个好处是,以后如果想接入 GitHub 登录或者企业微信登录,只需要在authOptions里继续添加 provider 就行,不需要推翻现有架构。
认证相关的 tRPC 封装也值得一提。我在createTRPCRouter基础上做了权限拆分:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session } }); });所有需要登录的接口,直接声明为protectedProcedure,一旦 session 不存在就统一抛出UNAUTHORIZED错误。前端在 tRPC 客户端里拦截了这个错误码,跳转到登录页并提示用户重新登录。这个方案帮我省掉了在每个接口里手工判断登录状态的重复代码,整体逻辑集中且好维护。
3.4 前端组件与页面渲染
页面层我用的是全量客户端组件,主要原因是 t3code 的碎片信息存储是一个高频交互的产品,几乎每个操作都涉及状态变更,用服务端组件反而要做更多的客户端边界设计,投入产出比不高。
组件拆分上,我只做了四类:
- 片段列表组件
SnippetList:接收片段数组,渲染卡片列表 - 标签筛选器
TagFilter:从所有片段中提取标签集合,点击进行筛选 - 编辑器表单
SnippetEditor:标题输入、语言选择、正文编辑、标签录入 - 高亮展示组件
CodeBlock:用统一的代码高亮库渲染代码块
代码高亮我选的是 Shiki,它在构建时把代码转换成带样式的 HTML 片段,运行时不用加载庞大的高亮脚本,首屏渲染速度快很多。Shiki 支持的语言众多,默认配置里我按需引入了 TypeScript、JavaScript、Python、SQL、Shell、CSS 等常用语言的解析器,没有全量加载,这样打包体积能控制住。
4. 实操过程与部署细节
4.1 从零初始化一个 t3code 项目
如果看完上面的设计后想动手复刻,最直接的路径是用create-t3-app生成基础工程。打开终端,在当前目录下执行:
npx create-t3-app@latest t3code安装过程中会有几个交互式选择,我的实际选择如下:
- Next.js 版本选最新稳定版
- 是否 TypeScript:选是,这是 T3 的核心
- tRPC:选是,并且选择
query和mutation的目录结构为 App Router 模式 - Prisma:选是,数据库先选 SQLite
- Tailwind CSS:选是
- NextAuth:选是,使用 Credentials provider
- 是否启用 ESLint / Prettier:都选是
生成工程后,npx prisma db push先同步数据库结构,再npm run dev启动项目。此时访问localhost:3000就能看到基础首页了。这里顺带提醒一句,开发阶段用的是 SQLite,数据库文件会自动生成在prisma/dev.db,如果想要一键重置本地数据,直接删掉这个文件再执行prisma db push即可,非常干净。
4.2 完整实现一个"保存片段"流程
为了让大家对 t3code 的实际开发有个直观认识,我拆解一下"用户在前端写完代码点击保存,到底发生了什么":
用户在编辑器表单里填写标题、粘贴代码、选择语言、录入标签,点击保存按钮。
前端触发
saveSnippetMutation.mutate(),传入表单数据。这里的数据在进入 tRPC 前先经过 zod 的类型校验,比如title不能为空,visibility必须是private/public之一。tRPC 服务端接收到请求后,先用
protectedProcedure校验登录态,再执行save方法。方法内部先处理标签的upsert,再处理片段本体的更新或创建。数据库写入成功后,返回新的片段对象,包含关联的标签信息。
前端在
onSuccess回调里触发重新查询列表,新片段出现在列表顶部。
这个流程看起来不长,但每一步都卡在类型安全上:前端表单的数据结构、tRPC 的输入定义、Prisma 的数据库实体三者的类型完全一致,任何一端改动了字段,另外两端的编译会立刻报错。这就把很多原本要运行时才能暴露的问题提前到了编码阶段,也是 t3code 项目开发体验最舒服的一点。
4.3 部署到 Vercel 的关键配置
部署环节我踩过几个坑,现在复盘下来其实重点就三个:环境变量、数据库连接、构建命令。
用 Vercel 部署 t3code 时,你需要配置的环境变量主要有:
| 变量名 | 说明 | 示例值 |
|---|---|---|
DATABASE_URL | 生产环境数据库连接串 | postgresql://user:pass@host:5432/db |
NEXTAUTH_SECRET | 用于加密 session/JWT 的密钥 | 随机生成的至少 32 位字符串 |
NEXTAUTH_URL | 应用对外地址 | https://t3code.vercel.app |
数据库连接这里必须强调:生产环境绝对不要用 SQLite,因为 Vercel 的函数是无状态的,文件系统在函数执行完就会被回收,数据根本存不下来。正确做法是在本地先执行prisma migrate dev生成迁移文件,然后线上执行prisma migrate deploy把结构同步到 PostgreSQL 上。
还有个容易忽略的问题:Vercel 构建时会自动执行prisma generate生成客户端类型,但不会自动跑迁移。有些朋友部署后发现线上报Table does not exist之类的错,十有八九就是迁移没有同步到生产数据库。我的习惯是在package.json的postinstall脚本里加上prisma generate,保证安装依赖时客户端一定是最新生成的。
4.4 GitHub Actions 自动同步数据库
如果你希望推送代码后自动完成数据库迁移,可以在仓库里加一个 GitHub Action。我参考的 workflow 大致是这样的思路:当master分支有新提交时,触发一个 job,用 Node 环境安装依赖,执行prisma migrate deploy,然后结束。这里只需要一个数据库连接串的密钥,把它配置到 GitHub 仓库的 Secrets 里就行。这个做法虽然不是必须的,但对长期迭代的项目来说,能避免每次部署后都要 SSH 上服务器手动敲命令的麻烦。
5. 常见问题与排查实录
5.1 tRPC 提交表单时返回 401 错误
这是新手最常见的问题,登录状态明明存在,但提交数据时接口报UNAUTHORIZED。
排查思路分三步:先看浏览器里有没有正确的会话 Cookie,其次看 tRPC 客户端请求头是否携带了凭证,最后看服务端authOptions里的 session 回调是否把用户信息正确写入了上下文。
我在 t3code 里遇到的是第二类问题。NextAuth 默认在跨域请求下不会自动附带 Cookie,但 t3code 同源部署也不该有问题,后来发现是我在封装 tRPC 客户端时,把url写成了绝对路径http://localhost:3000/api/trpc,浏览器把这种情况视为跨域请求。改成相对路径/api/trpc之后问题立即消失。
5.2 全文搜索在 PostgreSQL 上导致表扫描变慢
开发阶段用 SQLite 时,contains查询很流畅,但数据量过千条后部署到 Postgres,发现列表页翻页和搜索明显变卡。
原因是 Prisma 的contains生成的 SQL 是LIKE '%keyword%',这种查询无法使用普通 B-tree 索引,必须做全表扫描。t3code 目前的体量其实还不到需要引入全文索引的程度,我是通过两个方式缓解的:一是控制每次查询只返回最近 50 条,二是把关键字搜索限制在标题和代码片段的前 1000 个字符,避免 Postgres 扫描巨大的文本字段。如果以后片段量真的膨胀到上万条,再考虑接 PostgreSQL 的全文检索(tsvector)或者外挂一个轻量搜索引擎。
5.3 代码高亮闪烁和样式冲突
一开始我用的是 Prism.js 的动态高亮方案,问题非常明显:页面刚加载时代码是纯文本,高亮脚本运行后才变色,视觉上能感知到明显的闪烁。
换到 Shiki 后这个问题直接消失,因为 Shiki 在服务端或构建时就把代码转成了带高亮 class 的 HTML,浏览器拿到就已经是成品。不过这里也有个小坑:Shiki 的默认 CSS 主题和 Tailwind 的 preflight 样式有少数颜色冲突,导致某些 token 颜色显示不对。解决办法是把 Shiki 的高亮样式作为独立 CSS 文件引入,并用更具体的选择器优先级覆盖掉 Tailwind 的通用样式,实测下来没有再出问题。
5.4 部署后页面空白的排查记录
有一次推送代码后,线上页面出现了大范围空白,本地却一切正常。这类问题通常不是业务逻辑的 bug,而是构建过程中的静态资源或环境变量问题。
我当时的排查顺序是:先看 Vercel 的构建日志,确认有没有 TypeScript 报错或打包超时;再看浏览器控制台的网络请求,发现部分 JS 资源 404;最后确认是 Next.js 生成了带 hash 的文件名,而我用了相对路径的静态资源配置。调整assetPrefix相关配置并重新构建后恢复正常。像这类问题,要养成先看构建日志的习惯,它本身就会提示具体是哪一步出了问题。
5.5 常见问题速查表
| 现象 | 可能原因 | 处理方案 |
|---|---|---|
| 请求 tRPC 接口返回 401 | 会话 Cookie 缺失或 tRPC URL 使用了绝对地址 | 使用相对路径/api/trpc并确认 Cookie 有值 |
| 保存片段后标签重复 | tagNames未去重或存在空格差异 | 在服务端用Set去重并trim |
| 删除片段后关联标签还存在 | 多对多关联未清理 | 删除时同时调用snippetTag.deleteMany |
| 生产环境数据库无表 | 忘记执行prisma migrate deploy | 在 CI 或部署流程中增加迁移步骤 |
| 代码高亮初始闪烁 | 运行时高亮方案导致 | 改用 Shiki 在构建时完成高亮渲染 |
| 列表分页不生效 | findMany缺少take/skip参数 | 在查询中加入分页参数并传入前端页码 |
6. 一些经验与后续打算
t3code 做到现在这个阶段,我自己复盘了几个比较有借鉴价值的点。
第一,小项目的核心价值是需求聚焦,t3code 只做了代码片段的存取和检索,不做任何多余的社交和协作功能,所以整个项目结构才一直能保持清晰。第二,类型安全带来的长期收益远大于初期学习成本,tRPC + Prisma + TypeScript 的三角组合,让我每一次重构都有底气,因为编译期能拦住绝大部分低级错误。第三,部署在 serverless 平台上的项目,一定要在开发阶段就考虑到无状态环境的约束,比如数据库单独托管、静态资源不做本地文件依赖。
接下来一段时间,我计划给 t3code 增加几个实用的小功能:支持导出 Markdown 格式的片段文件、增加跨用户公开片段的聚合发现页、给片段密码分享链接。目前这个项目中关于服务端渲染、权限控制、数据库建模的实践经验,已经让我在日常工作时越来越敢尝试更复杂的全栈功能了。如果你也在做类似的知识管理工具或团队内网应用,希望这篇记录能给你一些直接可用的思路。