
手头有个做了两个多月的全栈小项目一直用的代号就是 t3code。最初它只是我想验证一下 T3 Stack 到底能不能扛住真实业务的小玩具后来发现这个组合把我之前最头疼的三件事——前后端类型不同步、接口联调效率低、代码重复维护——全解决了于是干脆把它做成了一款团队内部的代码片段管理工具。这篇文章就是 t3code 从 0 到 1 的完整复盘包括我为什么不先用传统 REST API、tRPC 的 Router 到底怎么设计、Prisma 连表踩了哪些坑、部署到服务器上又炸过几次。如果你正在做 Next.js 或 React 全栈方向的项目想把前后端打通这件事做得更省心这份笔记可以直接当参考。1. t3code 到底是个什么项目代码片段管理工具的技术选型复盘1.1 为什么说前后端类型不同步是最大的隐性成本t3code 最初的定位很简单团队里每个人手头都积攒了一堆常用代码片段散落在各个聊天记录、本地文件、甚至纸质笔记里想找的时候翻半天。做一个轻量的共享平台让每个人能上传、分类、检索代码片段这是典型的中后台工具型需求。这类项目有一个很突出的痛点传统的后端写接口 前端写类型定义模式在快速迭代时成本高得离谱。现实中大多数团队都是这么干的后端把 DTO 写一遍前端在 TypeScript 的 interface 里再抄一遍两边各维护一份字段清单。等接口版本一更新全局搜字段名改半天漏改一处就到运行时炸一个红色报错。t3code 是团队内部工具我不想把时间花在这种重复劳动上也不想为了一个查询功能单独抽一层 service。后来我决定用 T3 Stack 来重构整个项目。T3 Stack 的核心思路是用一套严格类型约束的体系把数据模型、服务端逻辑、前端调用三层彻底打通。TypeScript 提供语言层面的类型tRPC 把函数调用变成前后端共享的链路Prisma 负责数据库层的类型生成。这样一来删一个字段、改一个返回值类型所有引用点会在编译期直接报错而不是等到用户点了某个按钮才崩。1.2 T3 Stack 四件套各自的角色与配合逻辑T3 Stack 是四个工具的组合Next.js 负责页面和渲染TypeScript 提供基础类型Tailwind CSS 负责样式tRPC 负责前后端接口调用。可选组件里我加上了 Prisma 做 ORMNextAuth 做登录认证。很多人第一次听 tRPC 会懵这不是把服务端函数直接暴露给前端吗安全吗其实 tRPC 不是 RPC 中间件那种远程调用它本质上是基于 HTTP 的调用约定。每个 procedure 都可以配置鉴权、参数校验和错误处理安全边界取决于你怎么设计而不是它天生就是敞开的。你可以把 tRPC 理解成一份前后端共享的函数目录后端在 router 里定义好函数前端像是直接引入一个本地模块来调用但它背后实际走的是 HTTP 请求。Next.js 在这个组合里负责页面路由和服务端能力。t3code 的页面有列表页、详情页、编辑页全部由 Next.js 的 Pages Router 管理。Tailwind CSS 我本来还担心不好上手实际用下来确实适合工具型项目——不需要维护一堆 CSS 文件在 JSX 里直接写原子类配合复制粘贴一个组件就能快速成型。1.3 为什么不选 REST 和 GraphQL我看到不少人在做类似工具时会先纠结要不要用 GraphQL我的意见很直接不要为了一个团队内部工具引入 GraphQL。GraphQL 的强项是大前端多端的复杂数据聚合场景但它的 schema 设计、resolver 分层、缓存策略在小团队里很容易变成新的维护负担。REST 的方式虽然稳妥但 URL 设计、方法映射、请求/响应类型定义每一环都得手动对齐做个 CRUD 就够折腾了。t3code 这个场景本质上是登录用户创建数据、读取数据、按标签或关键词搜索。所有调用都围绕固定的数据模型没有复杂的多端差异。tRPC 恰好把最麻烦的类型定义对齐这个环节去掉了服务端 router 里定义的 query 和 mutation前端马上能拿到带类型的 hooks不需要 OpenAPI 生成器也不需要维护接口文档。2. 用 create-t3-app 初始化 t3code手把手过一遍脚手架关键选项2.1 创建项目时的选项怎么勾如果你从零开始建议直接用官方脚手架避免手动搭建时漏掉关键配置。t3code 就是npx create-t3-applatest生成的创建命令一行搞定npx create-t3-applatest t3code过程中会有一系列交互式选项。我第一次创建时只勾了 tRPC跑起来才发现 Prisma 没装context 里没法注入数据库实例后面又手动补了一堆配置白白折腾了半小时。所以我的建议是如果你明确要做带存储的项目一开始就把这些选项全勾上。我最终选择的组合TypeScript必选整个 T3 Stack 的地基Tailwind CSS选样式方案tRPC选核心通信层Prisma选数据库 ORMNextAuth选登录认证ESLint选保持代码规范有一点要注意Node.js 版本最好在 18 以上我早期用 16 跑 build 时遇到过一些依赖兼容性问题。包管理器我用的是 pnpm安装速度和磁盘占用都比 npm 舒服。2.2 脚手架生成后的目录拆解create-t3-app 生成的目录结构非常规整t3code 的项目目录大概是这样的t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── pages/ │ │ ├── api/ │ │ │ └── trpc/[trpc].ts │ │ ├── index.tsx │ │ ├── snippets/[id].tsx │ │ └── create.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── snippet.ts │ │ │ │ ├── tag.ts │ │ │ │ └── root.ts │ │ │ └── trpc.ts │ │ ├── db.ts │ │ └── auth.ts │ ├── styles/ │ │ └── globals.css │ ├── utils/ │ │ └── api.ts │ └── env.js └── ...刚看到这些文件的时候很多人会犯迷糊为什么又有个 trpc.ts 在server/api/下面又有个[trpc].ts放在pages/api/里这两个文件各管各的pages/api/trpc/[trpc].ts是 HTTP 入口负责把/api/trpc/*的请求转发给 tRPC 处理器server/api/trpc.ts是真正创建 Router 实例、定义 context、封装 procedure 的地方。src/env.js也是一个容易被忽略的文件它用来做环境变量的运行时校验。比如DATABASE_URL、NEXTAUTH_SECRET这类变量如果没配置页面会在启动时报错而不是运行到一半才暴露问题。我建议第一次跑项目前先复制.env.example成.env把DATABASE_URL填好否则prisma migrate会直接失败。3. t3code 的核心链路数据模型设计与 tRPC Router 的完整实现3.1 Prisma Schema 设计Snippet、Tag、User 三张表的关联关系t3code 的数据模型不算复杂但设计上有一个点值得展开说标签系统。一开始我想偷懒直接在 Snippet 表里存一个逗号分隔的字符串字段tags: react,typescript。后来意识到这样做搜索和统计都非常痛苦比如你想查用了 react 标签的 snippet用字符串去匹配会很别扭。所以我改成标准的多对多关系一个 Snippet 可以关联多个 Tag一个 Tag 也可以被多个 Snippet 使用。中间表SnippetTag用来维护关联关系。整个 Schema 长这样model User { id String id default(cuid()) name String? email String? unique snippets Snippet[] } model Snippet { id String id default(cuid()) title String content String db.Text language String default(text) visibility String default(team) authorId String author User relation(fields: [authorId], references: [id]) tags SnippetTag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique snippets SnippetTag[] } model SnippetTag { snippetId String tagId String snippet Snippet relation(fields: [snippetId], references: [id]) tag Tag relation(fields: [tagId], references: [id]) id([snippetId, tagId]) }关于主键我用的是${id}字符串加cuid()生成而不是自增 Int。原因是这个工具后续可能要部署到分布式环境里cuid 可以在多个实例生成而不担心冲突。对于团队内部几百条数据来说这个开销可以忽略不计。3.2 从 Router 定义到 mutation 实现创建代码片段的完整代码走读Router 是 tRPC 的服务端核心。t3code 里的 snippetRouter 承担了列表查询和创建功能。我先看列表查询它要支持按标签和关键词过滤import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; export const snippetRouter createTRPCRouter({ list: publicProcedure .input( z.object({ tag: z.string().optional(), search: z.string().optional(), }) ) .query(async ({ ctx, input }) { return ctx.db.snippet.findMany({ where: { ...(input.tag ? { tags: { some: { tag: { name: input.tag } } } } : {}), ...(input.search ? { title: { contains: input.search } } : {}), }, include: { author: { select: { id: true, name: true } }, tags: { include: { tag: true } }, }, orderBy: { createdAt: desc }, take: 50, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string().default(text), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { const snippet await ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, authorId: ctx.session.user.id, tags: { create: input.tags.map((name) ({ tag: { connectOrCreate: { where: { name }, create: { name }, }, }, })), }, }, }); return snippet; }), });这段代码里有一个特别值得解释的点就是connectOrCreate。标签是用户输入的可能已经存在也可能从没出现过。如果用connect数据库会报错说找不到对应记录如果用create重复标签会违反唯一约束。connectOrCreate的意思是有就连没有就建一条语句解决存在性判断省掉先查询再写入的额外逻辑。我的经验是在 Prisma 的嵌套写入场景里connectOrCreate是处理关联数据的默认思路尤其适合标签、分类这类用户自由输入的关联项。3.3 给 procedure 加权限控制protectedProcedure 的原理创建 snippet 必须登录但浏览公开 snippet 可以匿名。t3code 的trpc.ts里定义了两个基础 procedurepublicProcedure和protectedProcedure。import { initTRPC, TRPCError } from trpc/server; import { ZodError } from zod; import { type Context } from ./context; const t initTRPC.contextContext().create(); export const publicProcedure t.procedure; 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 }, }, }); });其实它的原理很像中间件或者说像洋葱模型请求进来先经过.use()里注册的函数只有通过校验才会继续执行真正的业务逻辑。如果 session 里没有用户直接抛出一个UNAUTHORIZED错误前端会拿到 401 的响应。我一开始犯过一个错误在 route handler 里手动判断 session每个操作都写一遍if (!session) throw ...。有了protectedProcedure这个逻辑就被抽成了通用封装。新写一个需要登录的接口时只需要把publicProcedure换成protectedProcedure所有鉴权逻辑自动生效。4. 前端怎么把这些 API 用起来tRPC React 的 hooks 调用模式4.1 createTRPCNext 客户端初始化与 QueryClient 缓存前端的核心入口是src/utils/api.tst3code 在这个文件里创建了一个带类型的 tRPC 客户端import { httpBatchLink } from trpc/client; import { createTRPCNext } from trpc/next; import type { AppRouter } from ~/server/api/root; export const api createTRPCNextAppRouter({ config() { return { links: [ httpBatchLink({ url: /api/trpc, }), ], }; }, ssr: true, });这段代码最核心的是createTRPCNextAppRouter。AppRouter是从服务端导出的类型它把所有 router 的类型信息带到了前端。这就是 t3code 能实现类型全链路打通的关键前端的api对象上有哪些方法、每个方法接受什么参数、返回什么类型全部由服务端 Router 推导出来。httpBatchLink这个链接需要特别说一下。它允许在一个 HTTP 请求里合并多个 procedure 调用。比如列表页有时会同时请求 snippet 列表和标签列表tRPC 会把它们打包成一次请求发给服务端减少网络往返。我第一次跑项目时发现 Network 面板里请求数比想象中少得多就是这个 batch 机制在起作用。ssr: true表示开启服务端渲染预取。这样进入列表页时数据在服务端已经查好HTML 直接带出来既有利于首屏速度也能避免部分内容在客户端闪烁。4.2 在列表页用 trpc.snippet.list.useQuery() 的完整实践接下来是我最满意的一部分前端调用后端的代码短得不像真的。在 t3code 的列表页组件里查列表只需要一行import { api } from ~/utils/api; const ListPage ({ activeTag, keyword }: { activeTag?: string; keyword?: string }) { const { data, isLoading } api.snippet.list.useQuery({ tag: activeTag || undefined, search: keyword || undefined, }); if (isLoading) { return div加载中.../div; } return ( ul {data?.map((snippet) ( li key{snippet.id} h3{snippet.title}/h3 p{snippet.language}/p span{snippet.author.name}/span {snippet.tags.map(({ tag }) ( span key{tag.id}{tag.name}/span ))} /li ))} /ul ); };有没有发现这里没有手写 fetch、没有手动拼接 URL、没有手动处理 JSON 序列化。useQuery背后是 react-query 的完整机制缓存、重试、失效、状态管理全部被封装好了。而且data的类型是Snippet[]里面的author.name可以直接点出来如果我把include里的字段名写错TypeScript 会在编译期就提醒。也有个反直觉的地方data?.map里返回的列表项类型和 Prisma 返回的记录是严格对应的。因为 tRPC 会把服务端返回类型完整地传递到前端任何字段缺失或类型不匹配在开发时就会报错而不是上线后才发现。4.3 mutation 之后怎么让列表自动刷新在创建页表单里提交成功后需要让首页的列表自动更新。传统做法可能是刷新页面或者手动调一次查询接口。t3code 里用 mutation 加 invalidation 的模式const utils api.useUtils(); const createMutation api.snippet.create.useMutation({ onSuccess: () { utils.snippet.list.invalidate(); }, }); const handleSubmit async (values: { title: string; content: string }) { await createMutation.mutateAsync({ ...values, language: typescript, tags: [] }); };useUtils()返回的utils.snippet.list.invalidate()会把当前页面上所有使用snippet.list查询的缓存标记为过期react-query 会自动重新请求最新数据。刚接触这套模式的人可能会问为什么不直接在 mutation 的返回结果里把新数据塞进缓存理论上可以但 invalidate 有一个好处——它对查询参数是全覆盖的。比如你现在有全部标签列表和只显示 react 标签列表两份缓存新增一条带 vue 标签的数据如果只操作缓存你很难判断哪些查询需要更新用 invalidate 则简单粗暴所有相关查询整体失效重新请求。4.4 页面级数据预取服务端渲染时怎么把数据塞进页面t3code 的首页是公开的我希望搜索引擎和直接访问的用户能看到内容而不是白屏等待 JS 加载。这里用到createServerSideHelpers在getServerSideProps里预取数据import { createServerSideHelpers } from trpc/react-query/server; import { appRouter } from ~/server/api/root; import { createContext } from ~/server/api/context; export const getServerSideProps async () { const ssg createServerSideHelpers({ router: appRouter, ctx: await createContext(), }); await ssg.snippet.list.prefetch({ tag: undefined, search: undefined, }); return { props: { trpcState: ssg.dehydrate(), }, }; };这一块我重点解释dehydrate的作用它把 react-query 在服务端查到的数据序列化成一个纯对象塞进页面的 props客户端拿到后直接作为初始缓存不会发起重复请求。说白了就是把服务端查到的数据冻住带到浏览器省掉一次网络请求同时保留了 react-query 后续的缓存更新能力。如果页面里既有公开数据又有登录数据可以在 props 里一并预取逻辑不会冲突。5. t3code 上线前踩过的坑Prisma 部署、鉴权回调、类型丢失5.1 Vercel 部署时 Prisma 引擎报错的处理这是我在 t3code 部署阶段遇到的第一个大坑。本地跑得风生水起一扔到 Vercel 上页面直接报PrismaClientInitializationError控制台里密密麻麻的引擎二进制错误。原因其实不复杂Prisma 的 Client 是依赖查询引擎二进制文件的这个二进制需要和运行环境匹配。本地是 macOS 或 Windows 的引擎Vercel 上的 Node 运行环境通常是 Linux 的默认打包上去的引擎就不匹配。解决方式是在schema.prisma里显式声明二进制目标generator client { provider prisma-client-js binaryTargets [native, linux-debian-openssl-3.0.x] }改完之后重新执行prisma generate再部署。如果你用 Docker 部署也需要在构建镜像里执行prisma generate否则同样会遇到引擎问题。这个坑属于本地 OK、线上挂的最典型代表碰上时先别怀疑代码逻辑大概率是环境差异。5.2 NextAuth 回调地址与 NEXTAUTH_SECRET 的坑t3code 的登录我用的 NextAuth。本地测试用 localhost 一切正常部署到线上之后点击登录按钮跳转一圈居然回到了 404 页面。这个问题的根因是NEXTAUTH_URL环境变量没有配置。本地运行时 NextAuth 会自动读取请求的 host但线上环境必须显式把NEXTAUTH_URL设置为自己的域名否则回调地址拼不出来。还有一个更容易踩的坑NEXTAUTH_SECRET。我没有自己生成一开始想省事让 NextAuth 自动生成结果每次服务重启或者 Vercel 重新构建后会话加密密钥变了用户登录状态全部失效。后来我用openssl rand -base64 32生成一个固定值写进环境变量彻底解决。还有一个小坑专门提醒一下如果你用 Google Provider 做登录并且允许用户用邮箱注册同一个邮箱如果切换登录方式会触发唯一约束报错。这不是 bug是 NextAuth 默认不允许一个邮箱绑定多种登录方式。我花了一个晚上才搞清楚当时一度怀疑是数据库有脏数据。5.3 tRPC 服务端类型导出失败时怎么排查tRPC 的类型安全依赖于服务端导出类型到客户端这个通道。一旦这条链路上出现类型不合法的问题整个项目编译都会报错。我在 t3code 里遇到的典型案例是想在 mutation 的 input 里传一个Date对象。t3code 的创建表单里有个定时上线需求需要传一个发布时间。我最初直接在 Zod schema 里写z.object({ publishAt: z.date(), })结果前端调用时TypeScript 直接红了一片。原因很直观Date是一个类没法在 HTTP 请求里直接序列化tRPC 的 input 必须使用可序列化的数据类型。解决方案是传 ISO 字符串到服务端再转成Date。如果你在部署后遇到forbidden错误也要检查一个细节调用 tRPC 时fetch 的 headers 里有没有Content-Type: application/json。tRPC 默认对 content-type 校验很严格很多自定义的 fetch 封装会忽略这一点导致请求被服务端拒绝。这个错误不报在 TypeScript 层而是运行时才出现所以更容易懵。5.4 性能优化避免 N1 查询的 include 写法t3code 的列表页一开始只查 snippet然后循环里再去查每个 snippet 的 author 和 tags。数据量小的时候没感觉等 snippet 数量到了几百条列表页就开始明显变慢数据库日志里的查询数量也很吓人。这个就是经典的 N1 问题一次主查询加上 N 次关联查询。解法是在 Prisma 里用include一次把关联数据带出来const snippets await ctx.db.snippet.findMany({ include: { author: { select: { id: true, name: true } }, tags: { include: { tag: true } }, }, });但这里也有一个度的问题。如果列表页只需要展示标签名不需要 tag 的完整信息就不要 include 整个 tag 对象用select只取需要的字段。include 太多关联会让单条 SQL 变得膨胀查出来的数据量远超实际用途。我在 t3code 的列表页最终并没有对所有字段做全量 include而是只带author.id, author.name和标签名详情页才使用完整数据。6. 我在 t3code 上沉淀的几个可以直接抄走的工具封装6.1 统一的 errorFormatter 让前端别拿着一堆英文报错骂人t3code 早期版本有个体验问题用户提交表单时如果数据不合法前端收到的错误信息是 tRPC 默认的英文报错比如Expected string, received undefined。团队同事看了直摇头。我后来在所有 procedure 创建时配置了统一的errorFormatter把常见的 Zod 校验错误翻译成友好文案const t initTRPC.contextContext().create({ errorFormatter({ shape, error }) { if (error.cause instanceof ZodError) { return { ...shape, message: 提交的数据格式有误请检查输入项, }; } return shape; }, });这样做的好处是统一的所有输入校验错误在前端表现为同一种提示不再需要每个表单组件单独处理错误文案。如果你希望把具体字段的错误也透出可以在cause里继续解析 ZodError 的issues按字段名拼出更精确的信息但要注意别把内部信息暴露给用户。6.2 用 infiniteQuery 做滚动加载别一次性把几千条丢给用户snippet 列表数据量变大之后一次性返回 50 条明显不够用。我一开始想的是加一个加载更多按钮自己维护一个游标参数。后来发现 tRPC 的infiniteQuery才是更省心的方案。服务端 router 需要先定义一个支持游标的 queryinfiniteList: publicProcedure .input( z.object({ take: z.number().default(20), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) { const items await ctx.db.snippet.findMany({ take: input.take 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { id: asc }, }); let nextCursor: string | undefined undefined; if (items.length input.take) { const nextItem items.pop(); nextCursor nextItem!.id; } return { items, nextCursor }; }),前端调用const { data, fetchNextPage, hasNextPage } api.snippet.infiniteList.useInfiniteQuery( { take: 20 }, { getNextPageParam: (lastPage) lastPage.nextCursor, } );滚动到底部时触发fetchNextPage新数据会按页追加到data.pages数组里。相比自己管理游标这套机制把缓存和分页状态都接管了代码量少很多而且每个页面的缓存是独立命名的切回来不会重新加载。6.3 前后端共用一套 Zod schema最后说一个我建议所有用 tRPC 的人都养成的习惯把输入校验规则抽到共享文件里。t3code 的创建表单和服务端 router 过去各写了一套校验前端在表单里用 HTML 的required属性服务端在 input 里用 Zod。两套规则一旦出现偏差就会出现前端觉得自己填得没问题服务端却拒绝提交的奇怪现象。后来我把校验规则统一放到src/lib/validators/snippet.tsimport { z } from zod; export const createSnippetSchema 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, 标签最多选 5 个), }); export type CreateSnippetInput z.infertypeof createSnippetSchema;服务端引入后直接在 input 里引用create: protectedProcedure.input(createSnippetSchema).mutation(...)前端在表单提交前也做一次safeParse不满足时直接显示error.issues[0].message。用户看到的中文提示和服务端返回的是同一套不会再出现两边口径不一致的尴尬。我后来新项目里的 router 结构基本都是从 t3code 这版里直接复制过去微调尤其是protectedProcedure封装和共享 Zod schema 这两个工具省下来的重复劳动比我当初预想的多得多。如果你也准备搭一个全栈工具型项目我建议先在这两处下功夫后面写业务接口会顺手很多。