T3 Stack实战:构建类型安全的代码片段管理工具与Serverless部署

发布时间:2026/10/28 6:11:50
T3 Stack实战:构建类型安全的代码片段管理工具与Serverless部署

我最近在维护一个叫 t3code 的小项目,最初真的只是图自己查代码方便:写过的订单状态机、文件上传分片、SSE 重连逻辑,散落在三四个仓库、几百个 Markdown 笔记里,还有不少躺在聊天记录中。等下次要用的时候,翻聊天记录比重新写一遍还痛苦。后来我干脆用 T3 Stack 把它做成一个代码片段管理工具,后端、前端、鉴权、数据库全走一套 TypeScript 类型系统,从录入到检索再到分享形成闭环。t3code 这个名字没啥花哨含义,技术栈是 T3,业务是做 code snippet 的管理,合在一起就是它。

如果你也在做类似的全栈工具,或者想找一个能真正练手 T3 Stack 的场景,这篇文章比较适合你。我会把项目从立项到上线的完整思路写清楚,包括技术选型为什么这么定、数据库模型怎么设计、tRPC 接口怎么组织,以及部署到 Serverless 环境后踩过的几个坑。项目本身不大,但覆盖的点很全,很多问题都是真实跑过之后才发现的。

1. 为什么做 t3code:团队里缺的不是代码,是能检索到的代码

1.1 需求的起点:片段散落在飞书、IDE、聊天记录里

先说背景。我所在的小组经常处理相似的业务逻辑,比如根据用户等级计算折扣、导出大 Excel 时控制内存峰值、定时任务里做幂等去重。这些逻辑每个项目都略有不同,但核心思路一致。按道理应该沉淀成公共代码片段,但实际情况是:

  • 老项目的代码还在仓库里,但没人记得具体是哪个文件;
  • 有人把片段贴到飞书文档里,文档权限混乱,没做过全文索引;
  • 个人 IDE 的 snippet 只能自己用,换个电脑就没了;
  • 偶尔有人分享 Gist,但 Gist 默认是公开的,稍微带点内部逻辑就没人敢发。

所以我希望 t3code 能解决几个很具体的问题:录入一个片段后,一秒钟之内能按语言、标签、关键词定位到它;片段可以标记为私有,也可以生成分享链接给同事;每个片段附带简述和使用注意点,这样半年后看也不会一脸懵。

做产品的人会立刻想到:这不就是又一个 Gist 吗?对,也不对。Gist 更适合公开小代码,团队内部用有两个硬伤:一是没有“代码片段库”的目录感,二是搜索能力很弱。我要做的是面向团队内部少量人使用的私有工具,核心不是“发出去”,而是“存下来、找得到”。需求定位清楚后,项目边界也顺势定了:暂不做代码运行评测、不做多端离线同步、不做复杂的权限体系,先把“增删改查 + 全文检索 + 分享”这条主链路打穿。

1.2 为什么项目要叫 t3code

T3 Stack 这个组合在 TypeScript 圈子里不算陌生:Next.js 做应用框架,tRPC 做端到端类型安全的 API 层,Tailwind 管样式,Prisma 管数据库,NextAuth 做鉴权,底层全部由 TypeScript 串起来。因为 t3code 本质上服务于“代码”,而且所有数据都是纯文本,非常适合用这套技术栈快速验证。

在正式动手之前,我也考虑过其他方案:

  • 纯 Next.js + REST API:需要在前后端各写一套类型定义,接口一多很容易两边不同步;
  • Next.js + GraphQL:类型安全很爽,但 GraphQL 的学习成本和服务器复杂度对这个体量的项目偏高;
  • 用现成的开源服务(如 Outline、Docmost):功能确实强,但定制成本不低,而且很多都假设你有自建基础设施的前提。

t3code 选择 T3 Stack 的核心逻辑其实只有一句话:让类型从数据库一路流到 UI,中间不需要我手动维护任何 API 契约。这个“不手动维护”在几十个片段的时候感受不明显,一旦接口数量膨胀到几十上百个,优势非常明显。后面我会具体讲 tRPC 是怎么做到这一点。

