前阵子给自己定了个小目标:不搞花活,用一套现代又克制的方案,把一直想做的代码片段管理工具真正落地。项目代号就叫t3code,技术底座选了社区里讨论度很高的T3 Stack,也就是 Next.js、TypeScript、Tailwind CSS、tRPC 这几件套,再加上 Prisma 和 NextAuth.js 完成数据层与登录鉴权。折腾完一个完整版本之后,我把选型逻辑、核心实现、部署过程以及踩过的坑全部复盘一遍,给正在研究全栈类型安全,或者想找一个可复现参考项目的人做份详细的实践记录。
t3code 本身解决的是很实际的问题:每个人的代码片段散落在本地文件、聊天记录、临时笔记里,真要用的时候永远找不到。把它做成一个支持标签分类、语言筛选、公开/私密切换、复制即用的在线工具,既能满足日常自用,也能作为学习 T3 Stack 的最佳练手项目。如果你对 tRPC 这种免写 REST API 的开发方式好奇,或者想在 Next.js 项目里把前后端类型彻底打通,这篇文章的示例代码和踩坑点基本都是可以直接照着改的。
1. 项目概述与定位
1.1 t3code 要解决什么问题
很多开发者都会遇到一个尴尬的场景:昨天刚调试通的加密逻辑,今天就忘了放在哪个目录;同事分享的一段 SQL 优化写法,翻半天聊天记录只找到半个;项目里积累的公共函数越来越散,最后只能靠搜索引擎重新找答案。t3code 最初就是冲着这个痛点去的,做一个自托管的代码片段管理工具,让片段可以集中管理、随时检索、一键复制。
功能层面,我给它规划的是最小可用集:用户注册登录后可以创建片段,片段包含标题、语言类型、正文、标签、可见性这几个核心字段;首页支持按语言筛选和关键词搜索;公开片段可以被其他登录用户收藏,收藏之后在个人中心快速查看。这个范围刚好覆盖 CRUD、搜索、鉴权、社交化收藏这几个全栈项目最常见的模块,不至于太小没代表性,也不会大到一个人做不完。
和 Gist、CodePen 这类在线服务相比,t3code 更强调 "自己能完全掌控" 这件事。它跑在自己的服务器或 Vercel 上,数据在自有数据库里,字段规则、权限逻辑、展示风格都由自己说了算。对于想深入学习 Next.js App Router 和 tRPC 的开发者来说,这个项目比照着文档敲 demo 有价值得多,因为每一步都会遇到真实业务带来的边界情况。
1.2 为什么选择 T3 Stack 作为底座
T3 Stack 这个名字来自 Theo 常用的 ID,社区里也喜欢把它解释成 TypeScript、Tailwind、tRPC 这三个 "T" 开头的组合。它强调的不是"必须用这几种技术",而是"用最简单直接的方式把类型安全贯穿到底"。选它作为 t3code 的底座,主要是因为三层考量。
第一,类型安全闭环。传统前后端分离项目里,接口请求和响应类型经常要靠手写 d.ts 维护,改一个字段可能两边都漏掉。T3 Stack 用 tRPC 让前端直接调用后端函数,类型定义是同一个源头,业务里增加参数或者改返回值,前端立即就能看到编译错误。这种体验用熟了之后,再回去手写 API 会感觉像在裸奔。
第二,渐进式上手。Next.js 同时负责页面渲染和 API,Tailwind 做样式,Prisma 管数据库,每个环节都是当前生态里学习资料最丰富、坑最少的选项。即使没有 T3 Stack 经验的人,看到目录结构也能大致猜出每个文件是干嘛的,这比引入复杂的微前端或者独立 BFF 层友好得多。
第三,部署简单。Next.js 应用可以直接部署到 Vercel,Prisma 对接托管 Postgres 数据库,环境变量配置好之后基本是流水线式发布。个人项目最怕运维成本拖垮积极性,这一套方案能把从开发到上线的路径压缩到最短。
2. 技术选型与架构设计
2.1 技术栈组成与分工
t3code 的核心技术栈是:Next.js 14 + App Router、TypeScript、Tailwind CSS、tRPC v11、Prisma、NextAuth.js。最后用的包管理器是 pnpm,因为它在依赖安装速度和磁盘占用上明显优于 npm,对于一个会长期迭代的项目来说是稳妥的选择。
每个组件在项目里的分工是这样:Next.js 负责页面路由、服务端渲染和 API 路由的宿主;TypeScript 提供全项目静态类型约束;Tailwind 处理所有 UI 样式,不写单独的 CSS 文件;tRPC 替代了手写 REST API,前端组件直接调用后端 router 里的 procedure;Prisma 负责数据库表结构定义、迁移和查询;NextAuth 处理 GitHub OAuth 登录和 Session 管理。
可能有人会问:已经有 REST 了,为什么还要引入 tRPC?这要从实际开发体验说。t3code 里删除一个片段,如果走 REST 就要定义 DELETE /api/snippets/:id,然后写前端 fetch、处理错误、维护类型。走 tRPC 的话,在后端写一个delete: protectedProcedure.input(...).mutation(...),前端通过生成的api.snippet.delete.useMutation()调用,参数类型和返回类型完全自动推断,省掉了一大堆样板代码。对于这种以 CRUD 为主的业务,tRPC 的开发效率优势非常明显。
2.2 目录结构与前后端边界
这是项目初始化后 App Router 模式下的核心目录结构,我稍微调整后加上了业务代码:
t3code/ ├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts │ │ ├── (auth)/login/page.tsx │ │ ├── (main)/ │ │ │ ├── page.tsx │ │ │ ├── new/page.tsx │ │ │ ├── snippet/[id]/page.tsx │ │ │ └── dashboard/page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ ├── routers/ │ │ │ │ ├── snippet.ts │ │ │ │ ├── tag.ts │ │ │ │ ├── star.ts │ │ │ │ └── user.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── client.tsx │ └── styles/ └── package.json这个结构的关键点在于把 server 代码和 client 代码物理分开。所有涉及数据库、鉴权、业务规则的逻辑都放在src/server下,浏览器端永远不直接接触 PrismaClient。src/trpc/server.ts和src/trpc/client.tsx是 tRPC 的桥接层,前者在服务端组件里调用 API,后者在客户端组件里挂载 Provider。
// src/trpc/client.tsx "use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import { createTRPCReact } from "@trpc/react-query"; import { useState } from "react"; import type { AppRouter } from "@/server/api/root"; export const api = createTRPCReact<AppRouter>(); export function TRPCReactProvider(props: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()); return ( <QueryClientProvider client={queryClient}> <api.Provider queryClient={queryClient} endpoint="/api/trpc" > {props.children} </api.Provider> </QueryClientProvider> ); }2.3 从 Schema 到 tRPC 的类型闭环
T3 Stack 最让我满意的地方,是整个数据链路从数据库到 UI 是同一份类型。Prisma Schema 定义了模型,tRPC 的 procedure 返回数据库查询结果,前端组件直接接收这个返回类型。中间不需要手写 DTO,也不用背后维护 document 文档,基因层面的类型一致性让改造成本降到最低。
举个例子,t3code 里"获取我收藏的片段"这个需求,前端页面只需要这么写:
const { data, isLoading } = api.star.myStars.useQuery(); if (isLoading) return <p>加载中...</p>; return data?.map((snippet) => ( <div key={snippet.id}>{snippet.title}</div> ));data的类型就是Snippet[],编辑器里会有完整的补全提示。如果数据库表结构变化,比如Snippet新增了字段,代码里马上会提示相应调整,不存在"数据返回了但前端不知道"这种状态。这套体验让我意识到,所谓类型安全不是单纯的上纲上线,而是实打实地减少排查 bug 的时间。
3. 核心实操:从零实现 t3code
3.1 用 create-t3-app 初始化项目
初始化是性价比最高的一步,不用手动拼装十几个依赖。我在空目录里执行:
pnpm create t3-app@latest t3code创建过程中会有几个交互选项,全部勾选核心的几项:Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth.js,ESLint 也要选上。安装完成后直接pnpm dev就能启动,默认页面会引导你完成环境变量配置。
需要提醒的是,create-t3-app 生成的模板会用环境变量文件.env保存数据库连接和 NextAuth 配置,里面给了示例值。我第一次跑的时候,直接复制了.env.example为.env,然后忘了改DATABASE_URL,结果 Prisma 一直报连接错误。这类问题在项目初期最容易浪费时间的,建议开局先把环境变量逐项检查一遍。
DATABASE_URL="file:./db.sqlite" NEXTAUTH_SECRET="随便填一长串随机字符串" NEXTAUTH_URL="http://localhost:3000"3.2 设计数据模型并完成首次迁移
create-t3-app 自带的 schema.prisma 已经包含 NextAuth 需要的 Account、Session、User、VerificationToken 四个模型,这部分我基本原样保留,然后在此基础上追加业务模型。核心是 Snippet 和 Star,Tag 用独立的模型维护,方便做标签聚合统计。
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] snippets Snippet[] stars Star[] } model Snippet { id String @id @default(cuid()) title String description String? content String language String @default("plaintext") isPublic Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) tags SnippetTag[] stars Star[] @@index([authorId, createdAt(sort: Desc)]) @@index([language, isPublic]) } model Tag { id String @id @default(cuid()) name String @unique snippets SnippetTag[] } model SnippetTag { snippetId String tagId String snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) @@id([snippetId, tagId]) } model Star { userId String snippetId String createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) @@id([userId, snippetId]) }这里给Snippet的authorId加外键级联删除,是为了用户注销时片段一并清理,避免留下孤儿数据。给authorId和createdAt建联合索引,是为了个人片段列表能快速按时间倒序拉取,后期数据量上来也不会瞬间变慢。数据库用 SQLite 起步开发足够,线上部署再切换 Postgres,Prisma 迁移能保证切换过程不至于太痛苦。
定义好模型后执行迁移:
pnpm prisma migrate dev --name init这条命令会生成 migration 文件并同步数据库,之后每次改 schema 都用类似流程生成新的迁移版本。要注意的是,团队协作时 migration 文件要一起提交,否则其他人的表结构就和你对不上了。
3.3 用 tRPC 把业务逻辑收敛到 server router
tRPC 的好处是后端逻辑不是分散在多个 API 路由里,而是集中在src/server/api/routers下。t3code 的 snippet router 承担了最核心的增删改查逻辑,我用protectedProcedure保证未登录用户不能执行写操作。
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc"; import { SnippetTag } from "@prisma/client"; const snippetCreateSchema = z.object({ title: z.string().min(1, "标题不能为空").max(80), description: z.string().max(200).optional(), content: z.string().min(1, "代码内容不能为空"), language: z.string().default("plaintext"), isPublic: z.boolean().default(true), tags: z.array(z.string()).max(10).default([]), }); export const snippetRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ language: z.string().optional(), keyword: z.string().optional(), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const where = { isPublic: true, ...(input.language && { language: input.language }), ...(input.keyword && { OR: [ { title: { contains: input.keyword, mode: "insensitive" } }, { description: { contains: input.keyword, mode: "insensitive" } }, ], }), }; return ctx.db.snippet.findMany({ where, take: 20, skip: input.cursor ? 1 : 0, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: "desc" }, include: { author: { select: { name: true, image: true } }, tags: { include: { tag: true } } }, }); }), create: protectedProcedure .input(snippetCreateSchema) .mutation(async ({ ctx, input }) => { const { tags, ...snippetData } = input; return ctx.db.snippet.create({ data: { ...snippetData, authorId: ctx.session.user.id, tags: { create: tags.map((tagName) => ({ tag: { connectOrCreate: { where: { name: tagName }, create: { name: tagName }, }, }, })), }, }, }); }), 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 Error("无权操作该片段"); } return ctx.db.snippet.delete({ where: { id: input.id } }); }), });有几个细节值得展开说。第一,关键词搜索用mode: "insensitive"是为了不区分大小写,英文关键词友好;中文场景下 Prisma 的 contains 也能正常工作,但 PostgreSQL 延迟会更大一些,所以搜索场景更适合用 pg_trgm 这类扩展来做索引,这是后话。第二,标签表用connectOrCreate是防止重复创建同名标签,一次性把标签和片段关联关系写好。第三,删除操作先查一次再做权限校验,虽然多一次数据库查询,但能避免直接注入删除条件绕过鉴权。
3.4 前端页面与样式细节
页面层面,t3code 主要拆成三块:首页片段瀑布流、新建/编辑页面、用户仪表盘。App Router 模式下,页面组件默认是服务端组件,但凡是需要交互的地方我都显式加了"use client"。比如首页的筛选下拉框,如果用服务端组件去接收表单请求,会经历一次完整页面刷新,体验上不如客户端筛选来得流畅,所以我让列表本身由服务端渲染,筛选动作通过 tRPC query 带着新参数重新请求,这样 URL 还能保持可分享的状态。
新建页面是典型的客户端表单,用 React Hook Form 管理状态成本太低,但 t3code 这种规模直接useState也够了。真正值得注意的是代码编辑器部分,我没有引入 monaco-editor,因为对单文件片段管理来说太重了,最后选的是 CodeMirror 6 的轻量方案,支持语法高亮:
"use client"; import CodeMirror from "@uiw/react-codemirror"; import { javascript } from "@codemirror/lang-javascript"; import { python } from "@codemirror/lang-python"; import { cpp } from "@codemirror/lang-cpp"; import { useState } from "react"; const languageMap = { javascript: javascript(), python: python(), cpp: cpp(), }; export function CodeEditor({ initialValue, onChange }: { initialValue?: string; onChange: (value: string) => void }) { const [value, setValue] = useState(initialValue ?? ""); return ( <CodeMirror value={value} extensions={[languageMap.javascript]} onChange={(val) => { setValue(val); onChange?.(val); }} className="rounded-lg border border-gray-200 text-sm" /> ); }样式上,Tailwind 让我完全跳过了写 CSS 文件的过程。页面布局基于 flex 和 grid 完成,列表卡片用shadow-sm rounded-xl border-gray-100这类原子类即可。这里我养成了一个习惯:设计页面时先想信息层级,再想组件拆分,最后才写标签和类名,Tailwind 类名虽然多,但结构和视觉是可以在脑子里先对齐的。
3.5 部署与发布
最后一步是把 t3code 推到生产环境。个人项目我推荐 Vercel 配合托管 Postgres,路线最短。先在 Vercel 导入 Git 仓库,把 Prisma 相关的构建命令配置好,再设置环境变量,包括DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL和 OAuth 的 Client ID、Client Secret。
部署前有一个关键点:Vercel 构建环境的 Prisma Client 需要重新生成。我在package.json里加了 postinstall 钩子:
{ "scripts": { "build": "next build", "postinstall": "prisma generate" } }如果不加这个钩子,构建时会出现 "PrismaClient is not initialized" 或者版本不匹配的问题。生产环境迁移也要注意,不要在构建时自动跑prisma migrate deploy之外的命令,我会在发布前手动执行一次迁移,或者在 CI 里单独加一个 job 处理。
部署完成之后,OAuth 回调地址要改成线上域名。这个坑几乎每个用 NextAuth 的人都会踩一次:本地测试没问题,部署后一直登录失败,最后发现NEXTAUTH_URL没改成 https 域名,或者 GitHub OAuth App 的回调地址还写着 localhost。
4. 常见问题与排查实录
4.1 tRPC 报错与类型不收敛
tRPC 类型偶尔会在两种情况下出问题:一是某个 procedure 里返回了Prisma.PromiseReturnType之类的复杂类型,给前端用的时候推断不完整;二是泛型嵌套太深,编辑器直接卡住或者显示 "Type instantiation excessively deep and possibly infinite",这种一般是输入输出类型写的太花哨。
排查思路很简单:先用export type AppRouter = typeof appRouter确认 root router 的类型导出正常,再一层层缩小到一个 procedure。我遇到最多的是在 query 的返回值里用include: { author: true },前端拿到的 author 是对象类型,模板里想访问snippet.author.name,但类型上 author 可能是null。这是因为外键关系默认可空,Prisma 生成的类型是User | null,所以访问时要么非空断言,要么做安全判断,这是类型的正确工作方式,不是 bug。
4.2 NextAuth 登录回调失败
登录回调失败是高频问题,根因基本集中在三个地方:NEXTAUTH_SECRET不一致、OAuth 回调地址不对、Session 策略配置错误。
如果部署在 Vercel 上,NEXTAUTH_SECRET必须稳定,不能每次部署都重新生成。我见过有人把 secret 直接写在代码里读取process.env,然后本地和生产不一致,导致登录后 session 校验失败。解决方式是用openssl rand -base64 32生成一串固定值存到环境变量里。NextAuth 的AUTH_SECRET在 v5 里会自动尝试读取,如果缺失会有警告,不要忽略。
另外,如果启用了database: ctx.db存储 session,还要检查 Session 模型是否在 schema 里,以及 Session 表内字段名是否和 Prisma 默认一致。
4.3 Prisma 查询性能与连接数
开发时 SQLite 没压力,生产切到 Postgres 后,如果所有页面都无条件查表,数据量增长后容易慢。t3code 里我做了几层优化:列表查询总是配合take限制条数,用游标分页而不是skip大偏移量;统计标签使用量时直接groupBy而不是先把全部标签拉回内存再数。
Prisma 连接数还容易在 serverless 环境里爆掉。Vercel 每个函数实例都可能建立数据库连接,如果连接池上限是 10,流量一上来就会出现 "Too many connections"。解决方式是用连接池代理或者限制prisma-client的复用,把 PrismaClient 实例声明成全局单例:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;这段代码来自 create-t3-app 模板,但很多人不理解为什么要这样写。原因是 serverless 环境下模块可能是热加载的,如果不缓存 PrismaClient,每来一个请求就 new 一个实例,连接数会快速膨胀,最终拖垮数据库。
4.4 Tailwind 与动态类名
Tailwind 一个容易踩的坑是:动态拼接类名不生效。因为 Tailwind 在构建时会扫描源码里的完整类名字符串,运行时bg-${color}-500这种写法不会被识别。我在 t3code 里做语言标签颜色时最开始就是拼接,结果显示全部没有样式。
解决方案是把完整类名写进映射表:
const languageColor: Record<string, string> = { javascript: "bg-yellow-100 text-yellow-800", python: "bg-blue-100 text-blue-800", cpp: "bg-red-100 text-red-800", plaintext: "bg-gray-100 text-gray-800", };这样 Tailwind 扫描时能看到完整的字符串,样式自然就生效了。
4.5 问题速查表
| 现象 | 根因 | 解法 |
|---|---|---|
| 本地正常部署后登录失败 | NEXTAUTH_URL 或 OAuth 回调地址未更新 | 统一改为线上域名,检查 GitHub OAuth 配置 |
| Prisma 报连接错误 | DATABASE_URL 写错或数据库未迁移 | 核对连接串,执行prisma migrate deploy |
| 点击按钮没有响应 | tRPC mutation 未触发或异常未捕获 | 检查 Procedure 是否 protected,查看浏览器 network 请求体 |
| 某个 Tab 样式全无 | 动态拼接 Tailwind 类名 | 用完整类名映射表替代字符串拼接 |
| 图片头像不显示 | NextAuth 默认头像 URL 被屏蔽或自签名 | 设置images域名白名单或直接本地存储头像 |
| 部署时构建失败 | postinstall 缺少 prisma generate | 在 package.json 增加 postinstall 钩子 |
4.6 排查问题时的工具与方法
遇到线上问题,我一般先看vercel logs或服务商日志,确认是不是构建阶段就挂了;如果构建正常,再看运行时错误,tRPC 的错误会封装在TRPCClientError里,浏览器 console 能直接看到 message。Prisma 查询慢的话,我会开启查询日志,通过PrismaClient({ log: ['query'] })查看每条语句的实际耗时,定位到底是缺索引还是数据量问题。
还有一个容易忽略的细节:Next.js 的静态优化会让部分页面在构建时执行服务端代码,如果某个页面调用了 tRPC 查询,而这个查询依赖 session,构建时没有 session 就会出错。遇到这种场景,需要把相关页面标记为动态渲染,或者把数据获取放在客户端组件里。
5. 写在最后的一点经验
t3code 这个项目从搭建框架到跑通核心流程,前后大概用了不到一周的业余时间,其中大部分时间不是在写业务逻辑,而是在调整环境、排查类型、处理部署细节。整个过程中我最大的体会是:技术栈选得稳,确实能省掉很多折腾。T3 Stack 给我的不是"哪个库更酷"的兴奋感,而是"所有环节都长在同一个语义体系下"的踏实感。这种踏实感在调试 bug 时特别明显,因为大部分错误不是藏在接口边界,而是出现在真实业务里,排查路径非常短。
如果之后继续迭代 t3code,我大概会先补上两件事:一是给片段加 Markdown 描述渲染,让说明文字能格式化展示,而不是纯文本;二是做一个公开 API,让其他工具可以按 token 方式读取片段。代码片段管理这种工具,真正的价值在于"用起来顺手",而这取决于你对细节的把控,比如搜索是否够快、收藏是否流畅、复制是否一步到位。对我个人来说,这也是做这个项目最上瘾的部分:每次改完一个小体验,都能立刻感受到自己写的东西在变好。