t3code 这个项目名字第一次出现在我的笔记里时,还只是个随手起的代号——t 取自 TypeScript,后面的 3 代表当时参与设计的三个人,code 就更直白了:代码片段。但做着做着,它最终演变成一个完整的全栈应用:一个基于 T3 技术栈的在线代码片段管理器。你可以把它理解成私人版的“代码收藏夹”,支持按语言分类、打标签、全文搜索,也允许把片段一键设为公开后分享给团队。适合刚接触 T3 全栈的同学拿来当实战项目练手,也适合想在公司内部搭一个轻量知识库的开发者直接参考。
1. 项目定位与技术选型背后的思考
1.1 t3code 到底是用来干什么的
先说清楚,t3code 本质上解决的是代码碎片沉淀问题。日常工作中,大家总会遇到这种情况:一个正则表达式查了三次才写对,一段 PostgreSQL 的递归查询调试了两个小时,一个组件的 scroll 监听写法特别典型,但下次要用时,这些内容散落在本地临时文件、聊天记录和浏览器书签里,检索成本高得吓人。我最初想做 t3code,就是希望能有一个统一的地方,把这些“断断续续总结出来的代码经验”好好存下来。
它不是一个重量级项目管理工具,也不是类似于代码托管平台的复杂产品,定位非常收敛:一个面向个人和小团队的知识型工具。页面数量也不多,一个列表页、一个详情页、一个新建/编辑页,再加一套登录与权限逻辑。功能看起来单薄,但正好覆盖了 Next.js、TypeScript、Tailwind CSS、tRPC 和 Prisma 的全链路,每一个环节都能被真实业务场景驱动起来。这个选择很关键,因为项目如果功能太简单,技术栈会显得“杀鸡用牛刀”;如果功能太复杂,又容易把篇幅浪费在业务逻辑上,反而冲淡了技术演示的价值。
至于为什么叫 t3code,除了“三个人”之外,还因为底层采用的就是 T3 技术栈。T3 这个名字本来就是 Next.js、TypeScript、Tailwind CSS、tRPC 的组合缩写,社区里也习惯用它来代指这套全栈方案。团队内部一旦给项目赋予了明确语义,后面无论写路由、建数据库模型,还是开 git branch,都可以围绕这个名字展开,沟通成本也低了不少。
1.2 为什么选 T3 技术栈,而不是其他组合
有人会问,一个代码片段管理工具,用熟一点的方案不是更快吗?比如 Spring Boot 写后端、Vue 写前端,或者干脆点用 Next.js 的 API Routes 配前端页面,为什么非要引入 tRPC 和 Prisma 这一整套东西?
我对比过几种实际方案,列个表更直观:
| 方案组合 | 优点 | 痛点 |
|---|---|---|
| T3:Next.js + tRPC + Prisma + Tailwind | 类型自动贯穿前后端,开发效率高,部署成本低 | 有学习曲线,需要接受不少框架约定 |
| Next.js + REST API + 独立前端仓库 | 前后端完全解耦,适合多端接入 | 要维护 DTO、接口文档,跨仓库改类型非常痛苦 |
| Vue3 + Express + MongoDB | 轻量、熟悉、上手快 | 类型安全靠额外方案补充,前后端契约容易断层 |
| Java Spring Boot + React | 团队已有 Java 基础时可复用 | 基建太重,简单场景反而被大量配置拖慢 |
我最终选择 T3,核心原因就是“端到端类型安全”。这句话听起来很高深,实际体验却非常直白:前端接口调用函数时,入参、出参都是 TypeScript 类型推导出来的,后端改了一个字段,前端编译期就会立刻报错,而不是等接口联调时才发现。过去前后端对接最耗时的部分就是“对着文档核对字段名”,T3 把这一层省掉了。写入一次 zod schema,类型就同时成为后端校验规则和前端类型来源,这种“单一事实来源”对中小团队帮助特别大。
但也要泼一盆冷水:T3 不等于银弹。它更适合中小型业务、原型快速验证和单团队统一技术栈的场景。如果业务并发极高、团队规模很大,或者有移动端、第三方开放平台等着接入,tRPC 这类 RPC 风格接口反而会限制多协议扩展性。取舍的时候一定要想清楚边界,不要因为“技术新”就盲目套用。
1.3 它解决了哪些真实痛点
第一是重复定义类型的问题。以前前后端各维护一套字段定义,后端 model 改了,前端 interface 可能还停留在旧版本,甚至要等联调跑一遍才知道。t3code 中,zod schema 定义一次,业务 Router 就把它作为 input 类型,前端 call 接口时可以直接获得类型提示,不再需要手动维护 DTO。
第二是接口文档滞后问题。tRPC 的 Router 本身就是可读的接口描述,IDE 里点开函数签名就能看到入参出参。对于三五个人的开发小组,与其维护 Confluence 上的接口文档,不如让代码成为活的文档。
第三是请求层样板代码太多的问题。直接用 fetch/axios 时,要处理 loading、错误、取消、缓存,稍微复杂一点还要自己封装 hooks。tRPC 配合 React Query 的封装,使用useQuery、useMutation就能把请求状态管理起来,减少了大量重复的请求处理代码。这个体感在写业务页面时特别明显。
2. 核心细节解析与实操要点
2.1 数据模型:别小看一个片段背后的关系
数据模型是整个项目的底座,我没想得太复杂,但也没有真的只建一张表。t3code 里一共三个核心模型:User、Snip、Tag。User 由 NextAuth 的标准模型扩展而来,Snip 是代码片段本体,Tag 则承担分类聚合的责任。
model Snip { id String @id @default(cuid()) title String content String @db.Text language String @default("plaintext") isPublic Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }这里有几个设计细节值得拿出来说。主键用cuid()而不是自增数字,核心原因有两个:一是避免片段 ID 被顺序遍历,二是多环境同步数据时(比如本地开发导入导出),字符串 ID 冲突概率远低于自增数字。我见过不少项目为了图方便用自增 id,最后做数据合并时踩坑,所以从一开始就把这个成本规避掉。
content 字段我特意标注了@db.Text。Prisma 默认的字符串在很多数据库上会映射成varchar(191),长度一超就报错。代码片段这种东西,短的几行,长的上百行,误用默认类型很容易在写入长 SQL 时莫名失败,这也是新手最容易忽略的点。
Tag 的设计用了多对多关系。有人会问,为什么不直接在 Snip 上存一个字符串数组?查询时用array_contains不也方便吗?我的理由是:关系模型能够让标签维度的统计和筛选更干净。一个标签页想要展示“这个标签下有多少片段”,一条groupBy就能完成。如果存字符串数组,这类聚合查询会变成全表扫描,维护成本和查询成本都更高。至于 Prisma 自动生成的中间表_SnipToTag,在没有额外字段需求的情况下完全够用,不需要手动建模。
2.2 tRPC 路由:把业务逻辑收敛成树状结构
t3code 的业务逻辑没有散落在页面里,而是统一收敛到src/server/api/routers/snip.ts,再注册进 rootRouter。这样整个应用的第一入口看起来就是一颗清晰的调用树,前端无论从哪个页面发起请求,最终都会落回这棵树里。
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; const createSnipSchema = z.object({ title: z.string().min(1).max(80), content: z.string().min(1), language: z.enum(["typescript", "sql", "bash", "python", "plaintext"]), tags: z.array(z.string()).max(10).optional(), isPublic: z.boolean(), }); export const snipRouter = createTRPCRouter({ create: protectedProcedure .input(createSnipSchema) .mutation(async ({ ctx, input }) => { const snip = await ctx.db.snip.create({ data: { title: input.title, content: input.content, language: input.language, isPublic: input.isPublic, authorId: ctx.session.user.id, tags: input.tags?.length ? { connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })) } : undefined, }, }); return snip; }), });我重点讲两个容易被忽略的点。第一个是protectedProcedure。这个中间件的作用是:如果用户未登录,直接抛UNAUTHORIZED,前端 tRPC 客户端会自动拿到一个可读的错误对象。比自己在每个接口里写if (!ctx.session) throw ...要干净得多。第二个是connectOrCreate。创建片段时同时传入标签名数组,Prisma 会先尝试连接同名 Tag,不存在就自动创建,省掉了一次先查再拼装的代码,这个写法在知识点聚合类需求里非常常用。
zod 在这里不仅是类型声明,更是数据守门员。title 长度、content 是否为空、language 的可选值,都在 schema 层就提前限定。后端不再需要一堆 if 判断往下走,脏数据进不到数据库,前端拿到错误提示时也能精确到字段。对于 t3code 这种以内容提交为核心的应用,输入校验的收益特别明显。
2.3 认证与会话:T3 默认踩坑最少的路
认证方案我直接选了 NextAuth(现在也叫 Auth.js),配合 Prisma Adapter。create-t3-app 初始化时勾选 NextAuth,会同步生成一套标准的 Session/Account/User 模型,省去了自己设计用户表的步骤。登录方式选择了 GitHub OAuth,是因为团队本来就都在用 GitHub,免密流程成熟,也不需要再单独维护一套账号密码体系。
生产环境配置需要关注两个关键环境变量:
DATABASE_URL=postgresql://user:pass@localhost:5432/t3code NEXTAUTH_SECRET=你的随机密钥 NEXTAUTH_URL=https://t3code.example.comNEXTAUTH_SECRET是用来给 session cookie 签名的密钥,一定不要硬编码到代码里,生成方式可以用openssl rand -base64 32。我见到不少人本地跑得好好的,部署上去却一直跳回登录页,最后发现是没配NEXTAUTH_SECRET。还有一点容易忽略:NEXTAUTH_URL在 Vercel 部署时可以直接省略,但在自托管时一定要填,否则 OAuth 回调地址会是空值。
权限控制不能只依赖登录中间件。私有片段的功能意味着:即使我登录了,也不能看别人的私有片段。所以在 tRPC 的查询里,必须叠加数据范围条件。比如获取列表时,where条件里必须带上authorId: ctx.session.user.id,而不是只查isPublic: true。tRPC 本身只是一个传输层,它不是权限层,真正的权限校验还是要写在业务查询里,这是很多人容易忽略的安全底线。
2.4 前端使用与状态管理:tRPC 不是万能胶
t3code 的页面交互不算复杂,但有一个问题相当典型:如何组织服务端状态和本地 UI 状态。我的结论是,中等规模页面尽量减少全局状态库,把服务端状态完全交给 tRPC,客户端瞬时状态只用useState就够了。
"use client"; import { api } from "~/trpc/react"; const utils = api.useUtils(); const { data: snips, isLoading } = api.snip.list.useQuery({ page: 1 }); const createSnip = api.snip.create.useMutation({ onSuccess: async () => { await utils.snip.list.invalidate(); }, });新手最容易踩的坑是:调用useMutation创建数据后,发现列表并没有自动更新。原因在于 tRPC 底层使用 React Query 做缓存,缓存 key 由路径和参数组成,新建片段后缓存的snip.list还是旧数据,必须主动invalidate让它失效重取。这个模式我用多了之后,已经养成了习惯:写 mutation 的onSuccess时,第一时间考虑需要刷新哪些 query。
还有一个细节是 React Server Components 和客户端组件之间的边界。不要在 Server Component 里直接调用useQuery,tRPC 的 hooks 只能在客户端组件中使用。我通常把页面外壳保持为服务端渲染,把搜索框、标签选择器这类交互组件单独拆出来标记"use client",这样既保留了 SSR 的性能收益,又能把交互逻辑隔离干净。
3. 实操过程与核心环节实现
3.1 初始化项目:create-t3-app 一分钟起盘
初始化项目没有用手写配置,直接使用官方脚手架,五分钟就能得到一个能跑的全栈骨架。
npx create-t3-app@latest t3code交互式选项里,我把 TypeScript、NextAuth、Prisma、Tailwind CSS、tRPC 全部勾上,ESLint 和 Prettier 也建议保留。生成后的目录结构其实不多,真正需要关心的核心文件只有这几个:
src/server/db.ts # Prisma Client 实例 src/server/api/root.ts # 汇总所有子 Router src/server/api/trpc.ts # createTRPCContext、createTRPCRouter 等基础工厂 src/trpc/server.ts # 服务端调用 tRPC 的封装 src/trpc/react.ts # 客户端 hooks 的封装先跑一遍npm run dev,确认默认的示例页面正常加载,然后再删掉 demo 代码。我个人的习惯是先用脚手架默认的示例把链路跑通,再开始改造;直接上来就删删除删,很容易漏掉 Provider、Router 注册等串联点,排查问题时反而更麻烦。Node 版本也注意一下,建议 18.17 及以上,太老的版本会遇到依赖安装失败或启动报错的问题。
3.2 设计数据库表与执行迁移
在设计阶段先写好 Prisma schema,然后执行迁移生成表结构。整个过程非常线性:改 schema、写迁移、启动数据库、校验数据。
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] snips Snip[] sessions Session[] } model Tag { id String @id @default(cuid()) name String @unique snips Snip[] }npx prisma migrate dev --name initmigrate dev很适合开发环境:自动对比 schema 变化,生成 SQL 迁移文件,执行后还会重新生成 Prisma Client。部署到生产时记得改用npx prisma migrate deploy,避免在服务器上意外触发交互式迁移流程。
数据验证用npx prisma studio,它会打开一个本地可视化界面,直接查看和编辑表数据。我通常用它来确认迁移后字段是否如预期,尤其是外键关系是否建立成功。数据库连接串如果密码里带特殊字符,比如@、#,需要使用 URL 编码,否则连接会一直失败而报错信息可能非常隐晦。
3.3 业务核心:片段的增删改查与公开私有
创建接口在上文已经给出,这里重点说查询列表。列表页如果只做“全量返回”,开发期没问题,但数据一到几百条就会发现接口响应越来越大、页面越来越卡。所以我用游标分页来做列表。
list: protectedProcedure .input( z.object({ cursor: z.string().nullish(), take: z.number().max(50).default(10), }) ) .query(async ({ ctx, input }) => { const items = await ctx.db.snip.findMany({ where: { authorId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, take: input.take + 1, cursor: input.cursor ? { id: input.cursor } : undefined, include: { tags: { select: { name: true } } }, }); let nextCursor: typeof input.cursor | undefined; if (items.length > input.take) { const extra = items.pop(); nextCursor = extra?.id; } return { items, nextCursor }; });这段代码解决的是一个特别具体的分页问题:游标分页需要知道“是否还有下一页”。实现办法是查询时多取一条,take + 1,如果结果数大于请求数,说明还有更多数据,把多出来的那条作为 nextCursor 返回,同时用pop()把它从本次结果里移除。这个“多取一判一”的模式比什么都简单,也不会因为中间有新数据插入而漏行,稳定性比传统 offset 分页高不少。
更新和删除接口需要注意越权问题。最稳妥的做法是直接在where条件里加上authorId,例如updateMany({ where: { id: input.id, authorId: ctx.session.user.id }, data: ... })。这样即使有人恶意传了别人的片段 ID,数据库也查不到匹配记录。返回的记录数如果为 0,说明要么 ID 不存在,要么不是本人的内容,直接抛NOT_FOUND即可。
3.4 搜索与标签过滤:先跑通再说模糊搜索
列表页的“搜索”功能可能名不副实,先别指望上 Elasticsearch。t3code 的第一步实现很朴素:针对 title 和 language 字段做过滤,标签采用关系表的部分匹配。Prisma 的 where 拼装可以用下边这种形式。
const where: Prisma.SnipWhereInput = { authorId: ctx.session.user.id, ...(q ? { title: { contains: q } } : {}), ...(language ? { language } : {}), ...(tagName ? { tags: { some: { name: tagName } } } : {}), };contains在 PostgreSQL 上会映射成 ILIKE,默认大小写不敏感,对英文搜索比较友好。但这只是起步版本,它不会利用普通索引,数据量小的时候没啥感觉,到几千条以上就能观察到明显的延迟。如果要继续深入,可以考虑 PostgreSQL 的pg_trgm扩展或者引入全文检索。我在项目里的取舍是:先把功能跑通,明确知道边界在哪儿,等实际数据量到了阈值再升级,不做提前过度设计。
3.5 部署与运行配置
本地开发数据库我直接用 Docker 起了一个 PostgreSQL:
docker run --name t3code-postgres \ -e POSTGRES_PASSWORD=localpass \ -e POSTGRES_DB=t3code \ -p 5432:5432 \ -d postgres:16部署到 Vercel 时,Prisma Client 的生成时序是关键。Vercel 的 build 阶段会自动执行prisma generate吗?不一定。建议在package.json的 postinstall 或 build 脚本里显式加一步:
"build": "prisma generate && next build"如果跳过这步,构建过程极容易报“PrismaClientInitializationError”或者找不到 generated types。另一个常见问题是运行时长限制,Vercel Serverless Function 默认执行时间有限,所以代码里一定别写长同步任务,t3code 这种简单 CRUD 完全没问题。
如果自托管,则需要把 Next.js 输出模式改一下,在next.config.js里配置:
module.exports = { output: "standalone" };然后npm run build,产物会生成自包含的 Node.js 服务,直接node .next/standalone/server.js就能跑起来。要注意生产环境下 cookie 的 Secure 属性,HTTPS 是必须的,否则 session 在部分浏览器下无法写入,用 nginx 反代时一定把X-Forwarded-Proto报文头设置好。
4. 常见问题与排查技巧实录
4.1 tRPC 类型不同步到了前端
这个问题出现率极高。现象是:后端改了返回字段,前端调用处仍然显示旧类型,甚至直接报TS2339: Property 'xxx' does not exist。本质是 TypeScript 的增量编译缓存没刷新。解决流程按顺序尝试:
- 重启
next dev开发服务器; - 清空
.next目录和node_modules/.cache; - 退到底检查 TypeScript 版本是否一致。
我多次遇到这类问题,都是“改后端、切前端、发现类型没变”,冷静一清理缓存就恢复。遇到这种问题不要急着改业务代码,先怀疑缓存。
4.2 Prisma 报 “Environment variable not found: DATABASE_URL”
看起来是环境变量配置问题,实际原因通常有三类。第一,.env文件不在根目录。create-t3-app 默认加载根目录.env,如果你把它放到了src或者prisma目录,Prisma CLI 是读不到的。第二,部署环境中没有配置对应的环境变量,本地跑得好好的,一部署就报错,绝大多数是少了这一步。第三,DATABASE_URL里的密码或者主机名写错了,比如生产数据库要求 SSL,而连接串漏了?sslmode=require。
排查时可以先在项目根目录执行npx prisma generate,如果它能成功,说明环境变量加载没问题;如果报同样的错误,就直接检查文件路径和变量名拼写。
4.3 Hydration 不匹配:页面渲染出的内容对不上
这个问题的典型场景是主题切换。比如根据用户本地存储的偏好,在首次渲染时就显示深色模式,但服务端渲染的 HTML 里永远是浅色,于是 React 在客户端 hydration 时就会报警告:“Text content does not match server-rendered HTML”。
根治手段是把依赖浏览器环境的判断放进useEffect里执行,这样首次服务端渲染和客户端渲染的结果保持一致,等到组件挂载后再更新 UI。对于纯展示型、无交互逻辑的小组件,也可以考虑suppressHydrationWarning,但这属于治标不治本,不要大量使用。
4.4 请求 404 或 500
404 大概率是路由没有串联。检查app/api/trpc/[trpc]/route.ts是否存在,rootRouter 里是否注册了新写的 router。500 则需要看服务端日志,tRPC 的错误堆栈默认不会直接展示给浏览器,建议在createTRPCContext里包一层全局 error handler,把error.message和error.stack打印到控制台。
排查接口问题时,我习惯于先绕过浏览器,直接用 curl 发一个 POST 请求:
curl -X POST \ "http://localhost:3000/api/trpc/snip.list?batch=1" \ -H "content-type: application/json" \ -d '{"0":{"json":null}}'这样可以快速区分问题出在前端调用还是后端处理。如果 curl 返回了正常数据,那就是前端 hooks 使用方式有问题;如果返回错误,就直接根据响应体定位服务端逻辑。
4.5 Tailwind 动态 class 不生效
Tailwind 的 JIT 编译只会扫描源码中出现的完整类名字符串。如果写成className={`bg-${color}-500`},生产构建时这个类名会被 purge,页面渲染出来就是一个没有背景色的元素,而且本地开发可能一切正常,打包后才发现。
标准解法是把类名维护成完整的映射表,而不是动态拼接:
const colorClassMap: Record<string, string> = { red: "bg-red-500", blue: "bg-blue-500", };然后在组件里通过colorClassMap[color]取值。类名在源码中以完整字符串存在,Tailwind 就能扫描到并保留下来。用 safelist 也能解决,但能不用就不用,保持源码整洁更重要。
5. 经验精华与扩展方向
5.1 类型安全的价值要在维护期才能感受到
写第一版时,zod schema、Prisma model、tRPC 类型确实会让人觉得“多了一层”,但真正到了第三周、第四周回头改需求时,收益一下子就出来了。改数据结构后,所有调用方在编译期亮红灯,哪些页面受影响一目了然,不用靠记忆和全局搜索。t3code 开发到后期,我几乎把“改数据”这件事完全交给类型系统守护,心里踏实很多。
还有一个体会是:把输入校验尽量放在 zod schema 里面,不要堆在业务代码中做防御式 if。后者会让业务逻辑被分支淹没,前者则让约束跟接口定义放在一起,天然可维护。
5.2 我最后悔的三件事
第一件事是一开始就加了团队分享功能,开发优先级错了。对 MVP 来说,应该先把私有片段存、取、搜这个核心闭环做好,分享功能后面再加,完全不影响主体架构。第二件事是低估了代码高亮的复杂度。自己封装代码编辑区和高亮逻辑花了一周,最后换成成熟方案半天就结束,浪费得最不值。如果再做类似项目,会把第三方库的选型决策前置。第三件事是没第一时间做内容全文检索。后期想补时发现直接用contains扫Text字段非常慢,才认真考虑倒排索引。小项目可能无所谓,但提前把搜索边界想清楚,能省一大轮返工。
5.3 可以扩展的方向
t3code 做完整条主链路后,还可以衍生出不少实用功能:
- 接入 AI 自动打标签:把片段发送给模型,返回建议标签,减少人工整理成本。
- 做浏览器扩展:选中网页里的代码,右键一键保存到 t3code,把收集成本降到最低。
- 与 Git Hooks 联动:在 commit 或 push 时自动提取项目内新增的典型代码片段,沉淀团队公共知识。
- 升级全文检索:Postgres 的
tsvector可以支持中文分词,或者引入 Meilisearch 这类轻量检索引擎,把搜索做到毫秒级。
扩展方向有很多,但不一定每个都要做,按自己的实际需要选一两个就好。持续做一个小项目,比一次想做大而全的系统更容易坚持。
做到这里,t3code 已经从当初随手起的代号,变成了我日常离不开的工具。我现在几乎每天都会往里存几个片段,周末顺手整理一遍标签。最后分享一个个人调试习惯:项目里所有对外暴露的接口,一定先把 zod schema 写完整再写 handler;后端少点防御式 if,让校验层挡住脏数据,代码会干净很多。项目本身不需要多大,把核心链路跑通,再一点点加功能,这样的工程生命周期往往比一开始想象的要长得多。