2. t3code 的核心选型:T3 Stack 五件套怎么搭才不浪费

2.1 用 create-t3-app 一分钟初始化项目

T3 Stack 的官方脚手架是 create-t3-app,它和别的脚手架最大区别是会让你交互式勾选组件,而不是一股脑全装。我初始化 t3code 的命令是:

pnpm dlx create-t3-app@latest t3code

接下来选择时,我勾了 Next.js App Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth.js、ESLint 和 Prettier。这里有两个选择值得说明:

第一,App Router 是 Next.js 近两年的主线方向,虽然服务端组件和客户端组件的边界需要一点时间适应,但新项目没有历史包袱,直接用是最省的。

第二,数据库层面我建议先用 SQLite 起步。t3code 在开发阶段数据量很小,SQLite 不需要额外起服务,file:./db.sqlite就能跑。等需要部署到 Serverless 环境时再切换到 PostgreSQL,这个切换我在后面部署章节详细说。

脚手架初始化完以后,目录结构大概是这样的:

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ └── routers/ │ │ └── db.ts │ └── trpc/ │ ├── server.ts │ └── react.tsx ├── .env ├── package.json └── next.config.js

create-t3-app 默认已经帮你配好了 Prisma Client 单例(在src/server/db.ts)、tRPC 的 server 和 react 两套入口、NextAuth 的初始化骨架。所以第一分钟你不需要理解所有细节,只需要知道每个文件是干什么的,然后往里填业务即可。

2.2 五件套各自的职责与取舍

很多初学者会问:tRPC 和 REST、GraphQL 到底差在哪?用一句话概括:REST 是“接口即约定”,GraphQL 是“查询即约定”,tRPC 是“函数即约定”。你不需要写 OpenAPI 文档,不需要手写请求类型,前端拿到的就是和后端定义一致的 TypeScript 类型。

我整理了 t3code 用到的五个核心组件,以及它们各自承担的职责:

组件在 t3code 里的角色我为什么选它
Next.js App Router页面结构、路由、服务端渲染一个框架同时覆盖前端和后端接口,部署模型简单
TypeScript全链路类型检查数据库返回类型可以一直穿透到组件,减少低级错误
tRPC前后端 API 层无需维护接口文档,新增查询就自动暴露给前端
Prisma数据库访问层schema 即文档,迁移和改表都很直观
NextAuth登录与会话和 tRPC context 融合度高,Google/GitHub 登录开箱即用
Tailwind CSSUI 样式写小工具类,不需要切到 css 文件找样式上下文

这里我想特别强调一下 tRPC 的一个使用原则:不要为了 tRPC 把所有逻辑都塞到 API 层。在 t3code 里,list、create、update、delete这些需要持久化的操作走 tRPC mutation/query 没问题;但像“把 Markdown 转成 HTML”“高亮代码”这类纯计算,应该放在服务端渲染阶段或者独立工具函数里,不要让客户端每次都调一次接口。这样能让 tRPC 接口保持精简,也不容易产生额外网络请求。

2.3 环境准备里最容易被忽略的版本问题

如果你打算照着复现 t3code,环境版本最好先固定一批:

Node.js 20 LTS 及以上 pnpm 8 及以上 Prisma 6.x Next.js 14.2 及以上 TypeScript 5.x

最容易翻车的是两个地方。一个是pnpm版本和 Next.js 的依赖解析方式,偶发会出现.pnpm目录下包路径不对的问题,最常见的解决办法是rm -rf node_modules && pnpm install重新装一遍,不要盲目升级大版本。另一个是 Tailwind CSS 和 PostCSS 的兼容,如果你拉到的脚手架版本比较旧,注意看tailwind.config.ts里的content是否把src/**/*.tsx和src/**/*.ts都包含进去,漏掉某一个目录很可能导致样式时有时无。

