最近用 T3 Stack 完整做了一个小项目,名字就叫 t3code——一个轻量级的代码片段管理工具。整体从初始化到部署折腾了两周多,中间踩了不少坑,也有几个挺爽的瞬间。这篇文章打算把整个过程中的技术选型思路、关键模块的实现细节、以及我遇到的典型问题都拆开讲清楚,给准备上手 T3 Stack 或者说想做个全栈实战项目的朋友一份可以抄作业的参考。
1. 项目定位与技术栈全景解析
1.1 T3 Stack 到底是什么,为什么我选它做 t3code
先解释一下 T3 Stack 这个组合。它由 TypeScript、Tailwind CSS、tRPC,外加 Next.js、Prisma、NextAuth 这些组件构成。听起来是六个东西,实际上可以理解成一套全栈 TypeScript 方案:前端、后端、数据库访问、用户认证,全部都用 TypeScript 写,很少需要跨语言切换。
t3code 这个项目的核心功能是管理代码片段——类似一个私人的代码收藏夹,用户可以按语言、标签去检索和整理自己见过的、写过的代码片段。这个需求听起来简单,但涉及到数据库建模、REST 风格的 API 设计、前端状态管理、用户权限校验等一整套流程,刚好适合拿来检验一个技术栈的完整度。
我选择 T3 Stack 的原因有几点:
- 端到端类型安全。这一点对 t3code 这种 CRUD 占比很高的项目特别友好,后端 service 改一个字段类型,前端页面编译期就能发现类型对不上,而不是等到运行时接口报错才去排查。
- 脚手架极度省心。create-t3-app 出来的项目自带 ESLint、TS 配置、Tailwind 样式方案、NextAuth 接入点,不用像过去那样花半天手工拼装。
- tRPC 减少样板代码。不需要定义 REST 路由、写 controller、配鉴权中间件那一大套东西。一次 RPC 调用就像调用本地函数一样,省掉的不仅是代码量,还有前后端联调和排查的精力。
但选它也不是没有代价。生态相对年轻,遇到冷门问题要靠读源码才能定位;tRPC 的代码组织方式如果项目超过一定规模,会很考验路由拆分的功力。不过对于 t3code 这个体量的项目,T3 Stack 的收益是远大于成本的。
1.2 核心功能拆解与数据库模型设计思路
做任何项目,第一件事永远是理清数据模型。t3code 不是那种复杂业务系统,但数据关系上有一些隐蔽的坑,我梳理之后拆出三个核心实体:User(用户)、Snippet(代码片段)、Tag(标签),另外用一张关联表来做 Tag 和 Snippet 的多对多关系。
在模型设计上有几个决策点:
- 为什么 Snippet 和 Tag 要多对多?因为一条代码片段往往同时属于多个分类。比如一段 React Hook 的写法,可能同时带 “react”、“hooks”、“typescript” 三个标签。如果只允许一个标签,检索维度就太窄了。
- 为什么要单独的关联表,而不是在 Snippet 里存一个数组?存数组在 Postgres 里也能做,但后续按标签统计、按标签筛选时,关系表的查询效率和索引能力都强得多,而且 Prisma 对多对多的支持非常自然,不需要写复杂的连接逻辑。
- 用户和代码片段的关系就是最普通的一对多,这点没什么争议。
下面是关键的 Prisma Schema(精简版),这个模型在后续所有模块里都被反复使用:
model User { id String @id @default(cuid()) name String? email String? @unique snippets Snippet[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Snippet { id String @id @default(cuid()) title String content String language String userId String user User @relation(fields: [userId], references: [id]) tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([userId, language]) @@index([userId, createdAt]) } model Tag { id String @id @default(cuid()) name String snippets Snippet[] }设计的时候我特别做了两个联合索引:@@index([userId, language])和@@index([userId, createdAt])。原因是 t3code 页面上有两个核心场景:按语言筛选列表、按时间倒序输出列表。没有这两个索引,数据量一旦上来,查询会全表扫描,这是一个早期就必须想清楚的细节。
2. 后端核心模块实现与关键技术决策
2.1 tRPC 路由拆分策略:为什么要拆成 router 和 procedure
tRPC 是 T3 Stack 里最值得花时间理解的部分。很多朋友第一次接触 tRPC 会有一个疑问:这不就是把后端函数直接暴露给前端吗,安全吗?
这个疑问很正常。tRPC 确实把 API 调用变成了函数调用,但它并不是裸奔。它的安全机制建立在procedure 的隔离性上——你可以在 router 层级或者单个 procedure 上挂中间件做鉴权、做限流、做参数校验,只有经过中间件放行的逻辑才会被执行。
t3code 的后端路由我拆成了三个文件,对应三种不同粒度的资源:
snippet.ts:代码片段的增删改查tag.ts:标签的读取与统计auth.ts:登录态与用户信息相关操作
拆分的原则很简单:同一资源的操作放在一个 router 里,procedure 只做单一职责。比如 snippet router 里有create、getAll、getById、update、delete五个 procedure,它们之间共享通过protectedProcedure注入的用户上下文,互不干扰。这种组织方式在项目规模变大后优势很明显——每个 router 的职责边界清晰,任何路由出问题,定位到文件也就是几秒钟的事。
下面这段代码是 snippet 的核心 CRUD 实现,重点看protectedProcedure的用法和 create 逻辑中connectOrCreate的写法:
// server/api/routers/snippet.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().min(1), tags: z.array(z.string().min(1)).max(5), }) ) .mutation(async ({ ctx, input }) => { const snippet = await ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, userId: ctx.session.user.id, tags: { connectOrCreate: input.tags.map((tagName) => ({ where: { name: tagName }, create: { name: tagName }, })), }, }, }); return snippet; }), getAll: protectedProcedure .input( z.object({ language: z.string().optional(), cursor: z.string().optional(), limit: z.number().min(1).max(50).default(20), }) ) .query(async ({ ctx, input }) => { const items = await ctx.db.snippet.findMany({ where: { userId: ctx.session.user.id, ...(input.language ? { language: input.language } : {}), }, include: { tags: true }, orderBy: { createdAt: "desc" }, take: input.limit + 1, ...(input.cursor ? { cursor: { id: input.cursor } } : {}), }); let nextCursor: string | undefined; if (items.length > input.limit) { const nextItem = items.pop(); nextCursor = nextItem?.id; } return { items, nextCursor }; }), });这里的connectOrCreate是 Prisma 里处理多对多关系特别顺手的方法:标签存在就关联,不存在就创建。不用先查一遍标签库里有没有,再决定是 create 还是 connect,一条语句直接搞定。
我实现分页的方式是 cursor-based,而不是传统的 page offset。为什么?代码片段的列表是实时性很高的数据,用户新增一条后旧数据的偏移量就变了,基于游标的分页不会因为新数据的插入导致“上一页和下一页之间出现重叠或跳跃”。代码里能看到一个细节:先取limit + 1条,如果多查出来的那一条存在,说明还有下一页,就把它的 id 当作nextCursor返回,否则就说明已经到末尾了。这种做法避免了额外的一次 count 查询。
2.2 用 Zod 做输入校验,别跳过这一步
tRPC 生态里有一个惯例:用 Zod 做输入校验。这块我在 t3code 里执行得比较严格——每个改数据的 procedure 前面,都挂了一个 Zod schema。
可能有人觉得麻烦:参数反正都是自己前端传的,自己人还校验什么?但真实情况是,DB 层的约束和 API 层的校验是两码事。Prisma 的字段类型约束更多是“防止脏数据落库”,而 Zod 的 input 校验管的是“业务规则”。比如title不能超过 100 字符、tags最多 5 个、content不能为空——这些规则数据库并不关心,但业务上必须有。
最典型的一个坑:当我第一次只做了前端双校验、后端直接写入时,用户在浏览器里手动改了本地 localStorage 的数据或者直接用 API 工具调接口,就能绕过前端校验,往数据库里塞入标题为空的垃圾记录。展示层没炸,但搜索列表和卡片标题都出现空白。后来把 Zod 校验补到后端,这条口子就堵上了。
另外 Zod 还有副产品级的收益:它可以作为类型推断的唯一来源。写z.infer<typeof CreateSnippetInput>就直接得到了前端需要的输入类型,不用手写接口的类型定义。这也是 T3 Stack 能实现“端到端类型安全”的关键一环。
2.3 NextAuth 会话与权限控制的落地方式
认证部分我用的是 NextAuth,这是 Next.js 生态里最成熟的方案。t3code 支持 GitHub 登录和邮箱登录,其实只用了 NextAuth 的一小部分能力——核心需求是用户登录之后能在后端拿到“当前操作者是谁”。
这里有一个 T3 Stack 中很关键的约定:createTRPCContext会把 NextAuth 的 session 注入到 tRPC 的 context 中去。也就是说,每一个 tRPC procedure 都能通过ctx.session拿到当前登录用户。
session 的注入逻辑长这样:
// server/api/trpc.ts 中简化后的核心部分 import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db, session, ...opts, }; };有了这个 context,protectedProcedure 的判断逻辑才成立:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user }, }, }); });所有涉及用户私有数据的查询,都强制走protectedProcedure,并且在 where 条件里带上userId: ctx.session.user.id。这一步不能省,否则会出现水平越权——用户 A 能读用户 B 的代码片段。我后续在文章第 4 章会专门讲这个越权问题的实际排查过程。
3. 前端页面与交互实现
3.1 页面路由设计与数据流组织
前端部分,t3code 用的是 Next.js 的 App Router。最初我考虑过要不要用 Pages Router,毕竟那个模式更成熟、资料更多。但 App Router 的 Server Component 能力确实香——尤其是列表页这种读多写少的场景,直接在服务端把数据查好传给组件,减少了一次客户端到服务端的往返。
页面结构非常直接:
/首页——展示当前用户最近发布的片段列表和标签云/snippets全部片段列表,支持按语言筛选/snippets/[id]片段详情页/new新增片段页面
App Router 下,访问这些页面的数据流是这样的:页面组件可以直接调用 tRPC 的 server-side 方法(通过trpcServerhelper),不需要额外封装 fetch 逻辑。比如在首页获取最近片段:
export default async function HomePage() { const recentSnippets = await trpcServer.snippet.getRecent({ limit: 10 }); return <SnippetList snippets={recentSnippets.items} />; }这段代码看起来像直接在后端执行,实际上也是在后端执行的——Next.js 服务器在渲染这个组件时会直接调用 tRPC Router 里的逻辑。好处是整个首页的首屏数据是在服务端一次性拿到的,浏览器收到的已经是完整的 HTML,对 SEO 和首屏体验都友好。
但交互型的操作(新增、更新、删除、搜索)不能走这条路。这些操作需要在客户端组件里用 tRPC 的 React Query hooks 来调。区别是什么?服务端调用是一次性的、直接落在函数执行环境里;而客户端调用是“发出一个请求到服务器再回来”,此时 tRPC 会走 HTTP 协议层,依赖 Next.js 的 route handler 转发。
3.2 新增页面的表单实现与乐观更新
在/new页面,用户输入标题、选择语言、填写代码内容、打标签,然后点保存。这个表单我用的是 React Hook Form + Zod 校验。核心逻辑是:前端的校验 schema 和后端用的是同一份定义——这个共享能力是 monorepo 结构下 T3 Stack 提供的最大便利之一。
保存时的代码逻辑长这样:
const utils = trpc.useUtils(); const createSnippet = trpc.snippet.create.useMutation({ onSuccess: () => { utils.snippet.getAll.invalidate(); router.push("/snippets"); }, });这里用到了 React Query 的invalidate机制——新增片段成功后,让 snippet 列表的缓存失效,下次渲染时自动重新拉取最新数据。不用手动管理刷新时机,也不用在多个组件之间广播“数据变了”这个事件。这种基于缓存失效的模式在 tRPC 的数据流里几乎是标配,实际体验比裸 fetch + Redux 要顺手得多。
还有一个小优化:提交按钮在 mutation 进行中时,设置disabled={createSnippet.isPending},防止用户连续点保存产生重复片段。这个细节初看不值一提,但真实场景里用户弱网环境下双击提交是很容易发生的,我不止一次在别的项目里见过因为没有禁用按钮导致的重复数据问题。
3.3 列表筛选与标签云的交互技巧
t3code 的列表页支持按语言快速筛选。交互上我做成了一排语言 Tab:全部、TypeScript、JavaScript、Python、Rust、Go 等。点击不同的 Tab 时,URL 的 query 参数会发生变化:/snippets?language=typescript。
为什么不在组件 state 里保存当前筛选条件?因为 URL 参数有几个天然优势:
- 用户刷新页面后筛选状态不会丢。
- 筛选结果可以复制链接分享给他人。
- 浏览器自带的“后退”可以直接回到上一个筛选状态。
代码实现上,读取 query 用searchParams(服务端组件)或useSearchParams(客户端组件),触发改动时通过router.replace来更新 URL,而不是用setState。数据请求直接依赖这些 query 参数。
标签云则是一个展示型组件——从后端聚合统计每个标签下有多少条片段,渲染成不同字号或颜色的文字集合。实现上我用了一个getAllTagsprocedure 返回标签名和引用次数,前端的视觉权重完全依赖 count 值来动态计算,逻辑简单但交互效果很直观。
4. 部署、环境配置与性能优化
4.1 环境变量管理:从 dev 到 prod 的完整配置清单
T3 Stack 对环境变量的管理有自己的一套规范——env.mjs文件会验证启动时需要的所有变量是否存在,类型不对或者缺失直接报错拒绝启动。这个设计很硬核,但也意味着配置环节必须按规矩来,漏掉一个字段服务就跑不起来。
t3code 在全阶段需要的关键环境变量如下:
| 变量名 | 用途 | 必填 |
|---|---|---|
| DATABASE_URL | Postgres 连接字符串 | 是 |
| NEXTAUTH_SECRET | 加密 NextAuth session token 的密钥 | 是 |
| NEXTAUTH_URL | 部署后的站点地址,用于 OAuth 回调 | 是(生产) |
| GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | GitHub 登录凭证 | 是 |
| EMAIL_SERVER / EMAIL_FROM | 邮箱登录的 SMTP 服务 | 可选 |
其中最容易踩坑的是NEXTAUTH_URL。本地开发时它可以不配,NextAuth 默认会使用 localhost:3000,但一旦部署到生产环境,这个值必须和你实际访问的域名完全一致,否则 GitHub OAuth 的回调会失败,用户在登录页点击授权后会被跳转到http://localhost:3000之类错误地址。
4.2 构建部署中去掉 VS Code 卡点:Prisma 和 tRPC 的构建期问题
部署前构建阶段有两个特别值得注意的问题。
第一个是 Prisma 的 client 生成。因为 T3 Stack 里 Prisma Client 是根据 schema 动态生成的,构建前必须确保执行了prisma generate,否则 TypeScript 编译会报一屏红字。很多部署平台(比如 Vercel 或 Render)的构建流程里不会自动跑这个命令,我处理的方式是把postinstall: prisma generate写进 package.json:
{ "scripts": { "postinstall": "prisma generate" } }这样每次安装依赖后会自动生成最新 Client,部署时不用手动操作。
第二个是 tRPC 的类型序列化。tRPC 虽然类型安全,但它的类型信息不会跨构建缓存保留——如果构建缓存目录被复用,可能会在编译期出现“类型不一致”的诡异报错。解决方案是关闭或清理 Next.js 的.next构建缓存,或者在部署命令里加上--force之类参数,具体视平台而定。
4.3 数据库迁移:本地与生产环境的安全对齐
Prisma 的迁移流程prisma migrate dev只在本地开发环境用。生产环境的安全做法是使用prisma migrate deploy——它会检查迁移记录表,只把还没有执行过的迁移文件应用到数据库,不会重置已有数据。
我在部署 t3code 时有一次深刻的教训:本地开发过程中我改过一个字段的默认值,生成了一个新迁移文件,提交后忘了把迁移文件推到远端。结果生产环境执行部署时,Prisma 发现_prisma_migrations表里记录的迁移状态和实际不一致,报了一个 migration 依赖错误的提示。后来我把所有本地迁移文件统一推上去,重新对齐之后才恢复。
所以这里要特别强调:迁移文件的版本管理要像代码一样认真提交,尤其是多环境协作时,任何本地生成的迁移记录都要同步推送。这是新手最容易忽略、但踩了一次就长记性的坑。
5. 典型实战问题排查与避坑指南
5.1 问题一:服务端渲染时的 session 丢失,登录状态“时有时无”
我遇到最诡异的问题是:t3code 在部分页面加载时,刷新后登录状态偶尔会变成未登录,再刷新一次又恢复正常。这个问题的排查花了很长时间,最后定位到原因:App Router 的缓存行为。
我在某些页面组件里手动调用了getServerSession(authOptions)来读取登录态,而这个函数在 App Router 的缓存场景下,如果被缓存了结果,就会输出不新鲜的 session。解决方法是不要自己多次调用getServerSession,而是统一依赖 t3code 里trpcServer的封装——它内部会通过正确的动态 API 读取 session,确保每次请求都拿到最新的会话状态。
5.2 问题二:数据越权,A 用户能通过改 URL 看到 B 用户的片段
这是安全层面最严重的问题,还好发生在开发阶段就被发现。现象是:用户 A 登录后,访问/snippets/某用户B的片段id,居然能正常看到完整内容。
排查后发现,我看详情页的 procedure 代码时发现 where 条件里没有带userId:
getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.snippet.findUnique({ where: { id: input.id }, include: { tags: true }, }); });这段代码只校验了“你是否登录”,没有校验“这条 snippet 是不是你的”。修复方案是在查询条件中同时加上用户维度:
getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const snippet = await ctx.db.snippet.findFirst({ where: { id: input.id, userId: ctx.session.user.id, }, include: { tags: true }, }); if (!snippet) { throw new TRPCError({ code: "NOT_FOUND" }); } return snippet; });这个改动把“找一条记录”变成了“在当前用户的记录里找一条记录”,查询结果为空时直接抛出 NOT_FOUND,而不是返回 null。这样即使别人拿到你的片段 id,接口也不会泄露任何内容。
5.3 问题三:T3 全栈类型错误,明明数据库能返回的数据类型却编译不过
出现这类问题的根源通常是 schema 变更后没有重新生成 Prisma Client。比如我给 Snippet 模型加了language字段,但在编辑代码的编辑器里,TypeScript 还停留在旧版的 Prisma Client 类型定义上,导致所有snippet.language的访问全部标红。
解决办法分两步:改动 schema 后立刻执行npx prisma generate,同时重启 TS 语言服务。很多人以为重启项目能解决,其实不重启 TS 语言服务,旧的类型快照还在内存里,照样报错。
5.4 速查表:那些容易重复踩的坑
| 问题 | 典型报错/表现 | 关键修复点 |
|---|---|---|
| 登录回调地址错误 | OAuth 授权后跳回 localhost | 配置生产环境 NEXTAUTH_URL |
| 构建阶段 Prisma 报错 | P1012 / Client not generated | 在 postinstall 中执行 prisma generate |
| 修改 schema 后类型不同步 | TS 报错找不到新字段 | prisma generate + 重启 TS server |
| 缓存导致登录态旧数据 | 刷新后偶发未登录 | 统一走 trpcServer 封装读取 session |
| 跨用户数据访问 | 越权读取他人片段 | 查询条件里带 userId,无结果抛 NOT_FOUND |
| 新增记录重复 | 双击提交按钮 | mutation 过程中 disable 提交按钮 |
6. 一些实打实的项目复盘与后续扩展方向
t3code 做完之后,我对 T3 Stack 的理解比看十篇教程都要深。有句技术圈的老话叫“纸上得来终觉浅,绝知此事要躬行”,放在全栈开发这行尤其贴切——tRPC 的类型安全、Prisma 的迁移机制、NextAuth 的会话处理,每一个模块单独看文档都觉得自己懂了,真到了把它们串联起来应付业务逻辑时,才会暴露认知的盲区。
我个人的体会是,对于中小型全栈应用,T3 Stack 提供的不仅仅是一套工具链,更是一套开发纪律:每个数据变更都必须经过类型校验,每次资源访问都必须显式声明权限边界。它不是帮你“少写代码”那么简单,更重要的是帮你“少犯错”。
如果后续继续迭代 t3code,我有两个明确的方向。第一是加一个全文搜索能力——目前列表只能按语言和标签筛选,用 Postgres 的tsvector或者引入 Meilisearch 做内容的全文检索,会让工具更实用。第二是加“分享片段”功能,允许用户把某条片段设置为公开并生成只读链接,这就涉及公开和私有两条访问路径,对权限模型又是一轮很好的锤炼。
最后分享一个我建议每个实战项目都做的环节:项目收尾时,把整个开发过程中卡住超过半小时的问题整理成一个 markdown 文档,连同解决方案一起放进仓库。这个文档表面上是给别人看的,实际上最有价值的是你自己——几个月后再维护项目时,你会无比感激当初那个认真记坑的自己。