前阵子我自己搭了一个小项目,名字叫t3code,说白了就是一个基于 T3 技术栈的代码分享工具:把代码粘进去,选一下语言和过期时间,生成一个短链接,发给别人就能看。整个过程不需要登录、不需要注册,点开即读。做这个的起因很现实,群里经常有人发代码片段求 debug,微信里贴出来的代码格式全乱,用公共 paste 站又嫌广告多、加载慢。干脆自己用 T3 全栈写了一个,既能练手,又能日常自用。如果你正在学 Next.js、TypeScript、tRPC、Prisma 这套技术栈,或者也想自建一个轻量级工具,这篇内容应该对你有参考价值。
1. t3code 整体设计与技术选型
1.1 t3code 到底解决什么问题
日常技术交流里,代码片段分享是个高频场景。GitHub Gist 功能很全,但你需要登录、还要进仓库体系,对临时聊天场景来说太重了。传统 paste 工具又常常在复制结果、等待倒计时、广告弹窗上做文章,体验很不友好。t3code 的定位就是“用完即走的代码临时存放点”,核心功能只有三个:粘贴代码、生成短链接、按需过期。
这类工具看起来简单,但真要做一个“用着顺手”的版本,需要考虑的东西并不少,比如:链接够不够短、代码能不能高亮、过期时间准不准、页面会不会被恶意访问刷爆。t3code 把这些都收敛到了最简形态,没有用户体系,也就没有找回密码、个人中心这一大堆关联需求。对我来说,这个项目更大的价值在于它是一个完整的 T3 全栈练习,麻雀虽小,但涉及数据建模、API 设计、服务端渲染、部署上线,每个环节都能踩到不少真实的坑。
1.2 为什么选 T3 技术栈,而不是分开自由组合
T3 不是官方框架,而是一套社区实践组合,核心是 TypeScript + Next.js + Tailwind CSS + tRPC + Prisma。t3code 这个项目名就是直接跟技术栈挂钩的:T3 对应这套组合,code 对应代码分享。选择这套技术栈,最关键的一个理由是端到端类型安全。
传统前后端项目里,前端调后端接口要定义一堆interface,手写fetch或者封装 axios,接口一改,前端报错要等运行时才能发现。tRPC 的思路是让前后端共享同一个类型上下文,客户端直接 import 服务端的 router 方法,类型不对时编译器就会报错。这个体验对一个小工具来说可能还没那么夸张,但一旦业务复杂起来,减少的联调成本是肉眼可见的。
Prisma 负责数据层,把数据库表结构用 schema 写清楚,然后自动生成类型安全的客户端。删除手写 SQL 和字符串拼接的麻烦,表结构改了,类型跟着变,很多低级错误在编译阶段就被拦住了。Tailwind CSS 则让我不用离开 JSX 写样式,速度和一致性都有保障。
1.3 t3code 的范围控制:没做什么比做了什么更重要
做一个工具最容易犯的错是功能蔓延。刚开始我的脑子里闪过一堆想法:支持 Markdown、支持密码阅读、支持一键部署、支持评论区……但最后全砍掉了。t3code 的 MVP 范围只有四件事:
- 创建代码片段
- 生成短链接
- 按语言高亮
- 设置过期时间
没有用户系统,省掉了登录、注册、Cookie、Session、权限管理这一大套。没有评论,省掉了用户生成内容的审核和通知。没有搜索,因为临时分享的代码片段本身就不需要被长期检索。项目越小,边界越清楚,代码质量和完成度反而更容易做高。
2. 核心细节拆解:数据模型与关键机制
2.1 Prisma 数据模型:越简单越好用
t3code 的表结构非常简单,核心就一张Snippet表。我当时用 Prisma 写的 schema 大概是这样的:
model Snippet { id String @id @default(cuid()) slug String @unique content String language String @default("text") expiresAt DateTime? createdAt DateTime @default(now()) }这个结构里,@id是数据库内部的主键,用于关联和性能;slug是对外暴露的短链接标识,必须唯一。expiresAt是可选字段,不填就代表永久保存,填了就按时间失效。没有加标题字段,因为临时分享场景里,代码本身比标题重要得多。也没有加浏览量字段,虽然我后来在日志里观察过访问情况,但 MVP 阶段没必要做计数器。
这里有一个细节:为什么不用自增数字 id 当短链接,而是单独搞一个slug?因为自增 id 太容易被遍历了。用户创建一条代码后,紧接着有人用?id=123、?id=124就能把他还没过期的代码全扫出来,隐私上非常难看。用随机 slug 就不存在顺序枚举的问题。
2.2 slug 生成:随机短码的概率与重试
slug 的生成方式有很多,我一开始想用长度很短的 4 位随机字符,毕竟链接越短越好。但 4 位只能覆盖 62 的 4 次方,约 1470 万个组合,已经不错了,可对于公开服务来说还是有点紧张。t3code 默认用 8 位随机字符,字符集是0-9a-zA-Z,去掉容易混淆的0O1lI,剩下 58 个字符。58 的 8 次方约等于 1.28 万亿,日常使用基本不用担心撞车。
生成随机串的代码不复杂,我用了 Node.js 内置的crypto.randomBytes:
import { randomBytes } from "crypto"; const ALPHABET = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"; export function generateSlug(length = 8): string { const bytes = randomBytes(length); let slug = ""; for (let i = 0; i < length; i++) { slug += ALPHABET[bytes[i] % ALPHABET.length]; } return slug; }创建的时候先查一次是否重复,如果冲突就重新生成。这个概率非常低,但写入数据库时还是给唯一索引加了兜底,万一真撞了,Prisma 会抛P2002错误,在 tRPC 路由里捕获后重试一次即可。
2.3 过期时间:惰性删除比定时清理更靠谱
过期逻辑听起来很简单,但实现时要考虑一个现实问题:如果用户创建了一个 1 小时后过期的片段,数据库里那条数据还存在着,你到底什么时候删它?
比较自然的想法是写一个定时任务,每小时跑一次,把过期的数据删掉。但 t3code 如果想部署到无服务器环境就麻烦了,Vercel 的免费计划本身不支持常驻进程,用定时任务还得额外接 Cron 服务。所以我采用了惰性删除的思路:每次读取片段时都检查expiresAt,如果当前时间已经超过过期时间,就直接返回“这个片段已失效”,同时顺手执行一次删除操作,把废数据清掉。
const snippet = await db.snippet.findUnique({ where: { slug } }); if (!snippet || (snippet.expiresAt && snippet.expiresAt.getTime() < Date.now())) { if (snippet) { await db.snippet.delete({ where: { slug } }); } throw new TRPCError({ code: "NOT_FOUND", message: "Snippet not found or expired" }); }这样做的好处是代码简单,不需要任何外部依赖。缺点是如果一段数据永远没人访问,它就会一直留在数据库里占空间。对 t3code 这种小体量工具来说,完全能接受,真要担心就每隔一个月手动清一次。事后证明,这个决定帮我省掉了至少半天部署和维护定时任务的时间。
2.4 语法高亮:Shiki 静态生成,不拖累前端
代码高亮组件选型上,我踩过一个小坑。一开始用了react-syntax-highlighter,它确实方便,但会一次性加载很多语言包,打包体积蹭蹭往上涨。用户打开一个页面要下载几百 KB 的 JavaScript,只为了看一段代码高亮,太不划算了。
后来换成Shiki,它的做法是在服务端根据语言生成高亮 HTML,直接把结果返回给浏览器,客户端不需要再跑高亮逻辑,也不需要下载语言包。渲染效果接近 VS Code 的配色,观感也好看。我用了一个包装函数:
import { getHighlighter } from "shiki"; let highlighter: Awaited<ReturnType<typeof getHighlighter>> | null = null; export async function highlightCode(code: string, language: string) { if (!highlighter) { highlighter = await getHighlighter({ themes: ["github-dark"], langs: ["javascript", "typescript", "python", "go", "rust", "shell", "json", "sql", "text"], }); } return highlighter.codeToHtml(code, { lang: language || "text", theme: "github-dark" }); }需要注意的一个细节是:代码内容必须先做 HTML 转义再交给高亮器,否则用户粘贴一段带<script>的代码,直接渲染到页面上会有 XSS 风险。Shiki 本身会处理一部分,但我仍然对最终 HTML 做了安全检查,并且给页面加了严格的 CSP 头,不信任任何站外脚本。这是做一个公开展示用户内容的工具时绝对不能省的功夫。
2.5 匿名设计的取舍与防滥用
t3code 不登录、不注册,用户体验非常好,但代价是任何人都能往你的数据库里扔东西。我做了三件事来兜底:限制 content 长度不超过 5 万字符;默认所有代码 7 天后过期;用 tRPC middleware 做了简单的速率限制。虽然免费方案下不可能做到完美防护,但这种轻量工具只要减少恶意刷量的动机就够了。真被刷了,由于没有用户系统,我只需要 truncate 数据表就能恢复干净状态。
3. 实操过程:从初始化到上线
3.1 初始化 T3 项目
创建项目直接用社区维护的脚手架create-t3-app,交互式命令行里勾选需要的模块即可:
npx create-t3-app@latest t3code过程中会让你选择 Next.js 的 App Router 还是 Pages Router,建议选App Router,这是目前 Next.js 的主力方向。Tailwind CSS、tRPC、Prisma 这三个都要勾上。数据库类型先选 SQLite,本地开发零配置,真正部署时再换成 PostgreSQL。项目创建后会生成标准的src/server/db.ts、src/server/api/root.ts、src/server/api/routers等目录结构,t3code 的路由代码就是往这个骨架里填。
3.2 定义 Prisma Schema 并完成迁移
schema 文件在prisma/schema.prisma,把第一节里的数据模型写进去,然后运行:
npx prisma db pushdb push适合开发阶段,直接把 schema 同步到数据库,不需要生成迁移历史。上线前如果要用正式数据库,建议改成npx prisma migrate dev --name init生成一份迁移文件,保证后续变更可追溯。这时候还需要设置环境变量DATABASE_URL,开发时指向本地 SQLite 文件就行:
DATABASE_URL="file:./dev.db"部署到 Vercel 时再换成 PostgreSQL 地址,比如 Neon 或者 Supabase 提供的连接串。如果用的是连接池地址,记得附加 SSL 参数,否则在云数据库上会连接失败。
3.3 tRPC 路由:创建与读取
t3code 的 API 有两个核心方法:create和getBySlug。由于没有用户系统,create方法不需要session,任何人都能调用。入参校验用 zod,这是 T3 生态里非常顺手的一环:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; import { generateSlug } from "../../utils/slug"; import { db } from "../db"; export const snippetRouter = createTRPCRouter({ create: publicProcedure .input( z.object({ content: z.string().min(1).max(50000), language: z.string().max(50).default("text"), expiresInHours: z.number().int().min(1).max(24 * 30).default(7 * 24), }) ) .mutation(async ({ input }) => { const slug = generateSlug(); const expiresAt = new Date(Date.now() + input.expiresInHours * 60 * 60 * 1000); const snippet = await db.snippet.create({ data: { slug, content: input.content, language: input.language, expiresAt, }, }); return { slug: snippet.slug, url: `/s/${snippet.slug}` }; }), getBySlug: publicProcedure .input(z.object({ slug: z.string().regex(/^[a-zA-Z0-9]{8}$/) })) .query(async ({ input }) => { const snippet = await db.snippet.findUnique({ where: { slug: input.slug } }); if (!snippet || (snippet.expiresAt && snippet.expiresAt.getTime() < Date.now())) { throw new TRPCError({ code: "NOT_FOUND", message: "Snippet not found or expired" }); } return { content: snippet.content, language: snippet.language, createdAt: snippet.createdAt, }; }), });这里有个很实用的点:regex校验限制了 slug 的格式,既避免用户传一个超长的查询条件,也能把非法请求挡在数据库查询之前。tRPC 的错误通过TRPCError抛出,前端直接用useQuery时能非常干净地拿到错误状态。
3.4 前端页面:创建页与展示页
创建页是一个典型的客户端组件。我用useState管理表单字段,点击提交后调用 tRPC 的createmutation,然后把返回的短链接显示出来。这个页面不需要服务端渲染,因为每次打开看到的都是空白表单。
展示页则相反,它是一个 Server Component。这样做的原因在于:读取代码片段、调用高亮在服务端完成,浏览器只需要接收最终的 HTML,对 SEO 和加载速度都比较友好。在 Server Component 里调用 tRPC 不直接用api.snippet.getBySlug.useQuery,而是用createCaller创建一个调用器:
import { createCaller } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; export default async function SnippetViewPage({ params }: { params: { slug: string } }) { const caller = createCaller(await createTRPCContext({ headers: Headers() })); const snippet = await caller.snippet.getBySlug({ slug: params.slug }); const highlighted = await highlightCode(snippet.content, snippet.language); return ( <main> <h1>代码片段 {params.slug}</h1> <div dangerouslySetInnerHTML={{ __html: highlighted }} /> </main> ); }有人会问:dangerouslySetInnerHTML听起来很危险,为什么还要用它?因为 Shiki 输出的已经是完整的高亮 HTML,React 不可能再纯文本渲染。关键是确保高亮器输出的内容经过了转义,页面上没有执行脚本的路径。另外,我在根布局里设置了Content-Security-Policy: default-src 'self',即使哪天高亮器有 bug,浏览器也会拦住大部分注入。
3.5 复制链接与用户体验
创建成功后,页面会给用户显示一个输入框,内容就是https://你的域名/s/{slug}。复制操作优先使用navigator.clipboard.writeText,但它要求页面处于 focus 状态且需要安全上下文。本地开发时 HTTP 环境可能不生效,所以提供了一个 fallback:先用临时textarea选中文本,再调用document.execCommand("copy")。这个小细节做完了之后,在手机浏览器和旧版桌面浏览器上都不会出现“复制无反应”的尴尬情况。
3.6 部署到 Vercel 的注意点
部署时数据库需要从 SQLite 切到 PostgreSQL。推荐 Neon 的免费层,连接串类似这种格式:
DATABASE_URL="postgresql://user:password@ep-xxx.region.aws.neon.tech/t3code?sslmode=require"这里有个坑:Vercel 的 Serverless 环境可能会产生大量数据库连接,Neon 的连接池地址能缓解这个问题。但 Prisma 会优先使用DATABASE_URL建立连接,最好在环境变量里再设置一个DIRECT_URL指向数据库直连地址,并把 schema 里的datasource改成directUrl = env("DIRECT_URL")。这样prisma migrate走直连,运行时走连接池,两边不打架。
构建时还需要在 package.json 里配置:
"build": "prisma generate && next build"否则 Vercel 构建阶段不会生成 Prisma Client,部署后必然报错。这个坑我踩过一次,查了小半天才搞明白。
4. 常见问题与排查技巧实录
4.1 tRPC 在 Next.js App Router 下请求报 401
现象:本地跑得好好的,部署后打开页面,tRPC 请求全部返回 401。原因通常有两个:一是你给 procedure 加了protectedProcedure,但客户端没有携带 cookie;二是 App Router 场景下createTRPCContext里读取headers()的方式变了。t3code 里的公开接口不需要鉴权,所以直接全部用publicProcedure即可。如果以后要加管理接口,再单独开一条受保护的路由。
4.2 页面打开显示 500,日志里是 Prisma “Can’t reach database server”
这种问题多半是环境变量没配全。先确认 Vercel 项目设置里的DATABASE_URL和DIRECT_URL都存在,再确认数据库 IP 白名单是否放行了 Vercel 出口 IP。Neon 默认允许所有 IP,但如果你用的是其他云数据库,就得手动加白名单。另一个容易忽略的点是连接串必须带?sslmode=require,不然 Prisma 连接时会被数据库服务器拒绝。
4.3 高亮语言新增后页面体积变大
Shiki 虽然比react-syntax-highlighter轻,但如果你注册了几十种语言,首次启动时加载语言包的时间也会变长。t3code 的做法是控制语言白名单,只保留最常用的十来种。代码里lang: "text"是最安全的 fallback,遇到不在白名单里的语言,直接走纯文本样式,不报错,也不影响整个页面。
4.4 过期数据清理不彻底怎么办
惰性删除只清“被访问到的过期数据”,没人访问的会一直留着。如果数据库膨胀让你焦虑,可以在 Vercel 的 Cron 配置里加一个定时任务,每天凌晨跑一次清理脚本:
{ "crons": [ { "path": "/api/cron/cleanup", "schedule": "0 0 * * *" } ] }然后新建一个 API route,在里面执行deleteMany删除所有expiresAt < now()的记录。这个 API 不需要对公网开放,但 Vercel Cron 调用时不会携带用户身份,最好在接口里检查Authorizationheader,防止别人手动触发。
4.5 超长代码提交导致页面卡顿
我一开始没有限制 content 长度,结果有个朋友粘贴了一个 200 KB 的 JSON 日志,前端渲染直接卡了几秒,数据库响应也变慢。后来在 zod schema 里加了max(50000),同时在 textarea 上做了字符计数,超过限制后按钮置灰并提示“代码长度超过上限”。对于临时代码分享工具,50 KB 已经足够覆盖 99% 的场景。
4.6 速查表
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 部署后 tRPC 请求 401 | procedure 用错了类型或 context 读取 header 失败 | 检查 procedure 是否应为 public,确认 context 代码 |
| 数据库连接失败 | 连接串缺少 SSL 参数、IP 白名单未配置 | 加sslmode=require,检查云数据库白名单 |
| 高亮代码乱码或没颜色 | 语言不在 Shiki 白名单内 | 注册对应语言或 fallback 到 text |
| 提交大段代码很慢 | content 长度无限制 | 增加 zod 长度校验,前端同步限制 |
| 过期的片段仍能通过 API 获取 | 读取时未检查 expiresAt | 在 getBySlug 中判断过期时间并删除 |
| 构建时 Prisma Client 找不到 | Vercel 构建阶段未运行 prisma generate | 修改 build 脚本为prisma generate && next build |
最后再分享一点我的实际感受
做完 t3code 再回头看,最大的收获不是某个具体的技术点,而是把一套全栈工具链完整地串了起来。以前看教程总觉得步骤都能懂,但自己动手时才会发现:schema 设计要考虑查询场景,随机 slug 要考虑碰撞,语法高亮要考虑 XSS,部署到 Serverless 要考虑数据库连接数。这些细节没有一项是复杂的,但它们拼在一起才是真正可用的项目。如果你也想练手 T3 技术栈,强烈建议从一个能解决自己真实问题的小工具开始,比如代码分享、URL 缩短、书签收藏,都不需要庞大的功能,三五天就能上线,跑起来之后你会对整条链路有一种非常踏实的感觉。