环境变量方面,create-t3-app 会生成一份.env.example,我复制成.env后做了这样几项配置:

# 开发数据库 DATABASE_URL="file:./db.sqlite" # NextAuth 相关,本地随便填个足够长的随机字符串 NEXTAUTH_SECRET="local-dev-secret-change-me" NEXTAUTH_URL="http://localhost:3000"

这两行看起来简单,但它们决定了后续很多坑的方向:NEXTAUTH_SECRET 不一致会导致生产环境登录会话失效,DATABASE_URL 不区分开发和生产会导致误操作到真实数据库。建议从开发第一天就把环境变量按环境切分,不要图省事只用一个。

3. 数据库模型与 tRPC 接口:端到端类型安全的完整落地过程

3.1 schema.prisma 设计:片段、标签、用户的关系

t3code 的业务对象其实不复杂:用户、代码片段、标签、收藏夹。但关系设计上我做了几个刻意选择,写出来给你参考。

model User { id String @id @default(cuid()) name String? email String? @unique snippets Snippet[] collections Collection[] } model Snippet { id String @id @default(cuid()) title String content String @db.Text language String description String? isPublic Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) tags Tag[] collections Collection[] } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] } model Collection { id String @id @default(cuid()) name String userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) snippets Snippet[] }

几个设计点解释一下:

  • 主键我没有用自增整数,而是用cuid()。原因有两个:一是避免通过 ID 猜测系统里有多少条片段,二是未来要扩展多节点写入时,cuid 冲突概率远低于自增 ID;
  • Snippet的isPublic默认是false,也就是说默认所有片段都只有自己可见,分享是主动行为。这个设计保证了“贴代码”这个行为的心理门槛足够低,不会因为怕泄露而干脆不记;
  • Tag的 name 设置成@unique,新建片段时如果是已有标签就直接关联,不重复建标签;
  • Collection相当于用户自建的文件夹,和Tag的区别在于它有明确的用户归属,只有自己能增删里面放了哪些片段。

写完 schema 后,执行:

npx prisma migrate dev --name init npx prisma generate

migrate dev会生成迁移文件并同步更新数据库,generate会生成 TypeScript 客户端类型。这一步做完,你就能在编辑器里体会到 Prisma 类型提示的好处——比如写错了字段名,保存的瞬间就会标红。

3.2 tRPC Router 的代码骨架

t3code 的接口层主要分成两部分:一部分处理片段列表和详情,一部分处理片段的新增、修改、删除。以新增片段为例,我的snippetrouter 是这样的:

// src/server/api/routers/snippet.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure, } from "~/server/api/trpc"; const createSnippetSchema = z.object({ title: z.string().min(1, "标题不能为空"), content: z.string().min(1, "代码内容不能为空"), language: z.string().min(1), description: z.string().optional(), tagNames: z.array(z.string()).default([]), isPublic: z.boolean().default(false), }); export const snippetRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ language: z.string().optional(), tag: z.string().optional(), keyword: z.string().optional(), }) ) .query(async ({ ctx }) => { return ctx.db.snippet.findMany({ where: { OR: [{ isPublic: true }, { authorId: ctx.session?.user?.id }], }, include: { tags: true, author: { select: { name: true } }, }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(createSnippetSchema) .mutation(async ({ ctx, input }) => { const tagNames = [...new Set(input.tagNames.map((t) => t.trim()))]; const tagConnections = tagNames.map((name) => ({ name })); return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, description: input.description, isPublic: input.isPublic, authorId: ctx.session.user.id, tags: { connectOrCreate: tagConnections.map((tag) => ({ where: { name: tag.name }, create: { name: tag.name }, })), }, }, }); }), });

这里最有价值的部分是connectOrCreate。它做的事是:如果标签已存在就直接关联,如果不存在就顺便创建一条。这比“先查标签再创建”少了一次网络往返,而且代码更简洁。

