T3 Stack全栈实战:从tRPC到Prisma的代码片段管理工具开发

发布时间:2026/10/13 15:25:17
T3 Stack全栈实战:从tRPC到Prisma的代码片段管理工具开发
最近用 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 和 proceduretRPC 是 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.infertypeof 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, }; };有了这个 contextprotectedProcedure 的判断逻辑才成立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?languagetypescript。为什么不在组件 state 里保存当前筛选条件因为 URL 参数有几个天然优势用户刷新页面后筛选状态不会丢。筛选结果可以复制链接分享给他人。浏览器自带的“后退”可以直接回到上一个筛选状态。代码实现上读取 query 用searchParams服务端组件或useSearchParams客户端组件触发改动时通过router.replace来更新 URL而不是用setState。数据请求直接依赖这些 query 参数。标签云则是一个展示型组件——从后端聚合统计每个标签下有多少条片段渲染成不同字号或颜色的文字集合。实现上我用了一个getAllTagsprocedure 返回标签名和引用次数前端的视觉权重完全依赖 count 值来动态计算逻辑简单但交互效果很直观。4. 部署、环境配置与性能优化4.1 环境变量管理从 dev 到 prod 的完整配置清单T3 Stack 对环境变量的管理有自己的一套规范——env.mjs文件会验证启动时需要的所有变量是否存在类型不对或者缺失直接报错拒绝启动。这个设计很硬核但也意味着配置环节必须按规矩来漏掉一个字段服务就跑不起来。t3code 在全阶段需要的关键环境变量如下变量名用途必填DATABASE_URLPostgres 连接字符串是NEXTAUTH_SECRET加密 NextAuth session token 的密钥是NEXTAUTH_URL部署后的站点地址用于 OAuth 回调是生产GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRETGitHub 登录凭证是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 条件里没有带userIdgetById: 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 文档连同解决方案一起放进仓库。这个文档表面上是给别人看的实际上最有价值的是你自己——几个月后再维护项目时你会无比感激当初那个认真记坑的自己。