资讯详情

T3 Stack实战:用tRPC与Prisma构建全栈代码分享工具

📅 2026/10/28 6:04:10 | 华诺云谱 👁 阅读
T3 Stack实战:用tRPC与Prisma构建全栈代码分享工具
t3code 这个项目拆开看就是 T3 Stack 和 Code 的组合说白了就是拿社区里那套很出名的全栈技术组合去做一个在线代码片段管理与分享工具。我把它定位成一个既能日常自用、又能当全栈练手项目的作品用户可以快速创建代码片段、打标签、在线编辑、高亮预览还能把片段公开分享给同事或网友。对于正在学 Full-Stack、想一口气把 Next.js、TypeScript、Prisma、tRPC 这些串起来的朋友来说t3code 算是一条很完整的“真项目”链路不是那种每个文件讲一遍的 Demo而是真的能放上线跑的东西。这篇文章我就把整个项目从选型到落地的完整过程包括过程中踩过的坑都复盘一遍。1. 技术选型与架构思路为什么是 T3 Stack1.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.createtRPC 在服务端执行时会先用 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负责片段的 CRUDtagRouter负责标签自动补全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-applatest 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 height400px defaultLanguage{language} themevs-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和callbacksexport 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_URLfile:./db.sqlite NEXTAUTH_SECRET随便一长串随机字符串 NEXTAUTH_URLhttp://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 换 PostgreSQLt3code 最终部署到 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方法而不是在同一个接口里加个?onlyMetrue就完事后者太容易被手动改参数绕过。对输入长度做了限制title最长 100code至少 1 个字符但最多不超过 100KB防止有人塞一个几十万字的长文拖垮渲染性能。对 tRPC 的 query 做了超时和缓存策略。公开片段列表用 React Query 设置staleTime: 60_000缓存一分钟减少后端压力私有片段因为变化频繁不设置 staleTime保证实时刷新。这些安全习惯在真实项目里极其重要。很多新手教程都跳过了“权限校验”这一步但一旦你把应用放上线扫描器会特别照顾那些查询参数因此不要抱侥幸心理。4. 实际效果与关键技术指标4.1 构建体积与首屏加载沒优化前的首版Monaco Editor 那一个包就占了 1.4MBgzip 后约 400KB首屏渲染明显卡。后来我做了两个优化第一个是动态加载编辑器只有用户进入“新建片段”或“编辑片段”页面时才加载 Monaco列表页只加载轻量的react-syntax-highlighter第二个是关闭 Monaco 用不到的扩展功能比如suggest、parameterHints对纯编辑场景影响不大但能把主包进一步缩小。优化后t3code 的首页 JS 体积从 1.4MB 降到 481KBLighthouse 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 generatetRPC 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 前后端类型三处对照一下绝大多数坑都能快速定位。这套全栈组合日常做工具和原型是真的舒服。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