再解释一下publicProcedure和protectedProcedure的差别:publicProcedure允许未登录用户访问列表里公开的片段;protectedProcedure要求必须有登录会话,在中间件里会校验ctx.session,拿不到就抛UNAUTHORIZED。t3code 里所有写操作都应该是登录后行为,所以统一用protectedProcedure。

3.3 前端组件怎么调用 tRPC

t3code 的前端采用“列表页用服务端组件,交互区用客户端组件”的混合模式。这样做的原因是:列表数据对 SEO 要求不高,但首屏希望尽快看到内容;而新增、编辑这种交互必须由客户端触发。

在客户端组件里调用 tRPC 的写法是:

"use client"; import { api } from "~/trpc/react"; export function SnippetList() { const [list] = api.snippet.list.useSuspenseQuery({}); return ( <ul> {list.map((item) => ( <li key={item.id}> <code>{item.language}</code> <p>{item.title}</p> </li> ))} </ul> ); }

如果你使用useSuspenseQuery,需要配合Suspense边界使用,否则页面会一直转圈。如果不想用 Suspense,也可以换成useQuery,但就要自己处理 loading 和 error 状态。

新增片段之后,想让列表刷新,不必去 reload 页面,tRPC 提供了缓存失效机制:

const utils = api.useUtils(); await api.snippet.create.mutateAsync({ title: form.title, content: form.content, language: form.language, tagNames: form.tags, }); utils.snippet.list.invalidate();

invalidate会把 tRPC 内部缓存的list查询标记为过期,下次渲染时自动重新请求。这里有个常见误解:invalidate是异步的,但你不一定要await它。如果不关心列表是否立刻刷新,就让它自己跑;如果你希望列表马上是最新,建议await utils.snippet.list.invalidate(),等它会话再给用户反馈。

4. 真实踩坑复盘:连接池、缓存与中间件的三连击

4.1 坑一:开发环境一切正常,部署后偶发 500

t3code 在本地跑了一个多月都没问题,Schema 改过五六次,数据也建了不少。结果一部署到 Vercel,隔三差五就报PrismaClientInitializationError,错误信息大致是:

PrismaClientInitializationError: P1001: Can't reach database server at ...

第一次看到这个报错,我以为是连接串写错了,反复检查了十几遍都没问题。后来才意识到问题不在连接串,而在 Serverless 的实例生命周期:Vercel 的每个请求可能由不同的运行实例处理,如果每次实例启动都创建新的 PrismaClient,短时间内会打爆数据库连接数。本地开发因为是一个长驻进程,所以永远触发不了这个问题。

标准解法是让 PrismaClient 复用同一个实例。create-t3-app 已经预设了这么一段代码,但你得确认它真的生效了:

// src/server/db.ts import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = db; }

这段代码的思路很简单:开发环境热更新可能会导致模块重复加载,所以把实例挂到globalThis上;生产环境虽然不挂,但每个 Serverless 实例内部还是会尽量复用。如果走到这里还偶发 500,那就不是单例问题,而是数据库连接池容量问题。我最终换成了 PostgreSQL 的池化连接参数:

DATABASE_URL="postgresql://user:password@host:5432/t3code?pgbouncer=true"

加上pgbouncer=true后,Prisma 会知道你的连接走的是 PgBouncer,从而限制连接存活时间,避免连接耗尽。

4.2 坑二:tRPC 查询结果像是被“缓存”了

上线后有个现象很奇怪:新增一个片段,列表不更新;刷新页面也不一定有,要过几秒或者再刷新一次才能看到。一开始我怀疑是 tRPC 缓存,于是加了一堆invalidate,仍然没有根治。

后来我把问题拆开排查,链路是这样的:

  • 第一步,直接查数据库,确认数据已经写入了 —— 数据库没问题;
  • 第二步,在浏览器里直接访问 tRPC 的接口地址,返回的结果是最新的 —— 后端接口没问题;
  • 第三步,看页面请求 Load 面板,发现页面根本没有发新的 tRPC 请求 —— 问题出在前端。

