"t3code"这个项目名,第一次看到的人可能会愣一下,熟悉T3技术栈的同行却能立刻会心一笑。它本质上是我花了一个多月做的一个代码片段收藏与管理工具——用Github账号登录,把平时散落在浏览器书签和GitHub Gist里的代码片段统一收进来,支持打标签、按语言筛选、全文搜索,还能控制片段公开或私密。技术栈上我选了T3这套组合:Next.js、TypeScript、tRPC、Tailwind CSS,数据层用Prisma,认证直接用NextAuth接GitHub。名字就顺手取成t3code,既是"T3技术栈的代码项目",也是"TypeScript、Tailwind、tRPC三件事一起上"的简写。
这篇文章不是官方文档的复述,而是我把这个项目从0到1跑通之后的完整复盘:包括为什么这样选型、核心功能是怎么一层层落地的、部署到线上时踩了什么坑,以及三个让我折腾到深夜的问题的排查全过程。如果你是刚接触T3技术栈的人,这里会有足够多的实操细节;如果你已经在用这套组合,希望能给你一些可参考的取舍思路。
1. 为什么会有"t3code":一个代码片段工具的诞生背景
1.1 散落在浏览器书签里的知识,根本没法用
这个项目最初的动机特别朴素。我写前端写过几年,浏览器书签栏里攒了几百个链接,收藏夹分类从"React技巧"到"CSS奇技淫巧"有好几十个文件夹。真到要找一个东西的时候,麻烦就来了:书签只能按标题和URL搜,想搜"当时收藏的那个用IntersectionObserver实现滚动加载的片段",标题里根本没有这些词;想按语言过滤更不可能。GitHub Gist虽然能存代码,但对"整理"这件事支持得不够——没有自定义标签体系,没有私密/公开之外的可见性控制,更没有一个我能接受的浏览界面。
1.2 T3这套技术栈,恰好能覆盖这个工具的方方面面
一开始我并没有直奔T3,而是纠结了一阵子后端到底用什么。我的需求其实很明确:需要第三方登录、需要数据库存片段和标签、需要一套简单的增删改查接口、需要列表页和详情页。如果按传统思路来,我得单独起一个Node服务,写REST接口,定义一套DTO,再在前端手动维护类型。项目还没开始,光胶水代码就能写一大堆。
T3技术栈最大的价值就在这里:tRPC让前端可以直接调用后端函数,类型从头到尾是同一份;Prisma把数据库表结构变成可读的schema文件;NextAuth解决了OAuth登录那一堆繁琐流程;Next.js负责页面渲染和API路由的挂载。四个东西配合起来,我这个"一个人的全栈项目"不需要再维护两套工程,一个Next.js应用就能全部搞定。
1.3 功能边界:上线第一版只做了四件事
我也很清楚,这种工具如果不控制功能范围,半年都上不了线。所以第一版把功能死死锁定在四个模块:
- GitHub账号登录、退出,未登录用户只能看公开片段
- 创建、编辑、删除代码片段(标题、说明、代码内容、语言、标签、可见性)
- 列表页支持按语言筛选、按关键词搜索、按时间排序
- 详情页展示代码,使用代码高亮组件展示语法着色
至于团队协作、评论、富文本编辑、浏览器插件,这些全部砍掉。我对自己设置的规则是:如果某个功能不影响"自己高效地存代码、找代码"这个核心目标,就不做。
1.4 这篇复盘适合谁看
如果你是刚接触T3技术栈的人,这篇文章可以当作一个真实项目的参考路径;如果你已经在用create-t3-app写东西,可以直接跳到第4节和第5节看部署和踩坑部分;如果你只是想找一个"一个人也能完成的全栈小项目"练手,那t3code的整个设计思路应该能给你一些启发。下面我开始按照实际开发顺序,把每部分的决策过程和实现细节写清楚。
2. 初始化与目录设计:create-t3-app给我们留下了什么
2.1 从一行命令到可运行项目
新项目还是用官方脚手架起步最省事。执行npm create t3-app@latest t3code之后,会进入一个交互式选择界面,让你确认要启用哪些模块。我当时的勾选情况是:
- Next.js:必选
- TypeScript:必选
- Tailwind CSS:必选,样式方案
- tRPC:必选,前后端调用通道
- Prisma:必选,数据层
- NextAuth.js:必选,登录认证
- ESLint、Prettier:顺手勾上
这里有个抉择值得说一下:create-t3-app新版提供了App Router和Pages Router两种Next.js路由方案的选项。我最终选的是Pages Router。原因很实际:T3技术栈的经典教程和大量社区示例都基于Pages Router,tRPC在Pages Router下的集成方式最成熟,遇到问题的时候能找到的参考最多。App Router更新潮,tRPC官方也有适配方案,但对于一个以"稳定跑通"为第一目标的个人项目,选更成熟的路线反而节省时间。
脚手架跑完后,npm run dev就能在本地看到默认首页。这个首页看起来简单,但底下的目录结构已经把一套经过设计的分层逻辑打包好了。
2.2 目录结构解读:分层思想的一堂课
项目跑起来后,我认真翻了生成的目录结构,几个关键路径的作用在项目里被充分体现出来:
src/pages:所有页面组件都在这,负责把用户请求转换成界面src/pages/api:Next.js的API路由,是tRPC和NextAuth暴露HTTP入口的地方src/server:服务端专属代码,包括Prisma实例、认证配置、tRPC基础src/server/api/routers:业务逻辑的router,实际处理数据的读写src/utils:客户端工具函数,比如tRPC的客户端封装prisma:数据库schema文件和迁移记录
这套分层的核心逻辑是:页面层不应该直接碰Prisma或数据库,它只能通过tRPC router的公开接口拿数据。刚开始写代码时我还不太适应这种"绕一圈"的方式——明明直接在getServerSideProps里查库更直接。但用了一个星期后我理解了这样设计的原因:如果页面直接查库,一段逻辑可能被复制到多个页面;而统一收口在router里,所有数据访问路径都变得可追查、可复用、可测试。
2.3 技术选型里的隐性决策:为什么是它们而不是别人
create-t3-app虽然是脚手架,但它把一些技术选型决策替你做了。在深入写代码之前,我把这些决策重新审视了一遍,确保自己是"理解之后接受",而不是"默认接受"。
先说Prisma和Drizzle的选择。Drizzle更轻量、更贴近SQL,数据库模型直接写在TS代码里,对喜欢原生SQL的开发者很有吸引力。但我最终仍然用Prisma,理由有三个:第一,Prisma Schema的声明式写法让表结构非常直观,非本项目的开发者看两分钟也能懂;第二,迁移工作流成熟,prisma migrate dev、prisma migrate deploy这套流程在小项目和正式项目里都走得通;第三,社区资料量大,我在这个项目里遇到的所有模型问题,几乎都能搜到现成的解决方案。
再说NextAuth和第三方托管登录服务的取舍。Clerk、Auth0这类服务确实省事,但引入一个外部身份服务,意味着用户数据逻辑被拆到两个地方。t3code是个小工具,我希望用户、账号、片段之间的关联能全部在自家数据库里完成,NextAuth正好能做到——它提供OAuth流程,但用户表可以完全由Prisma管理。
最后解释一下为什么不做前后端分离。t3code的体量撑不起两个独立服务的复杂度。Next.js一个应用同时承担页面渲染和API入口,部署就是一个应用,环境变量也是同一套,对个人项目来说性价比最高。
3. 核心功能落地的完整链路:认证、数据模型与API
3.1 Auth:NextAuth接入GitHub登录的配置细节
认证部分是很多T3新手最先碰壁的地方,其实步骤非常标准化。我做的第一件事是在GitHub上创建OAuth App,拿到Client ID和Client Secret。这一步最大的坑是回调地址必须填对:本地开发要填http://localhost:3000/api/auth/callback/github,线上要填你实际部署域名下的同样路径。这个地址多了/api/auth/callback/github这一段,很多人第一次会漏掉,导致登录后回调失败。
NextAuth的配置集中在src/server/auth.ts文件里。核心逻辑是定义一个authOptions对象,声明用哪些Provider,再导出一个getServerSession包装函数给服务端用。代码如下:
import { type GetServerSidePropsContext } from "next"; import { getServerSession } from "next-auth"; import { GithubProvider } from "next-auth/providers/github"; import { env } from "~/env.mjs"; export const authOptions = { providers: [ GithubProvider({ clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, }), ], callbacks: { async session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }, }, }; export function getServerAuthSession(ctx: { req: GetServerSidePropsContext["req"]; res: GetServerSidePropsContext["res"]; }) { return getServerSession(ctx.req, ctx.res, authOptions); }准备工作里最容易忽略的是NEXTAUTH_SECRET。开发模式下不配也能跑,生产环境必须设置一个足够长的随机字符串,否则每次部署后会话都会失效。我在项目里用openssl rand -base64 32生成了一份,然后写进.env文件,再配置到部署平台的环境变量里。
3.2 Data:Prisma Schema设计,从零定义Snippet模型
认证搞定之后,重点转移到数据模型。t3code的核心是一个名为Snippet的模型,加上NextAuth自带的用户相关模型。我打开prisma/schema.prisma文件,先补充关系,再定义新模型:
model User { id String @id @default(cuid()) name String? email String? @unique image String? snippets Snippet[] accounts Account[] sessions Session[] } model Snippet { id String @id @default(cuid()) title String description String? code String @db.Text language String @default("plaintext") visibility Visibility @default(PRIVATE) tags String[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) @@index([language, visibility]) @@index([authorId, createdAt]) } enum Visibility { PUBLIC PRIVATE }这个数据结构有几个设计点值得展开。tags我直接用了字符串数组字段,而不是单独建一张标签表——因为第一版只需要"按标签过滤"和"展示标签列表",拆表会增加不必要的join操作,等标签真正需要独立管理时再重构也来得及。visibility用枚举而不是布尔字段,因为未来可能扩展出"仅链接可见"之类的更多状态。@db.Text让code字段能存长文本,MySQL里默认的varchar有长度上限,代码片段很容易超。索引方面,@@index([language, visibility])是列表页最常用的过滤路径,@@index([authorId, createdAt])则保证"我的片段"页按时间倒序查询走索引。
写完schema执行迁移:
npx prisma migrate dev --name init_snippets这个命令会自动生成本地迁移文件,同时重新生成Prisma Client。需要注意一个小习惯:改完schema和业务代码后,我会再跑一次npx prisma validate来提前发现语法问题,免得等到运行时才报错。
3.3 API:tRPC Router如何把前后端写进同一份类型安全
数据模型定好后,API层是让整个项目真正"转起来"的部分。在t3code里,所有数据请求都通过tRPC完成。初始化时src/server/api/trpc.ts已经帮我们定义了三个基础构件:createTRPCRouter用于创建router,publicProcedure用于公开查询,protectedProcedure用于需要登录的请求。
业务router放在src/server/api/routers/snippet.ts里。我来展示最核心的创建和查询逻辑:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; const createSnippetInput = z.object({ title: z.string().min(1), description: z.string().optional(), code: z.string().min(1), language: z.string().default("plaintext"), tags: z.array(z.string()).default([]), visibility: z.enum(["PUBLIC", "PRIVATE"]).default("PRIVATE"), }); export const snippetRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { if (!ctx.session?.user?.id) { return ctx.prisma.snippet.findMany({ where: { visibility: "PUBLIC" }, orderBy: { createdAt: "desc" }, }); } return ctx.prisma.snippet.findMany({ where: { OR: [ { visibility: "PUBLIC" }, { authorId: ctx.session.user.id }, ], }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure.input(createSnippetInput).mutation(async ({ ctx, input }) => { return ctx.prisma.snippet.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), });这段代码的重点是输入校验和权限控制。z.string().min(1)这类约束保证了前端传空字符串时后端直接拒绝;protectedProcedure则让未登录用户无法调用create接口。数据流很简单:客户端调用api.snippet.create.useMutation(),传进去的对象在前端就已经通过zod校验,类型推导也自动同步,前端改了输入结构,后端类型马上报错,不用等运行期。
这里我补充一下为什么tRPC能带来这种体验。它的核心概念是把每一个query和mutation都当作一个带有类型的端点,前端通过一个类型化的客户端调用这些端点。相比手写REST接口然后在前端重复定义DTO的做法,tRPC直接消除了"接口文档漂移"这个恒久的问题——代码即契约。
3.4 UI:列表页、详情页与代码高亮的实现细节
数据链路打通后,界面部分反而是最顺的。页面用Tailwind写,主要工作是设计一个干净的卡片式列表和一个专注的详情页。
列表页的数据获取方式,我用的是tRPC提供的React Query封装。以首页为例:
import { api } from "~/utils/api"; export default function HomePage() { const { data, isLoading, error } = api.snippet.getAll.useQuery(); if (isLoading) return <div>加载中...</div>; if (error) return <div>加载失败:{error.message}</div>; return ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> {data?.map((snippet) => ( <div key={snippet.id} className="rounded-lg border p-4"> <h2 className="text-lg font-medium">{snippet.title}</h2> <p className="text-sm text-gray-500">{snippet.description}</p> <div className="mt-2 flex flex-wrap gap-1"> <span className="rounded bg-blue-50 px-2 py-0.5 text-xs"> {snippet.language} </span> {snippet.tags.map((tag) => ( <span key={tag} className="rounded bg-gray-100 px-2 py-0.5 text-xs"> #{tag} </span> ))} </div> </div> ))} </div> ); }代码高亮我选了react-syntax-highlighter。选它而不是Prism.js的React封装,是因为它在服务端渲染时对SSR的兼容性好,体积也相对可控。详情页直接被传入的code字符串用对应语言渲染,日常使用下来高亮稳定。
到这里,t3code的核心功能已经能用了:登录、创建片段、列表展示、详情页高亮。但"本地能跑"和"上线后也能跑"之间还有一段距离,下面这部分是这次项目里价值最高的一段经验。
4. 从本地到线上:部署配置与性能实测
4.1 环境变量三分法:本地、预览、生产必须分开管
我在这个项目上最惨痛的教训,几乎都集中在环境变量。开发阶段只有一个.env文件,写起来很随意。真正上线部署时,问题立刻暴露:同一个DATABASE_URL在本地指向本地数据库,在线上要指向云数据库;GITHUB_CLIENT_ID和GITHUB_CLIENT_SECRET本地和线上对应不同的OAuth App;NEXTAUTH_URL更不用说,环境不同值就不同。
我把部署平台的环境变量分成三套来管理:
| 环境 | 数据库连接 | GitHub OAuth | NEXTAUTH_URL | 用途 |
|---|---|---|---|---|
| 本地开发 | localhost | localhost应用 | http://localhost:3000 | 日常写代码 |
| 预览部署 | 开发分支数据库 | 同一个线上应用 | https://preview域名 | PR检查 |
| 生产环境 | 主数据库 | 线上应用 | 正式域名 | 对外服务 |
这里最容易被忽略的是预览环境。很多人在Vercel上只给主分支配了环境变量,一提交PR,系统生成的预览站就构建失败——因为DATABASE_URL为空,Prisma在build阶段直接报错。我在t3code项目里提前把预览和生产的环境变量都配好了,才避免了每开一个PR就要查一次构建日志的尴尬。
4.2 数据库托管与部署平台的配合
数据库这一层,我使用了和T3官方推荐风格一致的托管MySQL服务,主要是因为省心:不需要自己维护数据库服务器,连接字符串直接在控制台拿。实际操作流程大概是:
- 在数据库托管平台创建数据库
- 打开连接字符串,填入本地
.env的DATABASE_URL - 执行
npx prisma db push或npx prisma migrate deploy同步表结构 - 把连接字符串替换进Vercel项目环境变量
这里有个策略问题:生产数据库的表结构用哪种方式同步?prisma db push简单直接,但它是无状态的——数据库和schema文件完全对齐,不记录迁移历史;prisma migrate deploy则依赖迁移文件,顺序执行更可控。t3code上线初期我直接用db push跑了一次,后续每次改schema都走migrate deploy,这样生产环境的表结构变化是可追溯的。
部署本身的步骤反而不复杂:Vercel导入Git仓库,Framework Preset会自动识别为Next.js,build命令默认即可。唯一需要做的就是把所有环境变量填进项目配置,然后点部署。等构建完成后,绑定域名,加SSL证书,一整套流程大约十分钟。
4.3 性能实测:一次针对首页的优化记录
项目上线后,我用Lighthouse粗略跑了一遍首页性能,初始结果并不差,但在列表接口耗时上仍有明显优化空间。我把优化前后的数据记录下来作为参考:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首屏完全加载时间(本地) | 约1.8秒 | 约1.1秒 |
| Lighthouse Performance(移动) | 约72分 | 约89分 |
| 列表接口P95延迟(有缓存) | 约420ms | 约180ms |
| 首页数据请求次数 | 2次(SSR+客户端) | 1次 |
主要做了几个优化。第一,详情页不再在列表数据里包含完整code字段,列表页只返回标题、描述、语言和标签,代码内容单独在详情接口取,列表接口加载的数据量降了一个数量级。第二,给列表查询配置了较宽松的staleTime和revalidateOnFocus: false,避免每次鼠标切回页面都重新请求。第三,给详情页加了Progressive Enhancement的思路——服务端先渲染,客户端再水合,代码高亮在客户端接管后动态渲染较大的高亮组件,避免阻塞首屏。
这套组合下来,日常使用已经感受不到"网络加载"的等待感,基本是点击即出。
5. 记录下来的三个坑:排查思路比答案更重要
5.1 登录回调地址失效,问题不在代码而在配置
项目部署完成后的第一次测试,我点了GitHub登录,整个流程走完,页面跳回了首页,可是右上角却显示未登录状态。一开始我怀疑是NextAuth配置代码写错了,翻来覆去检查了authOptions好几遍也没发现异常。
后来我打开了浏览器DevTools的Network面板,查看/api/auth/session接口返回的数据,发现它返回的是空对象,说明会话并没有建立成功。再往前追溯,检查重定向时访问的是https://t3code.example.com/api/auth/callback/github?code=xxx,状态码居然是404。
到这一步基本锁定了问题:GitHub OAuth App里的Callback URL没有加上/api/auth/callback/github这一段,只填了域名。回调时GitHub把用户送到了不存在的路径,NextAuth自然接不到code。修正方法很简单,去GitHub应用设置里把回调地址改齐全。这不算代码问题,纯粹是配置疏忽,但排查链路让我养成了一个习惯:出了认证问题,先看回调到的URL路径对不对,再看OAuth回调日志,最后才怀疑代码。
5.2 tRPC mutation提交后页面不刷新,到底是谁的问题
第二个坑发生在创建片段功能完成后。我在创建页面提交了表单,数据确实写进了数据库,控制台也能看到成功的响应,但跳转回列表页后,新增的片段就是不出来。刷新浏览器才出现。
第一反应我以为是自己页面缓存了静态内容,排查了一圈,最后意识到问题出在React Query缓存。tRPC的useQuery在初次加载成功后会缓存数据,同一个key的查询在短时间内不会自动重新请求。创建成功后,列表页的查询缓存还停留在旧状态,自然不显示新数据。
解决办法是在mutation成功后让相关查询失效:
const ctx = api.useUtils(); api.snippet.create.useMutation({ onSuccess: () => { ctx.snippet.getAll.invalidate(); }, });一行invalidate(),缓存里的旧数据被标记失效,React Query会重新拉取列表。这个坑的本质是对React Query缓存模型不熟悉,跟tRPC本身关系不大。但这也说明了为什么tRPC要和React Query搭配使用——它的整套数据获取心智模型是建立在这个缓存机制之上的。
5.3 并发创建时唯一约束冲突,数据库层兜底不可少
第三个坑比较微妙。测试的时候,我快速点了两次"保存"按钮,结果发现同一条片段被创建了两条。我的第一反应是给前端加isSubmitting状态,在请求进行中禁用按钮,这样能解决绝大多数用户手滑的情况。
但深想一层,这并不能算真正解决。如果用户用脚本调用接口,或者网络超时后用户又点了一次,仍然可能产生重复数据。真正的兜底应该放在数据库层,比如给Snippet加一个clientToken字段并建立唯一索引,每次创建时生成一个唯一标识,数据库保证相同标识的请求只会创建一个记录。当然,这会让模型设计复杂一些。我在t3code里采取的是折中方案:前端状态限制加上mutation的onSuccess处理,后端用zod对提交内容做字段级校验。对个人工具来说这个强度够用了,但如果你在做多人产品,这个数据库唯一索引兜底建议提前设计进去。
这个问题的排查过程也提醒我一个通用原则:并发问题要按"前后端各防一层"的思路处理,前端提升体验,后端保证数据正确。
5.4 写代码时保持下来的两个小习惯
踩完这些坑之后,我给自己定了几条规矩,写代码的效率确实提高了:
- 每次修改
prisma/schema.prisma后,立刻跑npx prisma validate和npx prisma generate,不在运行时才暴露问题 - 每次修改router的输入输出时,先更新zod schema,再去写页面调用代码,让TypeScript在开发期就暴露类型不匹配的问题
这两条习惯看似简单,实际省掉的是大量"明明数据有问题却不知道在哪一层出错"的排查时间。
6. 如果你想复刻一个"t3code",我给你三条实在建议
6.1 先把最小闭环跑通,再谈功能丰富
不要一开始就想着把所有功能做完。我给自己的节奏是:先实现"登录、创建片段、列表展示、详情页"这四件事,跑通了等于整个技术栈都验证了一遍,后面再加标签、搜索、筛选只是往稳定的地基上加砖。如果一开始就同时做搜索、标签、代码高亮、暗色模式,最后大概率是项目烂尾。
6.2 环境变量和部署问题前置,越早解决越省事
很多开发者在写业务代码前不配部署平台,等到全部写完才发现环境变量缺失、数据库连不上、OAuth回调配置不对,被迫回头排查环境问题,体验非常差。我的做法是在建完项目后的第二天就把Vercel部署和数据库托管配好,哪怕当时首页还是脚手架生成的默认页,至少"上线"这条链路是通的。之后每加一个模块,只需要增量部署验证,不用再担心环境问题。
6.3 把React Query的缓存机制当成必修课去理解
这个项目的绝大部分"疑难杂症"都来自对React Query缓存行为的不理解。与其等出了问题再查文档,不如在写第一个useQuery之前,认真花一小时弄懂staleTime、cacheTime、invalidate、refetch这些概念。理解了这套模型,tRPC的体验才能真正发挥出来——否则你会有一种"数据总是不对"的错觉,以为框架有bug,其实只是缓存策略没选对。
我自己的体会是,t3code这个项目虽然不大,但它把现代全栈开发里类型安全、数据流、部署配置这几块硬骨头都过了一遍。技术栈不会替你解决所有问题,但它会把问题减少到你能够直接动手解决的那一层。下一步我打算给它加上全文搜索和浏览器插件,到时候再来分享新的坑和方案。