用T3 Stack构建代码片段管理平台:tRPC与Prisma全栈实战

发布时间:2026/10/28 6:37:53
用T3 Stack构建代码片段管理平台:tRPC与Prisma全栈实战 我把这个项目叫做 t3code一开始它只是本地仓库里的文件夹代号后来却成了我每周都在用的小工具一个面向开发者的代码片段管理平台围绕 T3 Stack 这条技术路线把前端、后端、数据库、鉴权全部串起来。很多朋友问我为什么不用现成的 Gist 或 Notion我的回答是自己做一遍 T3 Stack 的全栈流程比看十遍文档都管用。这篇文章就把 t3code 从选型、建目录、写 tRPC、设计 Prisma 模型到最终部署上线的完整过程拆开讲附带我在真实环境里踩过的坑和最后采用的方案适合想用 T3 Stack 做一个完整全栈项目、又不想只停留在 CRUD Demo 的人。1. 为什么是 T3 Stackt3code 的技术选型复盘先交代一下背景。t3code 这个产品定位很简单用户可以注册登录发布自己的代码片段给片段打标签也能收藏别人分享的高质量片段按语言和标签筛选。这个需求看起来不复杂但真正动手时前后端数据交互、鉴权、数据库关系设计、部署这些环节一个都跑不掉非常适合拿来验证一套技术栈的真实手感。T3 Stack 的核心组成是 Next.js、TypeScript、tRPC、Prisma、Tailwind CSS外加 NextAuth.js 做鉴权。它跟传统“前端一套、后端一套、再写接口文档”的方式完全不同最大的卖点是全链路类型安全。1.1 从“代码片段管理”这个需求出发我在设计功能边界时特意做了减法。MVP 只保留四个核心动作创建片段、浏览列表、搜索筛选、收藏。像评论、协作编辑、版本历史这些直接砍掉不是因为做不了而是因为如果第一版就把功能堆满你会分不清某个环节到底是技术栈的问题还是自己设计的问题。先把主链路走通后续再谈扩展。业务模型上至少需要用户、片段、标签、收藏四张表。这四张表的关系足够覆盖一对多、多对多、联合唯一索引这些最常见的 Prisma 建模场景。哪怕你之后不写代码片段管理工具这套模型也能直接迁移到笔记应用、资源收藏站、或简单的 CMS 内容管理上。1.2 为什么放弃 REST 和 GraphQL在正式动手前我把三种方案都过了一遍。REST 最直观/api/snippets、/api/snippets/:id路径清晰但问题在于类型不共享后端定义好接口后前端要手动维护请求函数和类型定义一旦字段改名或者结构调整两边经常出现不同步。GraphQL 能解决过度获取的问题但引入 schema、resolver、codegen 一套体系对中小型项目来说学习成本和配置成本偏重。tRPC 的思路完全不同它不定义 URL直接把后端函数暴露给前端调用就像调用本地函数一样。你用 zod 写好输入校验类型自动推导到前端改一个字段名编译期立刻报错。对 t3code 这种内部逻辑紧密、前后端同仓的项目来说这是开发和维护体验最优的方案。提示T3 Stack 不是银弹。如果你的项目需要开放 API 给第三方开发者使用REST 或 GraphQL 仍然是更稳妥的选择。tRPC 更适合前后端紧密协作、由同一团队维护的产品。2. 脚手架搭建与目录设计的取舍T3 Stack 有个官方脚手架 create-t3-app我的建议是直接用它不要自己从零拼装。原因很简单Next.js、tRPC、Prisma、NextAuth 这几样东西的初始配置有不少约定细节手动拼装很容易在版本兼容性上栽跟头而脚手架生成的模板已经把这些兼容关系处理好了。2.1 初始化命令和选项我当初执行的命令是这样的npm create t3-applatest t3code过程中会有一堆交互式选项我选择的是Next.js 启用 App Router现在已经是默认TypeScript 启用tRPC 启用Prisma 启用Tailwind CSS 启用NextAuth.js 启用ESLint 和 Prettier 保持启用如果你不打算用 Tailwind也可以不勾但 t3code 的界面是用 Tailwind 写的它跟现代 React 组件的配合非常顺畅几乎没有额外的心智负担。初始化完成后项目会自带一套 todo 示例我建议不要直接在上面改而是把示例路由和 tRPC router 清掉从空状态开始写自己的业务这样才能真正理解每一层的作用。2.2 src/ 内部怎么分脚手架生成的目录结构里有几个关键位置值得说明src/ app/ api/trpc/[trpc]/route.ts layout.tsx page.tsx components/ server/ api/ root.ts routers/ trpc.ts db.ts auth.ts trpc/ server.ts client.ts react.tsx这里最核心的是src/server/和src/trpc/的分工。src/server/里放所有只在服务端执行的代码包括 tRPC router、Prisma client、NextAuth 配置src/trpc/里放前后端共享的类型定义和客户端封装。这个分层起到了“强制边界”的作用你不可能在服务端代码里不小心 import 进客户端组件因为server/路径下的内容会被 Next.js 明确视为服务端模块。我在开发过程中最深的体会是目录即约束。约定比配置重要t3code 后续加功能时新代码往哪个目录放几乎没有歧义团队协作时也不需要反复解释架构。3. tRPC 打通前后端的类型安全链路tRPC 是 t3code 里最核心的一层也是很多人第一次接触时最困惑的部分。我尽量把它讲透它本质上是一个 RPC远程过程调用框架服务端定义函数客户端直接调用中间的类型定义由 TypeScript 自动推断不需要手写接口文档。3.1 procedure 体系public 与 protected 的分层tRPC 里每个可调用的接口叫 procedure分为 query查询和 mutation写操作。我把 t3code 的接口按权限分成两层公开接口和登录接口。初始的src/server/api/trpc.ts里会定义基础 t 对象我在此基础上加了鉴权中间件const isAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session, }, }); }); export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(isAuthed);之后在 router 里使用就非常直白export const snippetRouter router({ // 公开列表页任何人都能看 list: publicProcedure .input( z.object({ cursor: z.string().optional(), tag: z.string().optional(), q: z.string().optional(), }) ) .query(async ({ ctx, input }) { // 查询片段列表 }), // 创建片段必须登录 create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().default(text), tags: z.array(z.string()).max(5), }) ) .mutation(async ({ ctx, input }) { const authorId ctx.session.user.id; // 写入数据库 }), });你仔细看这个结构输入校验用的 zod 写在服务端但它的类型会完全同步到前端。在 React 组件里调用api.snippet.create.useMutation()时参数类型、返回类型全部自动推导连字段拼错都过不了编译。3.2 不要在 Server Component 里忘掉 callert3code 有一个页面是首页片段列表我最初是在服务端组件里通过api.snippet.list.useQuery()直接调用结果发现完全不行。原因很简单useQuery是 React Query 的客户端 Hook只能在客户端组件里用。服务端组件里正确的方式是使用 tRPC 的 caller直接以函数调用的方式执行查询import { createCaller } from /server/api/root; import { createTRPCContext } from /server/api/trpc; import { headers } from next/headers; const ctx await createTRPCContext({ headers: headers() }); const caller createCaller(ctx); const snippets await caller.snippet.list({ limit: 12 });这个方式很适合首屏渲染。页面第一次访问时直接拿到数据不需要客户端再发一次请求体验和 SEO 都更好。我在 t3code 的列表页就是这么实现的客户端只在做搜索筛选、收藏操作时才发起新的请求。注意headers()在 Next.js 中只能从服务端调用。如果你在客户端组件里误用了 caller会直接报“动态服务器用法”错误这个报错信息比较抽象我当时排查了很久才发现是执行环境的问题。3.3 实测中的错误提示与查错思路tRPC 的错误信息一开始会让人不太适应。它不像 REST 那样返回 HTTP 状态码而是用TRPCClientError包裹包含data.code字段常见的值有UNAUTHORIZED、NOT_FOUND、BAD_REQUEST、INTERNAL_SERVER_ERROR。t3code 开发中有一次前端收藏按钮点了没反应打开控制台才发现UNAUTHORIZED。我当时很困惑明明已经登录了为什么 session 没有传递后来检查发现收藏的 procedure 用了protectedProcedure而前端的 request 没有带上 cookie。NextAuth 的 session 默认是存在 cookie 里的跨域或者代理配置不对会导致 cookie 丢失。解决方式是保证前后端同域或者在后端配置正确的AUTH_URL。这个问题在本地开发时不会出现一旦部署到自定义域名就很容易踩中。4. Prisma 数据模型设计与迁移踩坑Prisma 在 T3 Stack 里的角色是数据库访问层。它最舒服的一点是用 schema 文件定义模型然后自动生成类型安全的查询客户端。对 t3code 来说Schema 设计直接决定了后续功能扩展的难度我花了不少心思在这个环节。4.1 模型设计的出发点四张核心表用户、片段、标签、收藏。用户和片段是一对多片段和标签是多对多收藏是用户与片段之间的多对多但带了一个时间字段表示“谁在什么时候收藏的”。我的 schema 大致是这样的model User { id String id default(cuid()) name String? email String unique emailVerified DateTime? image String? snippets Snippet[] favorites Favorite[] accounts Account[] sessions Session[] } model Snippet { id String id default(cuid()) title String description String? content String language String default(text) createdAt DateTime default(now()) updatedAt DateTime updatedAt author User relation(fields: [authorId], references: [id]) authorId String tags SnippetTag[] favorites Favorite[] } model Tag { id String id default(cuid()) name String unique snippets SnippetTag[] } model SnippetTag { snippet Snippet relation(fields: [snippetId], references: [id]) snippetId String tag Tag relation(fields: [tagId], references: [id]) tagId String id([snippetId, tagId]) } model Favorite { userId String user User relation(fields: [userId], references: [id]) snippetId String snippet Snippet relation(fields: [snippetId], references: [id]) createdAt DateTime default(now()) id([userId, snippetId]) }两个值得细说的设计点。第一Tag 的name字段加了unique避免同样的标签名重复创建这是很多初写多对多关系时容易忽略的。第二Favorite 用联合主键id([userId, snippetId])这样数据库层面就保证了“同一个用户不能重复收藏同一个片段”不需要在业务代码里先查一遍再插入。4.2 迁移和生产环境的大坑开发环境跑npx prisma migrate dev很顺畅真正的问题出现在生产环境。我最初把 t3code 部署在 Vercel 的 serverless 环境里结果发现两个很隐蔽的问题。第一个是连接数问题。Prisma 默认会维持一个连接池但 serverless 函数是按需启动的每次新实例都可能创建新的数据库连接。如果数据库是普通 Postgres比如 Neon 或 Railway 的免费套餐连接数很容易被打满。解决方式是在 schema 里显式配置连接池大小datasource db { provider postgresql url env(DATABASE_URL) directUrl env(DIRECT_URL) }生产环境里DATABASE_URL使用带连接池的地址如?pgbouncertrue或通过连接池代理DIRECT_URL使用直连地址用于 Prisma Migrate 执行迁移。这两个地址分开配置是我这次部署中最关键的一个调整。第二个问题更容易忽略prisma generate必须在构建阶段执行。我一开始在本地跑prisma migrate dev时它会自动生成 client但部署环境执行的是pnpm build并不会自动执行 generate导致生产环境找不到 Prisma Client。解决方式是在 package.json 里把 build 脚本改成先执行生成再构建{ build: prisma generate next build }4.3 查询性能include 与 select 的使用开发初期我图省事所有列表查询都是include全量关联数据结果片段列表页的数据返回越来越大一个列表页可能把用户的头像、所有标签、所有收藏记录全部查出来。Prisma 数据量小的时候看不出来数据一多性能立刻崩。后来我把列表查询改成了显式select只查出前端需要的字段const snippets await prisma.snippet.findMany({ select: { id: true, title: true, description: true, language: true, createdAt: true, author: { select: { name: true, image: true } }, tags: { select: { tag: { select: { name: true } } } }, _count: { select: { favorites: true } }, }, orderBy: { createdAt: desc }, take: 12, });_count是 Prisma 提供的聚合查询可以直接统计收藏数。这个改动让列表查询量从原来的每条片段关联几十条记录降到了真正需要的几个字段。实际体验是列表页从首屏加载接近一秒降到两百毫秒以内。5. 鉴权、部署与上线阶段最容易翻车的地方功能开发完毕只是第一步真正让 t3code 跑在公网上供人访问的过程踩坑比写代码多得多。这里我把鉴权配置和部署方案单独拿出来说。5.1 NextAuth 与 tRPC 的中间件协同NextAuth 在脚手架里已经接入但它跟 tRPC 的联动细节需要自己处理。整个流程是NextAuth 通过 session cookie 标识登录状态tRPC 的 context 会在每次请求时从 cookie 中读取 session然后注入到 procedure 的 ctx 中。t3code 的server/api/trpc.ts里会这样创建 contextexport const createTRPCContext async (opts: { headers: Headers }) { const session await getServerSession(authOptions); return { session, prisma, ...opts, }; };这里要注意的是getServerSession必须在服务端调用而且需要传入同一个authOptions对象。如果你在另一个文件里复制了一份 authOptionssession 就会一直为 null。我调试了很久才发现是实例不一致导致的。5.2 部署时要盯紧的几个环境变量t3code 上线前我把环境变量整理成一张检查表每个变量缺失或者写错都会造成不同的问题变量作用踩坑提示DATABASE_URL数据库连接地址带连接池误用直连地址会导致连接数打满DIRECT_URL数据库直连地址用于迁移部分托管平台必须单独配置AUTH_SECRET会话加密密钥不设置会随机生成重启后所有登录失效AUTH_URL部署站点的完整地址自定义域名需要显式配置否则回调地址错误AUTH_PROVIDER_ID/AUTH_PROVIDER_SECRETOAuth 提供方凭证注意区分开发环境与生产环境的回调地址那次我因为没有显式设置AUTH_URL导致用户点击 Google 登录后回调地址变成了localhost:3000点击登录后页面一直跳转回首页。排查方法是在浏览器的网络面板里看完整的回调 URL再对着环境变量逐一核对几乎百发百中。5.3 为什么我在部署中后期转向 Dockert3code 最初部署在 Vercel 上前端体验很好构建也顺利。但有两个功能让 serverless 环境变得别扭一是后台需要定期清理过期数据二是计划加入基于 WebSocket 的实时协作而 Vercel 的 serverless 函数默认是无状态的短期进程不适合常驻连接。于是我把部署方案改成了 Docker一个容器里跑 Next.js 服务前置 Nginx 处理 HTTPS 和静态资源数据库继续使用托管 Postgres。这个方案的好处是运行环境完全可控不管是定时任务、长连接进程还是需要执行 Prisma Studio 管理数据都可以在容器里直接做。代价是需要自己维护服务器的系统更新、容器的日志和备份对开发者来说多了一份运维负担但对于长期运行的工具类项目这份投入是值得的。Dockerfile 的核心内容很简短FROM node:20-alpine AS base WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY . . RUN pnpm prisma generate pnpm build EXPOSE 3000 CMD [pnpm, start]这里最容易被忽略的是.dockerignore里要把.env、node_modules、.next全部排除否则镜像体积会膨胀而且本地环境变量可能被意外带入镜像。我一开始没写.dockerignore构建出来的镜像有 1.2GB加上之后降到了 400MB 以内。6. 性能优化与后续可扩展的方向t3code 上线跑了一段时间后我陆续做了一些性能优化也重新梳理了后续功能的方向。这里分享几个真实有效、而且改动成本不高的优化措施。6.1 列表页分页与模糊搜索依旧保持类型安全最开始列表页直接用findMany查全部数据随着片段数量增长响应时间越来越长。我把查询改成了基于 cursor 的分页配合关键词搜索const where { AND: [ input.q ? { OR: [ { title: { contains: input.q, mode: insensitive } }, { description: { contains: input.q, mode: insensitive } }, { content: { contains: input.q, mode: insensitive } }, ], } : undefined, input.tag ? { tags: { some: { tag: { name: input.tag } } } } : undefined, ].filter(Boolean), }; const snippets await prisma.snippet.findMany({ where, select: { ... }, orderBy: { createdAt: desc }, take: 12, ...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}), });cursor 分页相比传统 offset 分页的优势在于数据量大时性能稳定不会因为翻页越深越慢。前端用 React Query 的useInfiniteQuery对接滚动到底部自动加载下一页。数据库层面的搜索在数据量达到万级以后会明显变慢届时可以考虑给title、language字段建立索引或者引入 Postgres 的全文检索。这是 t3code 之后性能优化的自然方向。6.2 后续可以怎么扩展从现在的模型出发t3code 可以扩展的方向比较明确。一是标签云首页直接统计所有 Tag 的关联片段数量展示热门标签这个查询用 Prisma 的groupBy就能实现。二是代码片段导入功能支持从本地文件或 GitHub Gist 批量导入需要在鉴权层增加 OAuth scope 的扩展。三是浏览器插件选中网页代码右键直接保存到 t3code这会让工具从“手动复制粘贴”变成“随手收藏”显著提升使用频率。我个人还比较想做的是基于片段的轻量协作用户可以给别人的片段提交改进建议类似简化版 PR。这个功能需要新增 suggestion 表但现有的用户、片段、权限体系完全够用改动不会伤筋动骨。经验之谈一个 T3 Stack 项目做到能上线、能维护、能继续加功能的程度比想象的简单也比想象的复杂。简单是因为脚手架帮你解决了绝大部分初始配置复杂是因为真正的问题永远出现在“文档默认你已经知道”的细节里比如连接池、cookie、构建顺序、环境变量。把这些细节记录成清单比重复踩坑更有价值。回到 t3code 本身我现在的习惯是每新增一个功能先想清楚它在 tRPC router 里对应的是 query 还是 mutation、数据模型改动是否需要新的迁移、页面组件应该是服务端还是客户端。只要这三个问题想清楚编码时间通常不超过预期的一半。这个思考框架可能就是这次项目带给我最大的收获。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询