再深挖,发现是 Next.js App Router 的客户端 Router Cache 在起作用。Next.js 会把 RSC Payload 缓存到客户端,如果你的页面没有显式声明动态渲染,它可能把数据当作静态内容缓存住。举个例子,我在app/snippets/page.tsx里没有加任何动态标记,Next.js 可能把首次请求的结果缓存下来,之后从缓存里取。

解决方式有三种,按推荐程度排序:

  1. 在相关的 route 上加:
export const dynamic = "force-dynamic";

这告诉 Next.js 这个路由每次请求都动态渲染,不做静态缓存。

  1. 在调用list的页面或布局中,读取 cookies 或 headers 这样的动态 API,Next.js 会自动把它标记为动态渲染。

  2. mutation 完成后调用router.refresh(),强制刷新当前路由的服务端组件数据。这个命令在 App Router 里很实用,但它会刷新整个路由,代价比invalidate大。

最终我把动态渲染标记加到了片段详情页,而列表页保留了一部分静态缓存,只对“最近更新”区域做动态刷新。这样即保证了新数据能出现,又不会每个请求都重新渲染整棵树。

4.3 坑三:NextAuth 的 session 在 tRPC context 里拿不到

这个坑让我印象最深。t3code 在本地登录没问题,但部署到 Vercel 之后,凡是protectedProcedure都报未授权。登录接口本身正常,浏览器里能看到 session cookie,但到 tRPC 的 context 里getServerSession拿到了null。

排查时我一度怀疑是 cookie 跨域问题,后来把环境变量逐行对比,发现问题出在NEXTAUTH_SECRET:本地的.env和生产环境的 Vercel 环境变量里存了不同的值。NextAuth 的 session 是加密签名的,如果部署后的 secret 和登录时不一致,服务端就无法验证 session 的合法性。这在本地不会发生,因为本地永远只有一套配置。

另一个容易忽视的点是 tRPC context 的写法。在 create-t3-app 的新版本里,服务端渲染场景下你需要确保 context 里拿到的是同一个 session 对象。我的最终代码是这样:

// src/server/api/trpc.ts import { initTRPC, TRPCError, } from "@trpc/server"; import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; import { db } from "~/server/db"; export const createTRPCContext = async () => { const session = await getServerSession(authOptions); return { db, session }; }; const t = initTRPC.context<typeof createTRPCContext>().create(); export const createTRPCRouter = t.router; export const publicProcedure = t.procedure; const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);

这里的关键是 middleWare 会把 session 解构后传给下一个中间件,保证后续逻辑中一定存在ctx.session.user。如果你的 context 里session本身就是null,那直接再看 session 来源:是不是 NextAuth 的authOptions没有正确配置 provider?先解决登录问题,再排查 tRPC。

我把这三个坑整理成了一张自查表,方便你踩雷时快速对照:

现象可能原因优先检查
部署后偶发 500PrismaClient 被反复创建 / 连接池耗尽全局单例、池化连接参数
新增数据后页面不刷新Next.js Router Cache / tRPC 缓存未失效动态渲染标记、invalidate、router.refresh()
所有受保护接口报未授权NEXTAUTH_SECRET 不一致 / session 获取失败环境变量、getServerSession的前后一致性

5. 部署到生产环境的细节与后续优化

5.1 首次部署:环境变量与 migrate 流程

t3code 的部署目标选了 Vercel,因为 Next.js 项目在 Vercel 上基本零配置。不过这并不代表没有注意点。首次部署时我踩了这样一个流程坑:在本地建好了 SQLite 数据库,代码推上去之后,远程环境照样连不上数据库。原因很简单,Vercel 的 Serverless 文件系统是只读的,SQLite 这种单文件数据库根本没法持久化。

