
1. 项目诞生背景与整体定位先聊聊“t3code”这个名字。每个做全栈的开发者看到“t3”这两个字符第一反应大概率就是 T3 Stack——那套以 TypeScript 为核心的全栈技术栈组合典型的搭配是 Next.js TypeScript Tailwind CSS tRPC再叠上 Prisma 和 NextAuth.js。t3code 这个名字放在这里几乎就是明晃晃地告诉同行这是一个长在 T3 技术栈上的项目。但我从来不相信“为了技术而技术”的项目能走多远。t3code 真正的价值主张是做一个面向开发者群体的代码片段管理与分享社区。简单来说它解决的是开发者每天都在经历的痛点本地散落着几十个 Markdown 笔记、IDE 里存着一堆临时片段、ChatGPT 对话记录里翻到好用的代码却找不到出处、跟同事协作时一个代码块贴来贴去还要手动对齐格式。t3code 做的事情就是把“存代码、找代码、分享代码、讨论代码”这四件事统一到一个产品里让每个开发者都拥有一个私有且可公开分享的代码片段库。这个定位听起来不复杂但拆开之后涉及的技术点其实相当密集全栈类型安全、数据库关系建模、认证授权、代码高亮渲染、全文搜索、实时校验、部署与性能优化随便哪一块拿出来都能写一篇深度文章。我选择把这些内容系统性地拆解一遍一方面是给想复刻类似项目的读者提供一条能落地的完整路径另一方面也是给正在学习 T3 Stack 的人一个参考样本——这套技术栈到底能支撑一个什么样的真实产品用的时候又会踩到哪些文档里没写的坑。2. 技术选型背后的关键决策逻辑2.1 为什么选择 T3 Stack 而非传统前后端分离第一个要解释清楚的问题就是为什么 t3code 不用常见的“Vue/React Spring Boot / Express REST API”方案而是整套押注在 T3 Stack 上核心答案就四个字端到端类型安全。传统前后端分离项目中接口返回的数据结构靠“约定”而不是靠“系统”。前端写一个interface CodeSnippet后端写一个class CodeSnippet两者在代码规范上明明是同一种东西但编译时没有任何机制互相校验。一旦后端字段改名、类型从string换成number、或者某个字段在特定条件下为null前端只有跑到运行时才能发现报错。老一代开发者经历过太多这种“类型地狱”修复一个线上 bug 的成本里有三分之一花在联调和排查数据形状上。T3 Stack 的思路是从根上消灭这一类问题前后端共用同一套 TypeScript 类型定义接口调用就像“直接调用一个函数”一样连带着输入校验、参数提示、返回值推断都自动带出来了。第二个原因是开发效率上的飞轮效应。t3code 这种量级的产品核心功能无非就是用户、代码片段、标签、收藏、评论如果走传统方案我得同时维护一份后端 API 文档、一套接口封装层、一个前端仓库、一套联调环境。而基于 Next.js tRPC 的全栈结构前后端代码可以放在同一个仓库里开发时上下文切换成本极低改一个字段直接把类型定义一改所有引用点立刻通过 TypeScript 报错告诉你哪里要跟着改。很多中型项目其实根本不需要前后端两套仓库、两套部署流程T3 Stack 在“单仓单应用”的场景下优势非常明显。用个生活化的比喻传统前后端分离像是两个人在房间两端用对讲机通话约定好“我说数字 1 就是 A 事件”一旦谁记错了口径就出事故T3 Stack 则是两个人坐在同一张桌子前直接面对面沟通看到的资料是同一份根本不存在“传话失真”。t3code 这种以开发者工具为核心的产品类型安全可以直接转化为产品体验——用户永远看不到“服务器返回了意外数据结构”这类报错。2.2 技术栈的生态位对比tRPC 与 REST、GraphQL不过T3 Stack 也不是无脑最优解。我在 t3code 立项时专门做了一轮选型对比核心纠结在两个维度接口层用 tRPC 还是 GraphQL数据层用 Prisma 还是 TypeORM 或 Drizzle。GraphQL 在查询灵活度上确实更强但它的代价是额外的 schema 定义语言、解析器分层、缓存复杂度对 t3code 这种以“列表详情、增删改查”为主的 CRUD 密集型应用来说属于过度设计。REST 则是最熟悉的路径但代价是前端需要手工写类型定义、手工维护请求封装每加一个接口都要两头奔走。tRPC 的取舍逻辑在于它牺牲了“面向第三方开放 API”的场景换来的是“自家前后端无缝调用”的极度顺滑。t3code 本质上是一个单体 Web 应用没有开放平台、没有对外 API 生态的计划tRPC 完全够用且体验最佳。如果哪天 t3code 需要开放 API 给外部开发者届时再加一层 REST 或者 GraphQL 适配层也不迟。技术选型永远是为产品生命周期服务的而不是反过来被某种“fancy 技术”绑架。对比维度tRPCRESTGraphQL类型安全端到端自动推导依赖手写类型/代码生成需要生成类型或手写解析器学习成本低会写函数即会用低高需要学 Schema 语言对外 API 支持弱内部专用强强适合场景全栈单应用任何场景的通用方案多端聚合、复杂嵌套数据过度设计成本无无较高缓存、解析层实际用下来tRPC 有一个隐藏红利是很多人没提到的它天然鼓励你写“小函数”而不是“大接口”。因为每个 procedure 就是一个能被类型检查的函数你会下意识把业务拆成细粒度的查询和变更单元。比如 t3code 中“获取某用户的公开片段”“获取当前用户的私有片段”“为片段点赞”“取消点赞”都是独立的 procedure后续维护和测试都清晰很多。2.3 数据模型设计Prisma Schema 的推演过程数据层我选了 Prisma 而不是 TypeORM 或 Drizzle主要原因是 t3code 涉及的关系模型比较经典用户、片段、标签、收藏、评论有典型的多对多关系。Prisma 的 schema 声明式建模在这个场景下直观得不需要额外解释而且迁移工具和类型生成都足够成熟配合 tRPC 用起来是无缝衔接。以Snippet模型为例我在设计时做了几次关键决策。一是语言字段用String而非枚举因为开发者社区的语言种类太杂未来还会有tsx、vue、svelte等变体用枚举会让扩展需要迁移数据库不划算。二是标签采用独立的Tag模型加隐式多对多关系而不是直接在片段里存一个字符串数组因为后续大概率要做“按标签聚合统计”的功能单独建表能让查询能力更灵活。三是给Snippet表加了isPublic布尔字段而不是单独建一个“收藏/公开”权限表因为 t3code 的隐私模型只有“公开”和“私有”两档用字段就能覆盖不需要额外引入权限框架。下面这一段 Prisma schema 是 t3code 实际跑通的核心模型定义公开的片段字段刻意精简但承载的业务边界是完整的model User { id String id default(cuid()) name String? email String unique image String? snippets Snippet[] likes Like[] comments Comment[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Snippet { id String id default(cuid()) title String description String? code String language String isPublic Boolean default(false) author User relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String tags Tag[] likes Like[] comments Comment[] createdAt DateTime default(now()) updatedAt DateTime updatedAt index([authorId, isPublic]) } model Tag { id String id default(cuid()) name String unique snippets Snippet[] } model Like { id String id default(cuid()) snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) snippetId String user User relation(fields: [userId], references: [id], onDelete: Cascade) userId String unique([snippetId, userId]) } model Comment { id String id default(cuid()) content String snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) snippetId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String createdAt DateTime default(now()) }这里有一个新手特别容易忽略的坑unique([snippetId, userId])这个复合唯一约束是点赞功能“一个人不能重复点赞”的数据库兜底。如果只靠应用层判断并发请求下很容易出现两个请求同时通过检查然后插入两条记录只有数据库层面的唯一索引才能保证真正幂等。同样的道理也适用于用户邮箱唯一约束。我见过的不少 TypeScript 全栈项目在数据完整性上过度依赖应用层代码最终上线后出现脏数据排查起来非常痛苦。2.4 Next.js 渲染策略的按需选择t3code 的页面结构并不单一不同页面对渲染方式的要求差异很大。首页排行榜需要被搜索引擎收录适合 SSR 或者静态生成用户个人页的数据是动态的但在 t3code 的定位下也没有一秒一变的需求因此优先采用静态生成加增量更新成本低、首屏快而“我的片段”这个页面涉及当前用户私密数据既不能静态生成也不能被 CDN 缓存只能在客户端渲染或者服务端按需渲染。最终我采用的策略是公开内容页面走getServerSideProps或 App Router 下的 ServerComponent 预取登录后的管理页面全部走客户端受控组件。Next.js 在这个维度上给了足够灵活的选择空间这也是它比纯 Vite SPA 更适合 t3code 的原因。SPA 渲染的页面在首屏性能和 SEO 上有硬伤而 Next.js 可以在同一个应用内优雅地处理两类需求无需为 SEO 单独拼一套服务端渲染框架。3. 从零搭建 t3code 的完整实操3.1 项目初始化create-t3-app 的配置实践创建 t3code 项目不需要手工拼装一堆依赖官方脚手架create-t3-app已经封装好了最佳实践的初始配置。执行命令后按交互提示选择需要的模块即可我当时的完整选择是npm create t3-applatest t3code交互式选项按照实际需求勾选Next.js 使用 App Router新版默认选项TypeScript 开启严格模式默认开启Tailwind CSS 用于样式方案tRPC 作为接口层Prisma 作为 ORMNextAuth.js 用于认证数据库选择 PostgreSQL本地用 Docker 起一个实例脚手架初始化完成后目录结构比我预想的清晰很多。src/app下面按 Next.js 的 App Router 组织路由src/server/api/routers放 tRPC 路由定义src/server/db.ts管理 Prisma 客户端src/trpc负责服务端和客户端的 tRPC 初始化。值得一提的是新版脚手架已经默认支持在 Server Component 中调用 tRPC通过server和client两套调用方式分离不再像早期版本那样只能在客户端组件里用这个改进让很多页面可以只用服务端渲染就拿到数据减少了一次客户端往返请求。3.2 数据库模型落地与首次迁移初始化完成后第一件事是搭建开发数据库。我在本地用 Docker 创建了一个 PostgreSQL 容器开发环境和生产环境都通过DATABASE_URL环境变量切换连接docker run -d --name t3code-db -e POSTGRES_USERt3code -e POSTGRES_PASSWORDt3code-dev -e POSTGRES_DBt3code -p 5432:5432 postgres:16创建数据库只是第一步关键动作是让 Prisma 根据 schema 生成迁移文件并应用到数据库。走到这一步第一个“文档之外的坑”就冒出来了Prisma 的迁移命令和旧版 ORM 不太一样必须先创建迁移文件再执行应用不能直接一键同步。正确的流程是分两步操作npx prisma migrate dev --name init这个命令会创建一个新的迁移文件、生成 Prisma Client 并执行迁移。但需要注意Prisma Client 是需要在每次 schema 变更后重新生成的很多人改了 schema 忘记执行prisma generate结果代码里引用的类型还是旧的TypeScript 直接报错。如果遇到“看起来 schema 改了但代码里取不到新字段”的情况先跑一遍npx prisma generate再说这个排查顺序能解决掉相当比例的问题。3.3 认证接入NextAuth.js 的配置细节t3code 的认证设计并不复杂允许 GitHub、Google 第三方登录同时也支持邮箱登录。NextAuth.js 在这里的最大价值是省掉了 OAuth 协议的繁琐对接。不过新手要特别注意NextAuth 的配置不是写完就完事的tRPC 的 protectedProcedure 依赖的是 session 信息而 TypeScript 默认并不知道你的 session 里有什么字段需要手写类型声明扩展import { DefaultSession } from next-auth; declare module next-auth { interface Session { user: { id: string; } DefaultSession[user]; } }这个types/next-auth.d.ts是 NextAuth 与 TypeScript 协作的“关键拼图”。没有它session.user.id在 tRPC 服务端获取不到用户 ID所有需要认证的接口都会编译报错。我的经验是只要项目用了 NextAuth tRPC 组合配置认证时先写这个类型扩展再写路由守卫顺序反了会多走很多弯路。回调函数方面NextAuth 的callbacks.session里需要显式将 MongoDB 或 Prisma 数据库里的用户 ID 注入到 session。因为默认的 session 对象只有 NextAuth 自己的用户信息并没有业务数据库里的用户主键而后面 tRPC 的每个 mutation 都需要拿到这个 ID 才能关联数据。3.4 tRPC 请求层定义 snippet router 的完整示例t3code 的核心接口都集中在 snippet 相关的路由上。拿“创建片段”这个操作举例它的 tRPC procedure 既要做输入校验又要处理数据库写入还要遵守“私有/公开”权限。我把一个完整的createSnippet定义贴出来配合注释讲解关键点import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; const snippetInput z.object({ title: z.string().min(1, 标题不能为空).max(80), description: z.string().max(300).optional(), code: z.string().min(1, 代码内容不能为空), language: z.string().default(plaintext), tags: z.array(z.string()).default([]), isPublic: z.boolean().default(false), }); export const snippetRouter createTRPCRouter({ create: protectedProcedure .input(snippetInput) .mutation(async ({ ctx, input }) { const snippet await ctx.db.snippet.create({ data: { title: input.title, description: input.description, code: input.code, language: input.language, isPublic: input.isPublic, authorId: ctx.session.user.id, tags: { connectOrCreate: input.tags.map((tag) ({ where: { name: tag }, create: { name: tag }, })), }, }, }); return snippet; }), });有几个细节值得展开。一是protectedProcedure会自动校验 session没有登录的用户调用这个 mutation 会直接被 tRPC 拒绝不需要在函数内部手动判断ctx.session是否为 null这个安全边界等于框架免费送的。二是tags字段的处理方式我用了connectOrCreate也就是标签存在就关联不存在就自动创建。如果不用这个 API就得先查询标签是否存在再决定connect还是create业务代码会啰嗦一倍。三是 zod 校验放在了 mutation 入口处这比在函数体内手工校验优雅得多无效输入会带着错误信息直接返回给前端。写到这里有个小技巧必须分享tRPC 的错误对象默认是不暴露给客户端的生产环境下前端只能看到 “INTERNAL_SERVER_ERROR”。为了让前端能展示校验消息需要在 tRPC 的 errorFormatter 里自定义输出 zod error 的详细信息。这一步虽然多写几行配置但对开发者工具的体验影响很大——用户创建片段填错表单时总得告诉他到底哪里错了。3.5 列表查询与按标签过滤的实现t3code 最常被调用的接口是“获取片段列表”。这个列表有两个维度的过滤一是公开/私有权限过滤二是标签过滤。如果用传统 REST 接口这类多条件组合查询通常会出现一大坨请求参数而在 tRPC 里只需要在 input 对象里定义好可选字段服务端按条件拼查询即可类型上依然安全。getList: publicProcedure .input( z.object({ tag: z.string().optional(), cursor: z.string().optional(), take: z.number().max(50).default(20), onlyPublic: z.boolean().default(true), }) ) .query(async ({ ctx, input }) { const where { ...(input.onlyPublic ? { isPublic: true } : {}), ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), }; const items await ctx.db.snippet.findMany({ take: input.take, skip: input.cursor ? 1 : 0, cursor: input.cursor ? { id: input.cursor } : undefined, where, include: { author: { select: { name: true, image: true } } }, orderBy: { createdAt: desc }, }); return { items, nextCursor: items.length input.take ? items[items.length - 1]!.id : null }; });这里的cursor是服务端分页的实现。很多新手会选 offset 分页用 skip/take但表数据一旦多起来offset 越深性能越差而且并发下可能出现重复数据。tRPC 的 cursor 分页是典型的“取上一页最后一个 ID 作为游标往下翻”的方案语义清晰也适合无限滚动列表的交互。在实际应用中这个查询加上了数据库索引查询效率在本地几万条数据量下毫秒级返回完全够用。3.6 代码高亮渲染Shiki 的使用思路代码片段平台的颜值担当就是代码高亮。t3code 最终选择了 Shiki 而不是 Monaco Editor或 CodeMirror原因是 Shiki 基于 TextMate 语法解析支持 VS Code 同款主题并且是构建时/服务端渲染友好的——它可以直接在服务端把代码转成带高亮类名的 HTML客户端零运行时负担这对列表页的性能至关重要。实现原理是先安装shiki包在服务端初始化一个高亮器然后把代码块渲染成precode片段。在实际编码时我做了一个CodeBlock组件在 Server Component 里预先调用高亮输出 HTML 后直接客户端展示。这个方案看似多了一次服务端高亮计算实际换来的是首屏多块代码页面的流畅滚动。如果只依赖客户端高亮页面加载时需要下载高亮引擎每个代码块还得在浏览器里做词法分析打开一个几十个片段的收藏列表页滚动起来会有明显卡顿。Shiki 在服务端把这类重计算提前做完了客户端拿到的只是静态 HTML性能提升非常直接。3.7 搜索功能从最基础的 LIKE 到全文检索t3code 的搜索诉求一开始并不复杂我就用 PostgreSQL 的ILIKE模糊匹配来实现标题和代码内容的简单搜索。但随着片段数量增加全表扫描的成本会逐渐显现而且%keyword%这种通配符查询用不了普通索引这种写法注定只能支撑早期版本。针对代码搜索这个场景稍好一点的做法是引入 PostgreSQL 全文检索。具体做法是在code字段上建一个基于english配置的全文索引查询时用to_tsquery匹配。但要注意代码和英文文章不一样代码里的符号、驼峰、特殊字符会让默认分词规则失效所以搜索功能并没有追求“鲁棒的全站搜索”而是把重点放在标题搜索 标签搜索上代码内容用简单ILIKE先顶着。如果后续数据量真的大到扛不住再单独引入 Meilisearch 或 Typesense 这类轻量搜索服务才是性价比更高的方案。在实际体验方面搜索结果页加上了“分组按标签展示”的功能用户在搜索框敲出 “react” 后会看到左侧标签聚合结果这种导航方式在代码社区里比纯列表更高效算是 t3code 在功能细节上多花的小心思。4. 常见问题与排查实战记录4.1 tRPC 类型不同步改完代码不生效怎么办tRPC 的强大之处是类型推导但这个推导依赖 TypeScript 能正确解析路由器定义。我在开发 t3code 的过程中至少三次遇到“后端改了输出形状前端类型还是旧值”的情况。排查路径很简单先确认后端 router 文件已经保存、TS 没有编译错误然后重启开发服务器。tRPC 的类型是基于运行时引用的理论上热更新应该能探测到变化但 VSCode 的 TS Server 偶尔会缓存旧类型。如果刷新后还不行就打开命令面板执行TypeScript: Restart TS Server这一招能解决九成“类型没变”的假故障。还有一个小坑是如果你在.ts文件里直接import了 tRPC routerTypeScript 可能因为路径别名配置错误而解析不到最新定义此时检查tsconfig.json的paths别名是否正确映射到了~/前缀。4.2 NextAuth 回调里拿不到用户 ID 的问题有段时间 t3code 的点赞接口一直报session.user.id is undefined。排查过程印象很深问题出在 NextAuth 的callbacks.session没有正确注入数据库里的用户 ID。NextAuth 的默认 session 里只有 NextAuth 自身的用户信息用户名、邮箱、图片它不知道你在业务数据库里的主键是什么。解决方案在 3.3 节已经提到就是写types/next-auth.d.ts类型扩展 在[...nextauth].ts的callbacks里显式赋值callbacks: { async session({ session, user }) { if (session.user) { session.user.id user.id; } return session; }, }但这里还有第二层坑如果你用的数据库适配器如next-auth/prisma-adapter返回的user.id是注册时的id那没问题如果 session 回调解构出来的user是空对象就要检查是否在app/api/auth/[...nextauth]/route.ts里没有传入正确的adapter配置。默认情况下只配置 providers 不配置 adapterNextAuth 走的是 JWT 策略session 回调参数里的user来自 JWT 解码而 JWT 数据不包含数据库 ID。所以要么改用数据库 session配置 adapter要么在jwt回调里把user.id塞到 token 里再在session回调里取出来。这两条路径必须选一条否则session.user.id永远拿不到。4.3 Prisma 迁移冲突dev 和 prod 环境的不同策略t3code 在团队协作时遇到过一次迁移文件冲突两个人同时改了 schema各生成了一份迁移文件合并代码时产生了冲突。解决思路很简单但很多人不知道个人开发阶段可以直接删除prisma/migrations文件夹里的冲突迁移然后重新执行prisma migrate dev --name init生成一份干净的初始迁移。但一旦上了生产环境这个“重置大法”就不能乱用了因为生产数据库已经应用过迁移记录。正确做法是生产环境使用prisma migrate deploy命令它会把所有尚未执行的迁移文件按顺序应用到生产数据库。所以在开发阶段养成一个好习惯每次 schema 变更后先跑migrate dev测试本地迁移确认无误后提交迁移文件到代码仓库再由 CI 或手动在生产环境执行migrate deploy。这样既保留了迁移历史又避免生产数据库被不合规的变更搞乱。4.4 部署上线时的环境变量与构建陷阱t3code 部署到 Vercel 时环境变量是最容易踩的坑。NextAuth 的NEXTAUTH_SECRET必须显式配置Vercel 不会自动注入DATABASE_URL如果是本地 Docker 容器地址部署后就会连接失败必须换成云数据库的连接串还有NEXTAUTH_URL必须设置为线上域名否则 OAuth 回调会撞上 localhost。此外构建阶段 Prisma 会因为找不到DATABASE_URL而报错。一个常见的解决方式是在.env里写开发环境的变量同时 Vercel 的环境变量面板里配置生产环境的变量。但要注意的是Prisma Client 在构建时需要访问数据库来生成 client实际上如果已经生成了 client构建不需要数据库但 Prisma 的迁移或初始化脚本可能需要。我的做法是在package.json的 build 脚本里先执行prisma generate再执行next build这样构建阶段不会因为数据库不可用而失败。4.5 更多经验结论速查最后整理一个“血泪教训”速查表方便大家对照排查服务端组件直接调用 tRPC 时如果报了 “Could not find a valid DB connection”检查db.ts是否在服务端初始化了 Prisma 单例不要每次调用都新建一个新的 PrismaClient。列表页出现重复数据几乎都是分页skip参数在数据删除/新增后错位导致的尽快切换到 cursor 分页方案。点赞按钮连点导致状态不一致除了数据库唯一约束兜底前端要加上“请求中”状态锁防止并发插入。tRPC 在生产环境返回的原始错误信息不直观建议自定义errorFormatter把 zod 错误信息翻译成用户易懂的中文提示。连接云数据库时不要直接明文把DATABASE_URL写进代码仓库Vercel 的环境变量面板是更安全的存放位置GitHub 仓库里用.env.example做占位。做一个“T3 全栈”的代码片段平台技术难度并不在某个单一框架上而在于如何把这些框架像积木一样严丝合缝地拼在一起。我个人做 t3code 最深的体会是T3 Stack 真正适合的还是“对类型安全有执念、同时不希望在前端基建上花太多时间”的团队。如果你的产品未来必须面向第三方开放完整 API、或者存在复杂的数据聚合查询tRPC 单体的边界会很快触顶到那时还是考虑拆出独立 BFF 层更稳妥。不过至少从 t3code 这个案例看纯 T3 方案在不大的投入下支撑一个完整的开发工具产品绰绰有余开发体验也是近年来少有的顺畅。如果你正打算做一个工具类 Web 应用又受够了前后端两仓库来回切换的折腾用 T3 这套技术栈复制一遍 t3code 的路径实现速度一定会超出你的预期。