t3code 这个名字,第一次看到的人多半会当成某个开源库的代号。其实它是我从零到一做完的一个代码片段管理应用,也是把 T3 技术栈完整跑通的一个实验项目。所谓 T3 栈,是现在前端社区很流行的一套组合:Next.js + TypeScript + Tailwind CSS + tRPC,再加上 Prisma 和 NextAuth.js 这两个常驻成员。t3code 的核心功能不复杂,就三件事:记录代码片段、按语言和标签检索、生成分享链接。但围绕这三件事,几乎能把一个 Web 全栈应用最常见的问题全踩一遍:登录鉴权、数据库 CRUD、列表搜索、前后端类型联动。这篇文章就是整个项目的完整复盘,从最初的技术选型逻辑,到每段关键代码怎么写的,再到部署上线后踩过的那些坑。如果你正准备从前端走向全栈,或者想快速搭一个内部小工具,这篇可以直接当“抄作业”的底稿。
1. t3code 项目定位与整体设计思路
1.1 t3code 到底是什么,解决什么问题
t3code 不是一个博客系统,也不是 CMS。它解决的问题非常具体:我们平时写代码总会积累下大量零散的片段,比如一段 Dockerfile 配置、一个 Nginx 反向代理模板、一条复杂的 SQL 语句、一个正则表达式。这些片段放在普通笔记软件里,找不到好的代码高亮;收藏在浏览器书签里,过几个月就变成一坨没有上下文的网址;放在真实的仓库里,又不够格成为一个正式项目。
t3code 就是把这些片段当作一等公民管理的工具。核心体验是一个极简版的 GitHub Gist:登录之后你能创建片段,给片段打标签、选语言,列表页能按关键词搜索,详情页能高亮展示,公开片段能生成一个独立链接发给别人。整个产品听起来很小,但恰恰因为小,才能在一周内从零跑到线上。我现在的习惯是,每次调好一个新配置或者写出一段满意的逻辑,就顺手归档进 t3code。三个月下来,这个私有知识库的检索效率远高于我以前的笔记方式。
关于“为什么不做成更复杂的笔记工具”,我的考虑也很直接。代码片段是一个边界十分规整的模型:不需要富文本编辑,不需要复杂的权限体系,主要场景是“本人私有”加“可选公开”。但检索、标签、列表分页、关联查询这些后端能力却一个都少不了。它是一个典型的“麻雀虽小五脏俱全”项目,用来验证技术栈和练习工程化能力都非常合适。
1.2 为什么选择 T3 技术栈,而不是前后端分离
先说明一下,T3 栈不是一个框架,而是一组经过社区验证、彼此配合得很好的工具组合。我选它有三个核心理由。
第一是类型安全。tRPC 让前端直接“调用”后端函数,不需要手写接口文档,也不需要额外生成类型定义。改后端数据结构,前端编译期间就能发现哪些地方挂了。这个体验平时不觉得,真正做项目时能省去大量联调时间。以前前后端分离,后端改一个返回字段,前端要翻接口文档、找数据字典、再手动改类型,一套下来至少半小时;用 tRPC 之后,编译器直接告诉你哪里需要改。
第二是部署简单。Next.js 单应用部署到 Vercel,只需要配置几个环境变量。不需要单独维护前端静态服务器、后端 Node 服务、数据库迁移那一整套流水线。个人项目最怕折腾运维,T3 栈把运维成本压到了极低。
第三是开发效率。Tailwind 让我不用写 CSS 文件,Prisma 改完 model 一行命令完成迁移,NextAuth 接 GitHub 登录半个上午就能跑通。这些工具单拎出来都有人用,组合在一起后,个人开发速度的提升非常明显。
但这里也要泼一盆冷水:如果你要做的是对外开放的公共 API,tRPC 并不合适。它要求调用方也使用 @trpc/client,把类型共享到前端,但外部开发者不会为了你的接口去额外装一个客户端。技术选型要按场景来,T3 栈最适合前后端同属一个团队的内部应用或中小型产品,对外提供公共服务还是老老实实 REST 或 GraphQL。
1.3 MVP 范围和功能边界
做项目和做产品一样,第一刀永远切在最核心的闭环上。t3code 的 MVP 只做了六件事:GitHub OAuth 登录、片段 CRUD、标签管理、语言分类、列表搜索、公开分享链接。明确不做的有:团队空间、富文本编辑器、实时协作、版本历史、移动端原生 App。不是这些功能没用,而是它们会迅速拉长开发周期,掩盖掉技术栈验证这个原始目标。
为了让自己不跑偏,我列了一张表贴在项目文档第一行。
| 本期做 | 本期不做 | 后续可以考虑 |
|---|---|---|
| GitHub OAuth 登录 | 团队空间 | 一键导入 Gist |
| 注册用户 | 富文本编辑器 | 浏览器插件 |
| 片段 CRUD | 实时协作 | 收藏他人片段 |
| 标签管理 | 多语言国际化 | 版本历史 |
| 公开分享链接 | 移动端原生 | 评论互动 |
实际上 MVP 范围内还能继续压缩。比如标签管理,一开始我不打算单独建 Tag 表,直接在 Snippet 上放一个 string[] 字段就完事。后来考虑到后续要按标签聚合统计,才决定用多对多关系。我的建议是,第一版能用一个字段解决的,就不要建表;只有确信查询模式会变复杂,再提前做关系建模。
2. 技术栈选型:为什么 t3code 需要全套 Next.js + tRPC + Prisma
2.1 Next.js App Router 的核心作用
t3code 有列表页、详情页、编辑器页、登录页,这些页面全部放在同一个 Next.js 应用里。App Router 一个最有价值的点,是可以在默认的 React Server Component 下做页面级拆包。纯展示的列表页放在服务端渲染,完全不需要给浏览器下发额外的 JS 包;而真正需要用户交互的部分,比如代码提交表单和实时搜索,再用 “use client” 声明成客户端组件。两者在同一个文件夹体系里各司其职,条理很清晰。
以列表页为例,我写了一个服务端组件,直接调用 tRPC 的 query,把它当作一个普普通通的异步函数来用:
export default async function SnippetListPage() { const snippets = await api.snippet.getAll.fetch({}); return <SnippetList initialData={snippets} />; }这里的 api.snippet.getAll 是类型安全的。snippets 会被自动推断成一个包含 title、content、language、tags 等字段的数组。如果在后面的 JSX 里不小心访问了一个不存在的字段,编译器马上就会报错。这种体验在老的 Pages Router 里做起来要别扭得多,那时候页面组件几乎都是客户端的,初始化数据要么用 getServerSideProps,要么在 useEffect 里请求一次,代码分散且类型经常断链。
2.2 tRPC:把接口调用变成本地函数调用
tRPC 的核心思路特别直接:一端定义函数,另一端直接调用。它不是把后端代码发到前端,而是通过 TypeScript 神奇的类型推导能力,把后端函数的输入输出类型精准地传递给前端。在 t3code 里,所有业务路由都定义在 server/api/routers/snippet.ts 中,用 query 表示读操作,用 mutation 表示写操作。zod schema 在这里很聪明地承担了双重职责:运行时校验和类型推导。
举个例子,创建片段的入参长这样:
const createSnippetSchema = z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().min(1), isPublic: z.boolean().default(true), tagIds: z.array(z.string()).optional(), });前端调用 createSnippet 时,只要参数类型不对,编译器直接报错,根本走不到运行时。这跟以前“前端手拼对象,后端手动校验”的做法相比,整个链路的安全水位高了一大截。但 tRPC 不是银弹。它的价值前提是“前后端都掌握在同一个团队手里”,一旦你面对的是非 TypeScript 客户端、第三方开发者、或者纯移动端,它反而会成为负担。
2.3 Prisma 数据建模与迁移
t3code 的数据模型很经典:用户、片段、标签,其中片段和标签是多对多关系。Prisma 的 schema 写出来是这样的:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String? emailVerified DateTime? image String? snippets Snippet[] accounts Account[] sessions Session[] } model Snippet { id String @id @default(cuid()) title String content String language String @default("plaintext") isPublic Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User @relation(fields: [authorId], references: [id]) authorId String tags Tag[] } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] }有几个细节值得解释。id 我不用自增整数,而是 cuid 字符串。原因是在 serverless 和分布式环境下,自增主键容易成为瓶颈,而 cuid 不需要访问数据库就能生成,插入性能更稳定。User 模型里挂着 accounts 和 sessions 关系,这是 NextAuth 的 PrismaAdapter 运行时需要的,表结构由官方 adapter 自动维护,不要手动去改。
本地开发我一开始用的是 SQLite,生产环境换成 PostgreSQL,结果遇到一些迁移脚本不兼容的问题。比如同样一个 enum 在 SQLite 里没有对应原生类型。后来我直接本地用 Docker 起一个 Postgres,开发和生产的 provider 保持一致,这类问题就再也没出现过。如果你也是 solo 开发,建议从一开始就用同一个数据库方言。
2.4 Tailwind CSS 与界面实现
t3code 的界面完全用 Tailwind 写,连组件库都没有引入。好处是初期迭代可以非常快地调整间距、配色和布局,不用在一个个 CSS 文件里跳来跳去。深色模式用 next-themes 实现,包一层 ThemeProvider,再调用 useTheme 切换,服务端渲染也不会闪白。
代码高亮是唯一需要单独处理的地方。我用的 react-syntax-highlighter,对常见语言的支持很稳,缺点是打包体积偏大。如果你想把首屏做得很轻,可以考虑 Prism 自建组件,但 MVP 阶段没必要为了几百 KB 去折腾。样式方面有一个小建议:在 index.css 里预先定义好背景色、文字色、边框色这些 CSS 变量,这样切换深色模式时只需要换变量值,不用到处去找 Tailwind class 批量替换。
2.5 NextAuth 认证与权限控制
t3code 的登录方式接的是 GitHub OAuth,同时把 NextAuth 的 PrismaAdapter 接上,OAuth 用户信息会自动落库到 User 表。会话策略我选择 JWT,因为 Vercel serverless 环境里,无状态 JWT 比数据库 Session 少一次查询,冷启动表现更好。
配置里有一个回调非常关键:
callbacks: { session({ session, token }) { session.user.id = token.sub; return session; }, },这个回调把用户 id 挂到 session 上,后面所有需要登录的操作都靠它拿当前用户。权限控制不能只靠 middleware 挡页面,业务层必须做二次校验。比如删除片段时,不能只看前端传进来的 snippetId,还要先查一遍这条记录的 authorId 是否等于当前 session.user.id,否则任何人打开 devtools 的 Network 面板,都能直接构造请求删别人的数据。这个坑我见过太多次,属于安全设计里最容易忽略的一层。
3. 从零到一:t3code 的初始化、编码与部署全流程
3.1 用 create-t3-app 快速初始化
t3code 的脚手架是用官方 create-t3-app 生成的,命令很简单:
pnpm create t3-app@latest t3code交互式询问里我勾选了 TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth、ESLint、Prettier。App Router 是默认选项,直接回车。生成后的目录结构关键位置如下:
src/ app/ api/trpc/[trpc]/route.ts layout.tsx page.tsx server/ api/ routers/ snippet.ts root.ts trpc.ts auth.ts db.ts trpc/ server.ts client.ts react.tsxcreate-t3-app 生成的代码会自动把 tRPC 的上下文链路搭好,utils/api.ts 也预先封装好了 React Query 的版本。很多新手第一次接触会觉得这些文件是“魔法”,但不要手贱删掉。trpc.ts 里的 createTRPCContext 会把数据库实例和 NextAuth 的 session 合进每个请求的上下文,后续写 protectedProcedure 就是从这里拿的 session。
3.2 设计数据模型与迁移
按照上一节的 schema 写好 prisma 文件后,执行一条命令:
npx prisma migrate dev --name init如果本地 DATABASE_URL 指向的是可用的 Postgres,这行命令会生成迁移脚本并直接建表。之后每次修改数据模型,都建议走 migrate dev 而不是手动改数据库表,因为迁移历史是团队协作和未来降级的依据。生成后别忘了跑 npx prisma generate,虽然 migrate dev 会自动触发 Client 生成,但如果你在 CI 里构建,还是要在构建脚本里显式加一步才稳妥。
3.3 编写 tRPC 路由
t3code 的核心路由集中在 snippet.ts 里。先定义几个 schema,再写 router。关键代码如下:
export const snippetRouter = router({ getAll: protectedProcedure .input( z.object({ search: z.string().optional(), language: z.string().optional(), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const snippets = await ctx.db.snippet.findMany({ where: { authorId: ctx.session.user.id, ...(input.search ? { OR: [ { title: { contains: input.search, mode: "insensitive" } }, { content: { contains: input.search, mode: "insensitive" } }, ], } : {}), ...(input.language ? { language: input.language } : {}), }, include: { tags: true }, orderBy: { updatedAt: "desc" }, take: 20, ...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}), }); return { items: snippets, nextCursor: snippets.at(-1)?.id }; }), getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const snippet = await ctx.db.snippet.findFirst({ where: { id: input.id, authorId: ctx.session.user.id }, include: { tags: true }, }); if (!snippet) { throw new TRPCError({ code: "NOT_FOUND" }); } return snippet; }), create: protectedProcedure .input(createSnippetSchema) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, isPublic: input.isPublic, authorId: ctx.session.user.id, tags: { connectOrCreate: input.tagIds?.map((tagId) => ({ where: { name: tagId }, create: { name: tagId }, })) ?? [], }, }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.snippet.findUnique({ where: { id: input.id }, }); if (!existing || existing.authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } await ctx.db.snippet.delete({ where: { id: input.id } }); return { ok: true }; }), });这里要重点说两个设计。第一个是列表接口不要返回 content 全量内容,只返回标题、语言、时间、标签这些元信息,详情单独走 getById。代码片段通常几百行,列表接口一次拉二十条全量,Payload 就是几千行,毫无必要。第二个是所有 mutation 都要重复校验资源归属。create 里的 authorId 直接取自 session,delete 里先查再比,这两步缺一不可。
3.4 页面接入与交互
页面接入其实最爽的部分。列表组件里我直接用 React Query 的封装:
const { data, refetch } = api.snippet.getAll.useQuery({ search: keyword, language: currentLanguage, });t3code 的编辑页和详情页是分开的。新建和编辑共用同一个表单组件,通过路由参数判断是 create 还是 edit。提交成功后,调用 invalidate 触发列表刷新:
const createMutation = api.snippet.create.useMutation({ onSuccess: () => utils.snippet.getAll.invalidate(), });这个 invalidate 是 tRPC 和 React Query 整合后的一个很顺手的能力。你不需要手动 refetch,它会让当前页面上所有相关的 useQuery 重新失效并拉取最新数据。体验上最直观的感受是:新建完一篇文章,回到列表页,列表已经是最新的了,中间没有多余的状态管理代码。
3.5 部署到 Vercel
部署流程比想象中顺滑。代码推到 GitHub,在 Vercel 里导入仓库后,环境变量要重新填四项:DATABASE_URL、AUTH_SECRET、AUTH_GITHUB_ID、AUTH_GITHUB_SECRET。Framework Preset 选 Next.js,Node 版本选 20,点一下 Deploy 就完了。
有几个细节需要单独提醒。第一,AUTH_URL 这个变量在 Vercel 上通常会自动生成,但本地开发时一定要显式设置成 http://localhost:3000,否则 GitHub 回调地址会一直对不上。第二,Prisma 在构建前必须跑过 npx prisma generate,我习惯在 package.json 的 postinstall 脚本里加上这一句。第三,如果数据库用的是 Neon 这类 Serverless Postgres,DATABASE_URL 建议使用连接池模式的那个地址,不然高并发时连接数很容易被打爆。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
以下是 t3code 开发过程中我自己遇到并且排查过的问题,整理成一张表,方便直接检索。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| prisma migrate 报 P1001 | DATABASE_URL 写错或数据库不可达 | 用 psql 手动连接数据库,先确认网络和账号权限 |
| tRPC mutation 全部报 UNAUTHORIZED | protectedProcedure 里拿不到 session | 检查浏览器是否带上 session cookie,用 localStorage 看 cookie 是否写入 |
| OAuth 回调地址不匹配 | AUTH_URL/NEXTAUTH_URL 没配置或端口不一致 | 本地统一用 localhost:3000,生产环境用真实域名 |
| Vercel 部署后查询 500 | Prisma Client 未生成或环境变量缺失 | 构建脚本里加 npx prisma generate,Vercel 环境变量重新检查 |
| 代码高亮样式不生效 | 主题 CSS 未导入 | 在全局入口显式引入 react-syntax-highlighter 的主题样式 |
| 深色模式水合报错 | 首次渲染时读取 localStorage 时机不对 | next-themes 正确包裹,避免手动操作 document |
| 列表接口响应过大 | 返回了 content 全量字段 | 列表接口瘦身,只返回元信息,详情单独查询 |
4.2 Prisma 数据库连接问题
P1001 是我这轮遇到最多的错误。第一次是本地 .env 里面 DATABASE_URL 的密码带了特殊字符,比如 @ 和 #,没有做 URL 编码,导致连接字符串解析错误。第二次是生产数据库的网络白名单没放行 Vercel 的出口 IP。排查这类问题不要盯着 Prisma 的报错信息无限猜,直接拿 DATABASE_URL 用 psql 或者连接工具连一次,很快就能定位是网络问题还是认证问题。
4.3 NextAuth OAuth 账号关联异常
开发时我用自己的 GitHub 账号测试一切正常,后来换了一个账号就出现 OAuthAccountNotLinked。原因是 NextAuth 根据邮箱判断账号身份,同一个邮箱如果关联过两个不同的 OAuth Provider,就会报这个错。MVP 阶段最省事的做法是不同环境用不同的 GitHub 账号,生产环境上线前清掉测试用户的数据,避免脏数据影响体验。
4.4 tRPC 部署环境的打包问题
还有一个比较隐蔽的坑:某些依赖库在 Next.js 的 Server Component 里会报 ESM/CJS 冲突,表现为构建过程中出现 is not defined 之类的运行时错误。我的做法是把这类库加进 next.config 的 serverComponentsExternalPackages 数组里,让它们在服务端作为外部依赖处理,不参与客户端打包。遇到具体报错后,先搜索一下哪个包的版本和 Next.js 13/14 不兼容,升级后再重新 build,往往一秒解决。
5. 做完 t3code 之后,我的一些真实体会
5.1 类型安全让我敢于重构
以前改后端接口,心里会发怵,因为不知道前端哪些地方还在用旧的返回结构。现在改了 Prisma schema 或者 tRPC 路由,前端编译直接列出所有需要修改的位置,这种安全感在连续迭代一周后体会特别明显。类型系统不只是写代码时的自动补全,它更像是一张织得很密的网,兜住了我每次偷懒时可能漏掉的隐藏依赖。
5.2 技术栈的开销边界要心里有数
T3 栈不完全是“小而美”,它也有学习成本。tRPC 的上下文链路、React Query 的缓存失效、NextAuth 的 Provider 机制、Prisma 的嵌套加载,哪个概念对第一次接触的人都不太友好。如果你是纯前端,不要直接上手大项目,先用 create-t3-app 跑通一个 todo 类的 CRUD,再进真实业务。反过来,如果团队本来就有成熟的 REST API 体系和文档规范,引入 T3 栈的收益就不一定高,判断标准始终应该是“是否真的需要类型共享和一体化部署”。
5.3 下一步扩展方向
这个项目我不会停在 MVP。短期计划加收藏功能、一键导入 Gist、基于内容的全文搜索。如果未来想把它变成公共分享平台,还需要补 API 限流、内容审核、垃圾内容过滤这些运营层面的东西。到那一步,问题的重心会从技术栈切换成产品治理,这也是个人项目从玩具走向工具的真实曲线。
最后分享一个实际的技巧:如果你也准备用 T3 栈做内部工具,第一版应当尽量少设计,把精力放在打通登录和 CRUD 闭环上。只要这条链路通了,后续加任何业务模块都只是复制粘贴路由再加一张表的事。 t3code 这个项目最让我意外的收获,不是学会了某个具体框架,而是真正确认了“类型安全 + 一体化部署”这两个选择,在个人开发场景下确实能带来实打实的效率提升。