t3code 是我最近几个周末一个人折腾出来的代码分享工具,名字起得很随意:技术栈用 T3 Stack,代码主题是 code,合起来就是 t3code。它解决一个很具体的痛点:临时要给别人一堆代码,微信聊天里发文本格式全乱,邮件附件又太重,于是我做了个带语法高亮的粘贴板,粘贴、选语言、生成短链接,对方浏览器打开就能看。项目做完以后最大的感受是,T3 Stack 没有社区炒得那么玄,但“端到端类型安全”这个点确实能在小项目里立竿见影地砍掉联调成本。这篇文章从技术选型、初始化、核心功能、部署上线一条线讲下来,把容易踩的坑也一并说清。如果你第一次接触 T3 Stack,或者正想找个全栈练手项目,这篇应该能帮你省不少弯路。
1. 为什么用 T3 Stack 来写 t3code:技术选择的底层逻辑
1.1 T3 Stack 到底强在哪个地方
T3 Stack 不是某个公司的框架,而是社区里一套组合方案,核心是 TypeScript、Tailwind CSS、tRPC,名字里三个 T 就是这么来的,t3code 的“T3”也致敬了这一点。再配合 Next.js 做前端渲染和路由、Prisma 做数据库访问,基本就是 create-t3-app 脚手架给你生成的标准全家桶。
这套组合最核心的价值是“端到端类型安全”:你的数据模型、接口入参、返回类型全部只写一份 TypeScript 定义,然后通过 tRPC 从前端直接推到后端,或者从后端推回到前端,中间不需要手写接口文档,不需要生成一堆 dto 类,也不需要启动两个服务做代码生成同步。对 t3code 这种“表单提交代码片段、展示代码片段”的小项目来说,感受极其明显——写完 Prisma schema,跑完 migrate,接着写 tRPC router,前端立刻就能拿到完整类型提示。之前做前后端分离项目,我至少要花半天把 OpenAPI 定义和 mock 数据理清楚,这次基本没干这活。
1.2 为什么不是 REST + OpenAPI
tRPC 最常被问的问题就是“为什么不用 REST”。我自己的判断是:REST 本身没问题,OpenAPI 也很好,但它们解决的是“协议标准化”和“多方系统互通”,代价是维护一份独立的接口描述文件,还要处理类型生成、版本迁移。t3code 没有对外提供 API 给第三方调用的需求,也没有多客户端(iOS、Android、Web 并行)必须遵守统一协议的压力,所以用 tRPC 更划算。下面这个表是我在选型时给自己列的理由:
| 对比项 | REST + OpenAPI | tRPC |
|---|---|---|
| 类型传递 | 需要 codegen,前后端各维护一份 | 编译器直接推导,零 codegen |
| 接口文档 | 靠 OpenAPI/Swagger 维护 | 类型即文档 |
| 入参校验 | 手动写中间件或请求校验层 | 配合 zod,router 里声明即校验 |
| 开放生态 | 任何语言、任何客户端都能调 | 基本只适合 TypeScript 生态 |
| 上手成本 | 约定多,样板代码多 | 概念少,写函数就行 |
如果你要做的是开放 API、给非 TypeScript 客户端调用,或者有复杂的多租户权限体系,tRPC 不一定合适;但 t3code 是典型的小体量全栈应用,tRPC 带来的“删掉一层多余的翻译代码”收益就是最实在的收益。
1.3 Tailwind 与组件取舍
坦白说,用 Tailwind 写 t3code 的界面属于“快速能看”的选择。代码粘贴工具重点在内容本身,不在花哨视觉,Tailwind 的原子类让我不用切到 CSS 文件就能完成布局,几行类名就能把粘贴页和展示页搭出来。这里有个使用心得:小项目别一上来就引组件库,Tailwind + 几个原生元素足够撑起早期版本,等真需要复杂表格、弹窗、日期选择器时再引也不迟。我一开始差点就被“没有轮子不舒服”的心态带偏,回头想幸好忍住,t3code 核心页面其实只有两个:首页提交表单和展示页代码预览。
2. 搭建 t3code:初始化命令、目录结构与数据模型
2.1 create-t3-app 初始化
第一次跑通整个项目用不了太久,核心命令就一句:
npm create t3-app@latest t3code交互式选择器弹出来以后,我勾了 Next.js、TypeScript、Tailwind CSS、tRPC 和 Prisma,没勾 NextAuth。登录功能对当前版本的 t3code 不是刚需,带上只会多一堆 session 和 provider 的样板代码。数据库我选了 SQLite 用于本地开发,等部署到生产环境再换 Postgres,这个切换后面会专门讲坑在哪。
如果你拿到手的 create-t3-app 模板默认是 App Router(Next.js 13 以后的默认方式),那后面的代码结构基本跟我一致;如果是老一点的 Pages Router 模板,建议先按官方文档升级,不然代码里的app/server和src/trpc目录布局会差很多,网上大部分教程也是按 App Router 写的。
2.2 生成后的目录结构讲解
安装完以后,create-t3-app 会生成一个相对清晰的目录,t3code 用到的几个关键位置大概是这样的:
prisma/ schema.prisma src/ app/ page.tsx # 首页,提交表单 s/[shortId]/page.tsx # 代码展示页 api/ trpc/ [trpc]/route.ts # tRPC 的 HTTP 端点 server/ db.ts # Prisma 客户端实例 api/ routers/ snippet.ts # 代码片段相关操作 trpc/ router.ts # 根路由,聚合所有子路由 trpc.ts # createTRPCRouter、publicProcedure 定义一开始看server/db.ts里的 Prisma 实例你可能觉得有点绕:它用了globalThis做缓存,而不是在每个模块直接new PrismaClient()。这是故意的,在 Next.js 开发模式的热重载机制下,每次代码变更都可能重新加载模块,如果不复用单例,Node 进程会一直堆积数据库连接,跑不了多久就会冒出连接数过多的错误。这个细节是 T3 Stack 帮你提前处理掉的一个大坑。
2.3 Prisma schema 设计:代码片段表
t3code 的数据模型很简单,一张表就够用:
model Snippet { id String @id @default(cuid()) shortId String @unique code String @db.Text language String @default("plaintext") title String? createdAt DateTime @default(now()) }设计的时候我特意留了shortId,没有直接用主键id当链接后缀。原因有两点:一是cuid()出来的字符串太长了,分享链接会显得很笨重;二是短 ID 可以单独做业务规则,比如后面想自定义字符集、生成二维码、按日期生成带前缀的编号,都不影响主键。
初始化数据模型之后,按老规矩跑迁移:
npx prisma migrate dev --name init_snippet开发环境是 SQLite,所以迁移文件也是 SQLite 方言,这一步很顺,直到我把它切到 Postgres 才体会到跨数据库方言迁移的麻烦。
2.4 第一个 tRPC 路由:先跑通再写业务
初始化完以后,我先写了一个最简单的路由验证整条链路,而不是一上来就写完整业务。这个习惯帮你把“环境问题”和“业务问题”隔离开,非常建议保持:
// src/server/api/routers/snippet.ts import { createTRPCRouter, publicProcedure } from "~/trpc/trpc"; export const snippetRouter = createTRPCRouter({ getLatest: publicProcedure.query(async ({ ctx }) => { return ctx.db.snippet.findMany({ orderBy: { createdAt: "desc" }, take: 5, }); }), });然后在根路由里把它挂上去:
// src/trpc/router.ts import { createTRPCRouter } from "~/trpc/trpc"; import { snippetRouter } from "~/server/api/routers/snippet"; export const appRouter = createTRPCRouter({ snippet: snippetRouter, });前端的src/trpc/react.ts是脚手架生成好的封装,直接用api.snippet.getLatest.useQuery()就能拿到带完整类型的数据。第一次看到编辑器里出现从数据库字段推导出来的类型提示时,我确实觉得这才是全栈开发该有的样子。
3. 核心功能实现:上传、语法高亮与短链接分享
3.1 用 tRPC mutation 实现代码片段创建
首页表单要做的是把用户粘贴的代码提交到后端,然后拿到短 ID 生成链接。tRPC 里写操作对应mutation,入参校验交给 zod 声明式完成:
// src/server/api/routers/snippet.ts import { z } from "zod"; import { nanoid } from "nanoid"; const createSnippetSchema = z.object({ code: z.string().min(1, { message: "代码不能为空" }).max(50000), language: z.string().max(50).optional().default("plaintext"), title: z.string().max(200).optional(), }); export const snippetRouter = createTRPCRouter({ create: publicProcedure .input(createSnippetSchema) .mutation(async ({ ctx, input }) => { const shortId = nanoid(8); return ctx.db.snippet.create({ data: { ...input, shortId, }, }); }), });zod 在这里的价值不只是“校验了一下”,更关键的是这段 schema 同时被服务端和客户端共享,前端在写表单的时候就知道哪些字段必填、最大长度多少,不需要另外写一份req.body判断逻辑。nanoid(8)只有 8 个字符,8 位随机字符在小规模粘贴工具里的碰撞风险基本可以忽略,如果再想稳一点,可以插入查重重试的逻辑,或者改成 10 位。
3.2 前端表单:react-query 的 useMutation
t3code 首页使用 create-t3-app 生成的@trpc/react-query客户端。提交逻辑写起来很直接:
"use client"; import { useState } from "react"; import { api } from "~/trpc/react"; const LANGUAGES = ["plaintext", "typescript", "javascript", "python", "go", "rust", "bash"]; export function CreateSnippetForm() { const [code, setCode] = useState(""); const [language, setLanguage] = useState("typescript"); const mutation = api.snippet.create.useMutation({ onSuccess: (data) => { window.location.href = `/s/${data.shortId}`; }, }); return ( <div className="mx-auto max-w-2xl space-y-4 p-4"> <select value={language} onChange={(e) => setLanguage(e.target.value)} className="border rounded px-2 py-1" > {LANGUAGES.map((lang) => ( <option key={lang} value={lang}>{lang}</option> ))} </select> <textarea value={code} onChange={(e) => setCode(e.target.value)} rows={12} placeholder="在这里粘贴代码…" className="w-full rounded border p-3 font-mono" /> <button onClick={() => mutation.mutate({ code, language })} disabled={mutation.isPending} className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50" > {mutation.isPending ? "创建中…" : "生成分享链接"} </button> {mutation.error && <p className="text-red-500">{mutation.error.message}</p>} </div> ); }这里有个容易忽略的点:useMutation返回的isPending在请求进行中会变成true,按钮必须利用这个状态禁用,否则用户连续点几次会创建出多个同内容片段。另外mutation.error.message的内容可能就是后端 zod 报出来的“代码不能为空”,前端没有额外写判断,因为这个校验已经从服务端类型信息直接透传下来,这又是一个少写样板代码的例子。
3.3 语法高亮:选择一个省事方案再谈优化
代码展示页/s/[shortId]要做的第一件事就是把代码高亮。t3code 里我用了react-syntax-highlighter的 Prism 版,理由很简单:接进来快,支持语言多,默认主题不难看。
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; <SyntaxHighlighter language={snippet.language || "plaintext"} style={oneDark} showLineNumbers > {snippet.code} </SyntaxHighlighter>不过用这个库有个代价:它会一次性打包进很多语言定义,就算 t3code 只展示几种语言,bundle 体积也会明显变大。想压缩体积的做法是利用它提供的轻量入口,只手动注册需要的高亮组件,或者干脆换用基于 TextMate 语法的 Shiki,在服务端渲染成带 span 的 HTML,前端无脑注入就行。两种方案的取舍我列在这:
| 方案 | 接入成本 | 体积控制 | 高亮效果 |
|---|---|---|---|
| react-syntax-highlighter | 低,十几行代码 | 一般,需手动做动态导入 | 够用 |
| Shiki | 中,需要处理服务端渲染和主题 | 好,按语言按需加载 | 更精致 |
| Prism 原生 | 中,需自己写初始化 | 好 | 够用 |
对这个项目来说,“省事”比“极致体积”更重要,所以先跑通再优化是完全可以接受的。
3.4 短链接展示页与安全细节
展示页其实是个 Next.js 动态路由页面,核心逻辑是从params.shortId查库:
// src/app/s/[shortId]/page.tsx import { db } from "~/server/db"; export default async function SnippetPage({ params, }: { params: { shortId: string }; }) { const snippet = await db.snippet.findUnique({ where: { shortId: params.shortId }, }); if (!snippet) { return <div className="p-8 text-center text-gray-500">链接不存在或已失效</div>; } return ( <main className="mx-auto max-w-4xl p-4"> <div className="mb-3 flex items-center justify-between"> <h1 className="text-lg font-semibold">{snippet.title ?? "代码片段"}</h1> <span className="rounded bg-gray-100 px-2 py-1 text-sm text-gray-600"> {snippet.language} </span> </div> <SyntaxHighlighter language={snippet.language || "plaintext"} style={oneDark} showLineNumbers > {snippet.code} </SyntaxHighlighter> </main> ); }这里的安全红线是:绝对不能把代码原文通过dangerouslySetInnerHTML直接插进页面。即使用户提交的代码在创建时经过了转义,展示时再经过一次高亮渲染也会增加额外风险。react-syntax-highlighter默认会把代码作为纯文本处理再生成 token,如果哪天你换了其他高亮库,记住一条原则:先转义,再高亮,永远不要信任用户输入的原始内容。
另外我还做了两个小限制:语言字段做了白名单校验,避免有人塞一个超长的字符串或带奇怪字符的内容进来;代码长度限制在 50KB,既防止有人把整个仓库塞进来,也能避免页面渲染时卡死。
4. 部署上线实录:Vercel + Neon Postgres 的坑与选择
4.1 为什么生产环境要抛弃 SQLite
本地用 SQLite 开发非常舒服,零配置,跑起来就是一个文件。但上了 Vercel 的 serverless 函数之后,SQLite 的读写是基于本地文件的,函数每次冷启动运行在随机容器里,根本没有持久化文件系统,所以生产环境必须换成一个远程数据库。我选的是 Neon 的 Postgres 免费档,理由就两条: Postgres 和 Prisma 的兼容性最好,而且 Neon 的 serverless 连接方式正好适配函数按需启动的场景。
4.2 从 SQLite 切换到 Postgres 的迁移过程
这一步比我预期花的时间更多,核心原因是本地迁移文件是 SQLite 方言,直接拿去 Postgres 跑会挂。我的处理办法是:
# 1. 修改 schema.prisma 里的 provider # 将 datasource db 的 provider 改为 "postgresql" # 2. 删除本地旧的 migrations 目录(开发期间数据不重要时) # 3. 重新生成 Postgres 方言的迁移文件 npx prisma migrate dev --name init_snippet_postgres如果你已经有正式数据要保留,不能像我这样直接删迁移目录,正确做法是写一个独立迁移脚本,把旧数据从 SQLite 导出再灌到 Postgres,或者搭配 pgloader 之类的工具。但 t3code 还在开发期,数据丢了无所谓,直接重置迁移是最快路径。
部署前要在 Vercel 项目设置里配置环境变量,最关键的是DATABASE_URL。我踩了一个比较隐藏的坑:Prisma 在 serverless 环境下需要连接池或者长连接管理,否则并发一上来连接数会飞快打满。Neon 控制台里能拿到两种连接串,一种是直连的,一种是走连接池的,部署到 Vercel 时我建议用带-pooler字样的连接串,而本地开发用直连的,两者不要混用。
4.3 生产环境执行数据库迁移
本地跑迁移用的是npx prisma migrate dev,但生产环境不能用这条命令,它会顺手触发 seed 和开发模式的行为。正确命令是:
npx prisma migrate deploy我在发布流程里把它放进了构建脚本中一个前置步骤,先保证数据库结构是最新的,再让 Next.js 编译产物启动。如果你用 GitHub Actions 做 CI/CD,同样把prisma migrate deploy放在构建之后、部署之前即可。
4.4 serverless 下的冷启动和 tRPC 批处理限制
部署完成后第一次打开 t3code 的展示页,我明显感觉到了冷启动延迟:函数被唤醒后要重新建立数据库连接,首次请求经常要等 3 到 5 秒。这个问题在 Vercel 免费档里无法完全消除,只能缓解。我的处理策略是:首页做静态化,让没有数据的落地页秒开;真正需要动态读取数据的展示页接受这个延迟,因为分享链接的使用频率本身不是每秒并发几十次的场景。
另一个和 tRPC 相关的坑是 HTTP 批量请求。tRPC 为了减少网络往返,默认会把同一时间片内的多个请求合并成一个 HTTP 请求,这对普通 Node 服务没问题,但在 serverless 环境里,批处理请求的等待时间变长,调试时还会看到一堆聚合在一起的请求日志,看着很迷惑。小项目用不着这个优化,直接在客户端配置里把httpBatchLink换成httpLink即可:
// src/trpc/react.ts import { httpLink } from "@trpc/client"; links: [ httpLink({ url: "/api/trpc", }), ]这样的代价是每个请求独立发出,但 t3code 每个页面最多也就一两个 tRPC 调用,完全够用。
5. 回看 t3code:几个最值得说的经验细节
项目做到能上线,我再回头看,发现最有价值的收获其实不在代码本身,而在下面几个转向:
第一,tRPC 会逼着你从数据模型开始想问题。以前写前后端分离项目,我总是先画页面再补接口,接口参数经常改到后面一团乱。t3code 因为类型是端到端联通的,我被迫先把Snippet模型定义清楚,前后端所有操作都围绕这个模型展开,改动任何一个字段,编译器立刻把没改到的地方标出来。这种“被编译器追着改”的体验,其实就是类型安全体系的真实价值。
第二,T3 Stack 适合做小规模全栈验证,但不是所有全栈场景的最佳解。如果未来 t3code 要开放 API 给外部开发者,或者要做复杂的权限体系、多租户隔离,我大概率会把它重构为 REST 加 OpenAPI,或者引入 GraphQL。类型安全是利器,也是约束,认清边界才能选对工具。
第三,部署环节才是小项目最容易翻车的地方。开发时跑得飞快的 SQLite 一上 serverless 就哑火,本地正常的迁移文件换个数据库方言就报错,这些坑都不是框架教程里会提前告诉你的。我的实际建议是:第一天初始化项目时就选择一个生产级数据库,别把“本地环境”和“生产环境”拖到最后一刻再对齐。
最后再分享一个我没想到的收益:t3code 做好以后,我团队内部真的开始用这个链接来互相分享代码,替代了过去在聊天工具里贴文本的原始方式。一个两三天写出来的小工具能真实改善日常协作,这比技术栈本身带来的满足感更强。后续如果要继续扩展,我可能会加上按语言筛选的列表页、设置片段过期时间,以及一个简单的访问统计接口,方向很多,但都不急,先把现版本用顺手再说。