t3code 这个项目,拆开看就是 T3 Stack 和 Code 的组合,说白了就是拿社区里那套很出名的全栈技术组合,去做一个在线代码片段管理与分享工具。我把它定位成一个既能日常自用、又能当全栈练手项目的作品:用户可以快速创建代码片段、打标签、在线编辑、高亮预览,还能把片段公开分享给同事或网友。对于正在学 Full-Stack、想一口气把 Next.js、TypeScript、Prisma、tRPC 这些串起来的朋友来说,t3code 算是一条很完整的“真项目”链路,不是那种每个文件讲一遍的 Demo,而是真的能放上线跑的东西。这篇文章我就把整个项目从选型到落地的完整过程,包括过程中踩过的坑,都复盘一遍。
1. 技术选型与架构思路:为什么是 T3 Stack
1.1 T3 Stack 是什么,它真正解决了什么问题
T3 Stack 不是某个框架,而是一组围绕 TypeScript 的全栈技术组合,核心成员就三个:Next.js 负责页面和渲染,Tailwind CSS 负责样式,tRPC 负责 API 层,再配一个数据库和 ORM。和“前后端分离 + REST API”的传统开发方式比,T3 Stack 最大的卖点是端到端类型安全——从前端组件到后端数据库查询,中间没有任何手写的 d.ts、interface 重复声明,IDE 提示全链路一致。
实际做过项目的朋友应该体会过这种痛苦:后端写了个接口getSnippet(id: string),前端调用时手滑传了个number,编译期根本发现不了,直到线上报 500。REST API 时代,我们用 OpenAPI、GraphQL 这类工具来生成类型,但那是额外的约束和工具链成本。tRPC 的做法是让“函数调用”直接跨越前后端边界,你把一个后端函数标成 router,前端 import 后直接调用,类型自动继承,字段改错了编译直接报错。
我选择这套组合的原因还有一个:它是目前少有的、把类型安全从数据库贯穿到 UI 组件的实践。Node.js 生态里类型安全往往是分层的,Prisma 保证数据库层类型,Zod 保证输入校验类型,React 里又有自己的 props 类型,如果没有 tRPC,这些层之间全靠手动力对齐。T3 Stack 把这几层用 tRPC 粘起来了:Prisma 返回的类型直接作为 router 的 outputs,前端拿到的就是精确的字段结构,中间不需要序列化、不需要写类型定义、不需要调用文档,开发体验非常顺滑。
1.2 t3code 的整体技术架构与数据流设计
t3code 不是千篇一律的 Todo Demo,我把它设计成一个代码片段管理工具,核心数据模型有三个:Snippet(代码片段)、Tag(标签)、Collection(收藏夹,用于分组)。关系上,一个 Snippet 可以有多个 Tag,可以用 Collection 做归类,用户体系则用 NextAuth 接入 GitHub 账号登录。
整个数据流是单向且清晰的:浏览器里的 React 组件直接调用 tRPC 的 router 方法(比如snippetRouter.create),tRPC 在服务端执行时会先用 Zod schema 校验输入,然后通过 Prisma Client 访问数据库,再把结果返回给前端。前端如果想要更新数据,不需要刷新页面,只需要调用trpc.useUtils().snippet.invalidate()让 React Query 重新拉一次。
从目录结构上也能看出这套架构的松耦合和类型安全:
t3code/ ├── prisma/ │ ├── schema.prisma # 数据库表结构定义 │ └── migrations/ # 数据库迁移记录 ├── src/ │ ├── server/ │ │ ├── api/routers/ # tRPC 路由(snippetRouter、tagRouter 等) │ │ ├── auth/ # NextAuth 配置 │ │ └── db.ts # Prisma Client 实例 │ ├── components/ # React 组件(Editor、SnippetCard、TagSelect) │ ├── pages/ # Next.js 页面路由 │ └── styles/ # Tailwind 全局样式 └── package.json这里有个很关键的设计取舍:tRPC 不是把所有逻辑都堆在一个 router 里。我按领域拆分 router,比如snippetRouter.handle负责片段的 CRUD,tagRouter负责标签自动补全,collectionRouter负责收藏夹操作。这样可以防止路由文件膨胀,也让后面做单元测试时能按模块 mock,逻辑边界清晰很多。
1.3 为什么选择 SSR + tRPC,而不是纯前端 + REST API
我在立项时其实纠结过要不要用 Node 写一个独立的 API 服务,前端再单独部署,毕竟那种“微服务架构思想”听上去更规范。但实际想清楚场景后,我果断放弃了。t3code 的核心使用场景是:个人快速记录片段、团队分享、社区浏览,这类应用的特性是服务端渲染有利于 SEO 和首屏速度、数据读取频率远高于写入频率、没有复杂权限矩阵。
如果强行用 REST API,你至少得维护两套 API 文档、中间层类型定义,还要考虑跨域 CORS。而 tRPC + SSR 模式下,数据请求发生在 Next.js 的getServerSideProps或 Server Components 里,浏览器拿到的已经是渲染好的 HTML,天然没有跨域问题;页面跳转之间的数据同步用 React Query 管理,缓存、去重、失效策略都是现成的。
另外,安全边界也更好控制。tRPC router 每个方法都会在服务端执行,你可以在方法前面加入鉴权中间件,比如protectedProcedure要求用户已登录才能创建 snippet,未登录只能浏览公开片段。这个逻辑是模板代码,但它在 tRPC 的封装语义里非常直观,阅读代码的人一眼就能看出“这个操作需要什么前置条件”。
所以最终我给 t3code 定的架构基调是:单体 Next.js 应用,Prisma 统一管理数据库模型,tRPC 让类型贯穿全栈,Tailwind 解决样式和暗黑模式。复杂度刚刚好,够支撑真实业务,又不至于让初学者劝退。
2. 核心实现细节:从脚手架到业务功能落地
2.1 用 create-t3-app 初始化项目,哪些选项必须选
搭建 t3code 第一步就是用脚手架起步,我不想手动拼这些技术栈,create-t3-app 帮你把 Next.js、tRPC、Tailwind、Prisma、NextAuth 全部配置好,还自带严格的 TypeScript 配置。命令行很简单:
npm create t3-app@latest t3code进入交互式配置后,它会问你要不要开启 App Router、是否启用 NextAuth / Prisma / Tailwind / tRPC。我的建议是第一次做全栈项目就全选上,因为哪怕暂时用不到,这些模块的脚手架都给你搭好了,后面加功能时不会因为缺依赖而卡住。
真正要仔细看的是“严格 TypeScript”选项。脚手架默认会打开noUncheckedIndexedAccess和strictNullChecks,这俩一起开,会让编译变得严格很多——比如从数组里取元素,类型是T | undefined,你必须处理 undefined。我在 t3code 项目里就因为这俩选项,少了一堆线上崩溃,代价是写代码时必须注意边界情况。这个严格模式千万别关,后面你会感谢它。
初始化完成后先跑一下开发环境确认一切正常:
cd t3code npm run dev打开http://localhost:3000,能看到默认的 tRPC 健康检查页面,说明脚手架没问题。接下来才是真正的项目代码。
2.2 数据库模型设计:Prisma Schema 怎么写才不后悔
我见过太多项目,表结构设计没想清楚,写着写着就开始 migration 地狱。t3code 的 schema 设计我花了很多心思,最终核心模型大概是这样的:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? snippets Snippet[] collections Collection[] } model Snippet { id String @id @default(cuid()) title String code String language String @default("plaintext") description String? isPublic Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) tags Tag[] collections Collection[] } model Tag { id String @id @default(cuid()) name String snippets Snippet[] } model Collection { id String @id @default(cuid()) name String userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) snippets Snippet[] }几个设计要点:
- ID 直接使用 cuid 而不是自增整数。cuid 在分布式和公开接口里更安全,无法被猜测和遍历。代码片段如果 id 是自增的,别人能枚举你的所有片段,这很危险。
- 多对多关系用显式关系表,虽然 Prisma 可以生成隐式多对多,但后面要加加字段(比如收藏时间)就得重构,不如一开始就显式定义。
isPublic字段必须在数据库层做过滤,不能只靠前端控制显示隐藏,否则别人可以绕过 UI 直接调用 tRPC 方法查看。
写完后跑迁移命令:
npx prisma migrate dev --name init npx prisma generate这两个命令一个负责建表,另一个负责生成 TypeScript 类型定义。注意prisma generate必须跑,不然 tRPC 里的 Prisma 类型是旧的。
2.3 tRPC Router 编写:核心代码是如何组织的
t3code 的业务代码集中在src/server/api/routers/目录下,每个领域拆一个 router 文件。以 snippetRouter 为例,最核心的几个方法:
export const snippetRouter = createTRPCRouter({ getAll: publicProcedure .input( z.object({ limit: z.number().min(1).max(50).default(10), cursor: z.string().optional(), }) ) .query(async ({ ctx }) => { return ctx.db.snippet.findMany({ where: { isPublic: true }, orderBy: { createdAt: "desc" }, take: limit + 1, // 多取一条判断有没有下一页 cursor: cursor ? { id: cursor } : undefined, include: { tags: true, user: { select: { name: true } } }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), code: z.string().min(1), language: z.string().default("plaintext"), tags: z.array(z.string()).max(10).default([]), isPublic: z.boolean().default(false), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { title: input.title, code: input.code, language: input.language, isPublic: input.isPublic, userId: ctx.session.user.id, tags: { connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })), }, }, }); }), delete: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { const snippet = await ctx.db.snippet.findUnique({ where: { id: input.id } }); if (!snippet || snippet.userId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "操作无权限" }); } return ctx.db.snippet.delete({ where: { id: input.id } }); }), });几个实测下来的心得:
Zod 的 schema 其实就是你的接口文档。你没写 JSON Schema,没写 OpenAPI,但 Zod 已经在编译期定义了所有输入的规则。团队协作时,别人去看这个文件就能知道这个 API 接受什么、返回什么,而且不需要额外维护文档,z.string().min(1)本身就是“不能为空”的活注释。
分页不要用 skip/take 的页码制,用 cursor 制。t3code 这个场景下,代码片段是持续增长的,用户大概率是“滚动加载更多”,而不是“跳到第 15 页”。Cursor 分页在数据量大时性能更稳定,而且不会因为中间插入新数据导致某一页数据重复或遗漏。
protectedProcedure 是 NextAuth + tRPC 边框的私货。create-t3-app 已经给你准备好了一个protectedProcedure中间件,它的作用就是在进入 mutation 前检查ctx.session里有没有登录用户,没有就抛 UNAUTHORIZED。我后来在每个涉密操作前都套一层,这样即使前端按钮漏写if (!user) return,后端仍然安全。
2.4 前端交互与代码高亮:如何把“编辑器”体验做好
t3code 是代码工具,前端的编辑器和展示效果如果不专业,整体质感会大打折扣。这里我建议直接复用成熟方案,千万不要自己写文本编辑器的逻辑。
编辑器我选的是Monaco Editor(就是 VS Code 的编辑器内核),支持 TypeScript 语法高亮、自动补全、错误提示、代码折叠。配合@monaco-editor/react这个 React 封装,接入成本很低:
import Editor from "@monaco-editor/react"; function SnippetEditor({ initialValue, language, onChange }) { return ( <Editor height="400px" defaultLanguage={language} theme="vs-dark" defaultValue={initialValue} onChange={(value) => onChange(value ?? "")} options={{ minimap: { enabled: false }, fontSize: 14, tabSize: 2, scrollBeyondLastLine: false, }} /> ); }列表展示时就没必要用重型的 Monaco 了,我用react-syntax-highlighter搭配 Prism 主题,只负责渲染高亮代码,体积小很多。这里有个小技巧:把语言枚举限制在常用范围内(比如 JavaScript、TypeScript、Python、Go、Bash、SQL、JSON),而不是把 Monaco 支持的几百种语言全部暴露出来,否则编辑器启动会明显变慢,语法补全列表也乱的。
暗黑模式我用 Tailwind 的darkMode: "class"策略,定义一个ThemeToggle组件,把状态存到 localStorage 并提供给next-themes管理。这样风格切换时刷新页面不会闪烁。
3. 实操过程与关键配置:从本地到线上的真实记录
3.1 NextAuth 鉴权配置:为什么不用自建登录
t3code 的登录系统,我直接用 NextAuth 接入 GitHub OAuth,这样省去自己管理密码、Token、找回密码、邮件验证这一大堆和业务无关的逻辑。NextAuth 的配置写在src/server/auth.ts里,核心是定义providers和callbacks:
export const authOptions: NextAuthOptions = { providers: [ GitHubProvider({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET, }), ], callbacks: { session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }, }, };这里的关键点是:NextAuth 默认的 session 里并没有用户 ID(为了安全只给了一部分字段),如果你的数据库操作需要userId,就必须在 callback 里手动把user.id塞进 session。我一开始没加这个,结果 tRPC 的ctx.session.user.id一直是 undefined,调 create snippet 接口报错报了半天,翻文档才发现这个坑。
另外 NextAuth 的依赖项@auth/prisma-adapter也要配好,它会自动在数据库里建 Session、Account、VerificationToken 这几张表。这样登录态和 Prisma 用户表就能衔接上,不需要自己写会话持久化。
3.2 项目启动与数据库连接:本地环境的陷阱
开发时数据库我用了 SQLite,生产环境再切换到 PostgreSQL。SQLite 的优点是零配置,npm run dev就能跑,适合开发迭代。但这里有一个坑:Prisma 在 SQLite 上的关系查询能力被削了一截,比如不支持createMany的部分场景,不支持某些并发写,所以我把“标签自动创建”写成了connectOrCreate循环,而不是异步并发;否则 SQLite 并发写可能报错。
如果你跟着做,启动本地环境只需要把.env里这段配上:
DATABASE_URL="file:./db.sqlite" NEXTAUTH_SECRET="随便一长串随机字符串" NEXTAUTH_URL="http://localhost:3000" GITHUB_ID="你的GitHub OAuth App ID" GITHUB_SECRET="你的GitHub OAuth App Secret"说到NEXTAUTH_SECRET,我见过有人直接复制命令行生成的随机字符串,结果部署到线上环境变量不一致,所有登录用户的 session 全部失效。我的做法是统一用一个环境变量,本地和生产用同一把 secret,或者至少固定一份各自独立的密钥且不自动更换,否则每次next build后 session 都会出问题。
3.3 部署上线的关键环节:Vercel + Polarized SQLite 换 PostgreSQL
t3code 最终部署到 Vercel,但本地用的 SQLite 不适合线上,因为 Vercel 的函数是无状态容器,文件系统是临时的,每次冷启动都可能丢失 SQLite 数据。我的方案是生产环境切到 PostgreSQL,数据库用免费的 Neon 云数据库(你也可以选 Railway 或 Supabase)。切换思路很简单:
- 在
prisma/schema.prisma里把 provider 从sqlite改成postgresql。 - 在
.env.production里设置DATABASE_URL为 PostgreSQL 连接串。 - 重新跑
npx prisma migrate deploy让生产数据库建表。 - 如果有初始数据需要导入,用
npx prisma db push --force-reset重置并重建。
Vercel 部署阶段有几个容易踩的坑:
- Build Command 必须加上 Prisma 迁移:在 Vercel 的 Build 设置里,把 Build Command 改成
npx prisma migrate deploy && next build,否则数据库表还没建好,构建时代码跑起来就会报找不到表。 - 环境变量必须配全:
DATABASE_URL、NEXTAUTH_SECRET、GITHUB_ID、GITHUB_SECRET一个都不能少。 - Prisma Client 的生成时机:
prisma generate要在 build 触发器里跑,我写成一个独立的脚本文件prepare.mjs,在postinstall钩子执行,避免在 Vercel 上因为依赖安装顺序导致 Prisma Client 缺失。
// package.json 摘选 { "scripts": { "prebuild": "prisma generate", "build": "prisma generate && next build", "start": "next start" } }3.4 上线前的安全检查:接口和权限再核一遍
上线前我系统地做了一轮安全自查,这里也列一下给参考:
- 所有
delete、update操作都校验了资源归属权,防止水平越权。比如删除 snippet 时,必须确认snippet.userId === session.user.id。 - 公开片段和私有片段在数据库查询层就隔离。list 接口里如果是未登录用户,强制
where: { isPublic: true };如果登录用户想查自己的私有片段,要有专门的getMySnippets方法,而不是在同一个接口里加个?onlyMe=true就完事,后者太容易被手动改参数绕过。 - 对输入长度做了限制:
title最长 100,code至少 1 个字符但最多不超过 100KB,防止有人塞一个几十万字的长文拖垮渲染性能。 - 对 tRPC 的 query 做了超时和缓存策略。公开片段列表用 React Query 设置
staleTime: 60_000,缓存一分钟,减少后端压力;私有片段因为变化频繁,不设置 staleTime,保证实时刷新。
这些安全习惯在真实项目里极其重要。很多新手教程都跳过了“权限校验”这一步,但一旦你把应用放上线,扫描器会特别照顾那些查询参数,因此不要抱侥幸心理。
4. 实际效果与关键技术指标
4.1 构建体积与首屏加载
沒优化前的首版,Monaco Editor 那一个包就占了 1.4MB(gzip 后约 400KB),首屏渲染明显卡。后来我做了两个优化:第一个是动态加载编辑器,只有用户进入“新建片段”或“编辑片段”页面时才加载 Monaco,列表页只加载轻量的react-syntax-highlighter;第二个是关闭 Monaco 用不到的扩展功能,比如suggest、parameterHints,对纯编辑场景影响不大,但能把主包进一步缩小。
优化后,t3code 的首页 JS 体积从 1.4MB 降到 481KB,Lighthouse Performance 评分从 61 分到 92 分左右,首屏可交互时间大概从 4.8 秒降到 1.9 秒。对这样一个以代码展示为主要内容的工具,体验提升是非常直观的。
4.2 数据库查询性能
因为使用了 Prisma + PostgreSQL,常用查询都有手写 index 保护。t3code 里 snippet list 最常见的查询是按时间倒序 + 标签过滤,我给Snippet表加了复合索引:
@@index([isPublic, createdAt(sort: Desc)]) @@index([userId, updatedAt(sort: Desc)])分别覆盖“公开片段按时间浏览”和“我的片段按更新排序”两个高频场景。加了索引后,即便有几百条公开片段,查询时间也能稳定在 10ms 级,拿 SQLite 本地开发时更是毫秒级别的响应。
数据量更大的时候可以再考虑全文搜索或 pg_trgm,但目前这个体量没必要上搜索引擎组件,加索引已经够了。
4.3 开发体验与迭代速度
用 T3 Stack 之后,最直观的感受是改字段特别安心。以前改一个接口返回结构,前后端、文档、mock 全要跟着动;现在只要改 Prisma schema,重新prisma generate,tRPC outputs 自动更新,前端组件签名报错会第一时间告诉我哪里要改。
举个具体例子:有一次我想在 Snippet 模型里加一个isPinned字段,操作步骤只有三步:改schema.prisma、跑迁移、前端加一个 Pin 按钮。整个过程大概 20 分钟,没改任何 API 路由的手写类型,也不需要去看别的模块的接口文档。这就是类型安全贯穿全栈的红利,真实的开发效率提升非常大。
5. 常见问题与排查技巧实录
5.1 NextAuth 一切正常但 session 里拿不到用户 ID
现象:tRPC protectedProcedure 里ctx.session.user.id报 undefined,但 email、name 都在。原因就是前面提到的,NextAuth 默认 session 没有携带用户 ID。解决方案已经在配置章节写过了:在callbacks.session里手动session.user.id = user.id。如果你已经加了这个仍不行,检查是否改多个 authOptions 文件,create-t3-app 可能在src/server/auth.ts和src/pages/api/auth/[...nextauth].ts都做了封装,你编辑的和你实际加载的可能是两个对象。
5.2 tRPC 报错 “Unable to resolve @trpc/server” 或类型对不上
这种问题通常是版本差异。create-t3-app 默认用的 tRPC 是 v10+,如果你的代码是从 v9 老教程里复制过来的,类型泛型会完全对不上。排查思路是先npm ls @trpc/server确认版本,再检查是否在项目里混装了多个 tRPC 版本。另一点:tRPC 客户端类型是服务端类型推导出来的,前端 import 时要保证是import type { RouterOutputs } from "~/server/api/root";,不能直接从某个组件文件里导,否则会有循环依赖或者拿不到最新类型。
5.3 Prisma 报错 “Invalidprisma.snippet.create()invocation”
这类问题一多半是模型关联没有写对,比如tags字段要求传connectOrCreate或connect,不能直接传 string 数组。我在过程中把input.tags.map()返回的对象直接塞进tags: input.tags里,结果 Prisma 编译器报了一长串错误。按 Prisma 的标准写法,改成tags: { connectOrCreate: input.tags.map(...) }就好了。遇到这类问题,优先看 Prisma 返回的报错详情,它会把期望的字段结构打印出来,照着叉口补就行。
5.4 部署到 Vercel 后数据库表一直不存在
检查了三遍.env才发现在 Vercel 控制台里把DATABASE_URL配到了 Preview 环境,Production 环境用的还是旧变量。Vercel 的环境变量按分支分环境,本地预览和生产环境是两套。一定要在 Production 那栏也粘贴一遍数据库连接串,别只在 Development 或 Preview 里加。另一个坑是prisma migrate deploy的时机,如果设置到 Vercel 的 install command 后面,可能因为脚本还没跑到就报错;我最后把它放在 build command 靠前位置才稳定。
5.5 代码高亮在 SSR 阶段报错 “window is not defined”
react-syntax-highlighter的一些组件依赖浏览器 API,在服务端渲染时会报 window 未定义。解决办法是改成动态加载不执行 SSR,直接在组件里加一行:
const SyntaxHighlighter = dynamic( () => import("react-syntax-highlighter").then((m) => m.Prism), { ssr: false } );或者用lightweight-build版本,也能避开这个问题。
6. 后续扩展方向与实际体会
t3code 做到现在这个版本,已经能在日常工作中解决实际问题了,但扩展空间也很大。我目前最想做的是两个方向:
一是分享链接的时效与访问控制,比如给某个 snippet 生成一个短链,短链可以设置过期时间、密码保护。这就涉及独立的 Route 和专门的校验逻辑,T3 Stack 的 tRPC + NextAuth 组合刚好能无缝接上。
二是全文搜索,虽然当前标签已经做了过滤,但代码内容里的关键字搜索很刚需。PostgreSQL 自带的全文检索在这个体量完全够用,不需要再引搜索服务,重点是把language字段和title、code的权重分好。
再分享一个做这类全栈项目的真实心得:不要试图在一个项目里同时把所有功能做到完美。t3code 最核心的两件事——片段保存、片段展示——做到流畅耐用,已经比很多产品的首版有价值了。功能可以后面迭代,但基础架构(类型安全、权限边界、数据库设计)一定要在第一步就做好,不然越往后越难改。我自己在第二版加收藏夹功能时,只花了大概半天时间,就是因为第一版 schema 留了足够合理的扩展点。
如果你也想从零搭一个完整作品,直接把 t3code 的步骤抄过去,从脚手架到部署按理说一天内能跑通。过程中遇到问题,不要只看最后一行的报错,把 Prisma 生成的结构、NextAuth 回调日志、tRPC 前后端类型三处对照一下,绝大多数坑都能快速定位。这套全栈组合,日常做工具和原型是真的舒服。