所以生产环境必须换成 PostgreSQL。我的迁移步骤是这样的:

  1. 在 Supabase 或 Neon 上创建一个 PostgreSQL 实例,拿到连接串;
  2. 本地schema.prisma确认provider = "postgresql",重新生成迁移文件;
  3. 执行npx prisma migrate deploy,在生产数据库上执行迁移;
  4. 在 Vercel 项目设置里配置三个环境变量:DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL;
  5. 部署后访问/api/auth/session确认登录会话正常。

这里必须强调一个和本地开发的差异:本地开发时,我习惯直接用prisma db push同步 schema,因为它不产生迁移文件,改表速度快。但生产环境千万别用db push,一是它不可追溯,二是多人协作时没人知道数据库到底被谁改过。规范的做法是prisma migrate deploy,它会按迁移文件顺序执行,保证每个环境的结构一致。

5.2 性能与资源控制:小项目也要有优化的意识

t3code 的数据量短时间内不会很大,但我在写的时候还是埋了几个优化点,免得以后改起来伤筋动骨。

第一个是列表分页。目前的list接口直接返回全部片段,量大了会有两个问题:网络包变大、数据库查询变慢。我给list加上了cursor分页模式:

list: publicProcedure .input( z.object({ cursor: z.string().optional(), take: z.number().max(50).default(20), }) ) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ take: input.take + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: "desc" }, }); }),

take: input.take + 1是判断是否还有下一页的技巧:如果返回的记录数大于take,就说明还有下一页,前端再把最后一条记录的 id 作为 cursor 继续拉。

第二个是代码高亮。t3code 里片段内容可能比较长,如果在前端引入一个重量级高亮库,首屏会明显变慢。我选择把高亮逻辑放到服务端来做,用 Shiki 在页面渲染时把代码转成带样式类名的 HTML,前端只负责展示。这样用户的浏览器不加载高亮库,代码高亮也不会闪烁。

第三个是全文搜索。小项目阶段用 Prisma 的contains勉强够用:

where: { OR: [ { title: { contains: keyword } }, { content: { contains: keyword } }, ], }

但超过几千条之后,contains的性能就会开始捉急。如果后续集成真正的全文检索,我会优先考虑 PostgreSQL 的pg_trgm扩展,再进一步才会上独立的搜索引擎。这些都不用现在做,但 schema 和接口层面要预留好扩展点,别把自己写死。

5.3 后续规划:从“自用工具”到“团队可用平台”

t3code 现在满足了我的核心诉求:快速记录、快速检索、可分享。但你要说它是个好产品,还差得远。我自己整理的后续优化方向大致有三块:

第一,体验层面的补充:分享链接支持有效期,过期后自动失效;片段支持 Fork,也就是在别人公开片段的基础上另存一个自己的版本;增加收藏夹的模糊搜索。

第二,数据层面的扩展:把 Markdown 说明和代码内容拆开,支持下载 Markdown 文件快照;提供浏览器扩展,在 GitHub 或 GitLab 页面上右键即可把选中代码存入 t3code。

第三,工程层面的完善:给 tRPC 接口加上请求日志和错误上报;用 Playwright 写几条核心路径的 e2e 测试;把数据库迁移流程 CI 化,每次 schema 变更自动生成迁移文件。

很多项目做到“自用 OK”就停了,但如果你真的打算给团队用,稳定性和数据安全就必须排在前面。比如片段内容对团队可能是核心资产,导出和删除都必须有明确的操作记录,这些后续大概率得用一张 audit log 表来支撑。

最后再分享一个我个人的操作习惯:所有代码片段录入时,强制要求填language和description两个字段。前者是为了筛选方便,后者是为了让别人(包括未来的自己)能在一分钟内理解这段代码解决的是什么问题。这个习惯比任何技术选型都重要,因为工具再顺手,也架不住你存进来的是没有上下文的半成品。t3code 这个项目让我真正体会到了“类型安全 + 统一录入规范”带来的长期价值,也建议你从自己的高频重复代码开始,先存它十个片段感受一下检索效率的变化,再做下一步功能的优先级判断。