T3 Stack实战:用Next.js、tRPC与Prisma打造个人内容管理系统
项目代号定成 t3code 的时候,我手头上其实只有一个很朴素的需求:把散落在各家笔记里的技术碎片统一收进一个能长期维护的仓库,并且以文章、标签、收藏和搜索的形式重新组织起来。真正开始选型之后,我发现越是这种看起来简单的需求,越容易在架构上失控。一个人维护前后端分离工程、再维护一份接口文档、再定期忍受前后端类型对不上的联调问题,这套成本放在小项目里完全不划算。于是我决定用全栈框架一步到位,最终选择了 Next.js + TypeScript + tRPC + Prisma 这套组合,也就是大家常说的 T3 生态,项目代号就叫 t3code。
这篇文章我会把 t3code 从业务边界、Prisma 建模、tRPC 路由组织,到列表缓存和线上维护的完整过程都摊开讲。如果你也在评估要不要用 T3 Stack 做全栈项目,或者已经用上了但在某些边界场景里卡住,这些内容应该能给你不少可以直接抄作业的参考。
1. 业务边界与选型逻辑:t3code 到底要解决什么问题
1.1 项目形态:一个内容仓库,而不是一个重系统
在写第一行代码前,我把 t3code 的用户边界定得非常小:发布和收藏只有我自己能用,访客只能读。范围一旦缩到单人,权限模型就变得很干净,不需要多租户、不需要邀请、也不需要审批流。围绕这个需求,系统最终被拆成四个页面形态:
- 首页文章流:按发布时间倒序展示已经发布的文章
- 标签聚合页:点击标签可以过滤出同主题文章
- 文章详情页:渲染 Markdown 正文和元信息
- 写作与收藏管理:需要登录后使用
这套形态听起来简单,但足够覆盖一个内容系统的全部核心链路:写、存、查、筛、收藏。我故意没有在一开始上过多的功能,比如站内全文搜索和评论区,因为前期做太多,代价会很直观地体现在后续每次迭代要承载的历史包袱上。先把这个闭环跑通,后续要扩展也不难。
1.2 为什么把前后台放进同一个 Next.js 应用
不少做前端的同学听到全栈两个字,下意识就会想拆成 SPA 前端加 REST API 加独立认证服务。对于大团队,拆的好处是边界清晰、可以独立部署。但对于 t3code 这种一个人长期迭代的项目,拆的直观代价是:前端一个PostDTO,后端一个PostEntity,中间再夹一个 mapping 层。每次给文章多返回一个readingTime,要同时改 Prisma 查询、后端返回类型、前端类型、接口文档四处。这不是夸张,这是我在过去的项目里真实掉过的坑。
T3 Stack 的出发点就是把这些成本压缩掉:前端直接消费后端导出的查询函数,类型从 Prisma 模型一路推断到组件 props。接口上的字段发生变化时,编译器会在你构建前就把问题暴露出来,而不是等到运行时才看到 undefined。对一个人维护的项目来说,这种“类型系统替你兜底”的能力,比所谓的高性能并发架构重要得多。
1.3 为什么不用 REST 也不用 GraphQL
我自己也写过 REST,也浅尝过 GraphQL,但在 t3code 这个项目里,两者都显得不够轻。REST 的问题是要手工维护接口契约,一个字段在不同页面里的形状经常不一样,最后只能靠一套 DTO 妥协。GraphQL 的问题是 ORM model 和 GraphQL 类型经常要各写一遍,一旦字段更新,两处都要同步,再加上缓存层的复杂度,对单人项目来说负担反而更重。
tRPC 的定位正好卡在中间:它不是一门新的查询语言,而是把函数调用直接暴露给前端。过程和过程之间的输入输出用 zod 做运行时校验,类型再由 TypeScript 做静态推断。前端调用api.post.listPublished()时,得到的结果类型和你在后端写的 query 返回值完全一致。各种方案里挑来挑去,这是我觉得最顺手的一种。
2. Prisma schema 建模:每一条数据关系怎么定下来的
2.1 从实体到迁移:一版能直接落地的数据模型
t3code 的表结构相当克制,总共四张表:用户、文章、标签、收藏。下面是我的 Prisma schema 核心部分:
model User { id String @id @default(cuid()) name String? email String? @unique image String? posts Post[] favorites Favorite[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String slug String @unique summary String @default("") content String published Boolean @default(false) author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String tags Tag[] @relation("PostTags") favorites Favorite[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([published, createdAt]) } model Tag { id String @id @default(cuid()) name String @unique slug String @unique posts Post[] @relation("PostTags") } model Favorite { user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String post Post @relation(fields: [postId], references: [id], onDelete: Cascade) postId String createdAt DateTime @default(now()) @@id([userId, postId]) }几个决策点我想逐一说明。主键我全部用 CUID 而不用自增整数,不是因为自增有什么原则性问题,而是文章和收藏这类资源在外链、导入导出时,容易被遍历的数字 ID 是我刻意想避免的。CUID 既不暴露总量,也方便未来做离线生成。slug 字段加了 unique 约束,保证 URL 不会因标题重复而打架。收藏表的复合主键@@id([userId, postId])从数据库层就阻止了同一用户对同一篇文章的重复收藏。
2.2 多对多关系为什么先用隐式写法
Post.tags和Tag.posts之间我用的是 Prisma 隐式多对多关系,不会在 schema 里看到一张 PostTag 表,但数据库会自动创建。默认行为符合当前阶段需求:一个文章可以有多个标签,一个标签可以挂多篇文章,关联表本身没有额外属性需要记录。
如果未来要给标签加权重,比如“某个标签在某篇文章里排在第一位”,那隐式关系就不够用了,得换成显式中间模型,并在中间表加 position 字段。做这个判断的时机要卡准:不要在还没有明确需求时就引入中间模型,不然每个查询都会多一次 join;也不要等数据结构已经产生脏数据后再改。t3code 现在还在早期,一旦确定要排序,我会第一时间把它改成显式关联,而不是继续硬扛。
2.3 迁移流程里必须分清楚的两个命令
本地开发我用的是:
npx prisma migrate dev --name init它会将 schema 变化转成 SQL 迁移文件并在开发库执行。如果只是临时试字段,也有人用prisma db push,但从项目维护的角度我并不推荐在多人项目里长期使用它,因为db push不会产生迁移记录,时间一长就没人说得清线上库到底是怎么变成目前结构的。我在 t3code 里保持的纪律是:每次改 schema,都生成一次迁移文件并提交到 git;提交信息里把改动点写清楚,比如migrate: add reading time fields。这样将来不管从哪个 commit 部署,都能用迁移文件把数据库状态同步到一致的版本。
3. tRPC 路由组织、zod 校验与服务端组件的调用边界
3.1 一个典型的 postRouter 长什么样
t3code 的接口层没有统一放在一个巨大的 api.ts 文件里,而是按业务域拆成多个 router 文件。以文章接口为例:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; const postInput = z.object({ title: z.string().min(1), slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), summary: z.string().max(200).default(""), content: z.string().min(1), tagIds: z.array(z.string()).optional(), }); export const postRouter = createTRPCRouter({ listPublished: publicProcedure .input(z.object({ cursor: z.string().optional(), tag: z.string().optional() })) .query(async ({ ctx, input }) => { return ctx.db.post.findMany({ where: { published: true, tags: input.tag ? { some: { slug: input.tag } } : undefined, }, select: { id: true, title: true, slug: true, summary: true, createdAt: true, tags: { select: { name: true, slug: true } }, }, orderBy: { createdAt: "desc" }, take: 20, }); }), create: protectedProcedure .input(postInput) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), toggleFavorite: protectedProcedure .input(z.object({ postId: z.string() })) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.favorite.findUnique({ where: { userId_postId: { userId: ctx.session.user.id, postId: input.postId, }, }, }); if (existing) { await ctx.db.favorite.delete({ where: { userId_postId: { userId: ctx.session.user.id, postId: input.postId, }, }, }); return { favorited: false }; } await ctx.db.favorite.create({ data: { userId: ctx.session.user.id, postId: input.postId }, }); return { favorited: true }; }), });这样一段代码同时承担了校验和业务逻辑。前端拿到的返回值直接有完整 TypeScript 类型,不需要额外定义 PostListItem。当 listPublished 的 select 字段变化时,前端列表组件的类型也会同步变化,真的做到了改一处全链路生效。
3.2 protectedProcedure:登录态从中间件进业务
在 tRPC 里,区分公开和登录后才能访问的过程,不是靠页面判断,而是靠过程本身。protectedProcedure的实现是这样的:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });这个中间件会在进入业务逻辑前检查 session。未登录就直接抛 UNAUTHORIZED,前端会收到对应的错误码,从而决定是弹出登录框还是跳转到首页。所有需要登录的 mutation 都统一用 protectedProcedure,而不是在每个函数里自己写 if,避免重复判断漏掉某个入口。
3.3 Server Components 里不要直接调用 useQuery
T3 Stack 默认提供的客户端调用方式是 useQuery、useMutation 那套 React Hooks,这是给客户端组件用的。t3code 里很多页面是 Next.js App Router 的服务端组件,我在一开始踩了一个坑:直接在服务端组件里写api.post.listPublished.useQuery(),结果运行时报错,提示 hooks 无法在服务端组件里使用。
正确的做法是用 tRPC 的 server caller:
import { createCaller } from "@/server/api/root"; import { createTRPCContext } from "@/server/api/trpc"; const ctx = await createTRPCContext({ headers: await headers() }); const caller = createCaller(ctx); const posts = await caller.post.listPublished({});这里传递 headers 的目的是让服务端组件也能继承会话上下文,使 session 在 RSC 里可用。服务端组件初始化时数据获取一次到位,客户端拿到的是完全渲染好的页面,而后续的交互部分再交给 Hooks。这种混合模式让我既保留了服务端渲染的 SEO 和首屏速度,又拿到了 tRPC 的端到端类型安全,体验比单独的 SPA 方案好不少。
4. 列表页优化:无限滚动、React Query 缓存与查询瘦身
4.1 从一次性加载改成 cursor 分页
文章数量上来之后,首页一次加载全部文章显然不行。我的列表接口改成 cursor 风格,整个流程分三块:后端返回 items 和 nextCursor,前端通过 useInfiniteQuery 的 getNextPageParam 拿到下一页游标,滚动到底部时触发 fetchNextPage。
使用 cursor 而不是 offset 分页的原因是:offset 越往后面翻,数据库需要扫描并跳过的行数越多,性能会线性恶化;cursor 则是从指定位置直接继续取,对深分页友好很多。另外 Prisma 对 cursor 分页有一个硬性要求:cursor 所用字段必须是排序字段的一部分,或者带唯一索引。我的列表按 createdAt 倒序,如果直接拿 createdAt 当 cursor,两条文章创建时间相同会出问题,所以我采用 id 作为游标,同时用 createdAt 排序。
4.2 staleTime 与缓存更新的取舍
平时看着小项目,最容易忽略 React Query 的缓存配置。t3code 默认配置下,页面重新聚焦时列表会自动重新请求,这对频繁切 tab 的我来说其实很浪费。我给列表接口设置了staleTime: 30_000,文章详情设置更长一些,避免在详情页和列表页之间切换时反复 loading。
收藏操作后,我没有傻傻地整体刷新列表,而是手动更新缓存,用 setInfiniteData 直接修改现有页面数据。这种做法比 refetch 响应更及时,也不会造成列表闪烁。前提是后端接口返回值稳定,否则缓存更新逻辑会成为第二个 bug 源头。
4.3 select 投影:列表页不碰 content 字段
一个很常见的性能反模式是列表查询把整篇 Markdown 正文也查出来,虽然前端没渲染,但数据已经从数据库传到应用进程,再经过 JSON 序列化传到浏览器。文章一多,页面体积会很难看。t3code 的列表查询从第一版就用 Prisma 的 select 只取需要的字段:id、title、slug、summary、createdAt、tags。也是从这一版开始,我把“能少查一个字段就少查一个字段”当成查询设计的基本纪律。
很多人会问,是不是该用 Redis 之类的缓存。我的判断是,在数据只有几百篇的规模下,先把数据库查询瘦身和 HTTP 响应体积控制好,效果远比引入一层缓存框架更直接。Redis 等缓存带来的复杂度在项目快速增长后才值得考虑。
5. 部署之后的迁移命令、日志排查和备份实操
5.1 生产环境永远用 migrate deploy
本地开发可以随时改 schema 然后跑 dev 迁移,生产环境不一样。我把代码推到服务器后,第一件事就是执行:
npx prisma migrate deploy这个命令只会执行尚未应用的迁移文件,不会根据最新 schema 生成新的迁移,也不会像 db push 那样直接同步表结构。这样可以确保线上数据库与迁移记录保持一致。如果某次部署前忘记跑这个命令,之后再改 schema 就会遇到很尴尬的迁移冲突,所以我把这一步写进了部署脚本里,让流程替我避免记忆负担。
5.2 线上问题排查:先看日志,再查数据
上线后遇到最典型的案例是某个收藏接口偶发 500。本地复现不了,因为本地数据总是干净的。后来我从日志里找到 PostgreSQL 返回的约束错误,再去数据库一查,发现有两条历史数据缺少字段,导致复合唯一索引判断出错。于是先把脏数据修正,再调整代码里对缺失字段的兜底逻辑,问题才彻底解决。
这让我养成了习惯:任何线上问题排查,第一步永远是拿到完整错误日志,第二步才谈得上逻辑分析。不要凭记忆去猜原因,更不要一上来就重构代码。日志至少记录请求路径、请求方法、状态码、错误消息和发生时间。不需要上太重型的监控平台,只要有个地方能按时间检索日志就行。
5.3 备份不只是导出,还要演练恢复
数据库备份这件事,很多项目会把脚本写成 pg_dump 生成了就算完。但真正要命的是你需要恢复时,发现脚本备份出来的文件在空库上根本导不进去。我在 t3code 上验证过一次全流程恢复:新建空库、重新跑迁移、再导入备份数据,确认一切正常后才放心把备份脚本固定下来。低频备份如果又没有恢复演练,等于没有备份。更重要的是,一定要把恢复步骤写进文档,因为三个月后的你不会记得当初是怎么恢复的。
5.4 环境变量管理里最容易踩的几个坑
第一,不要把.env文件提交进 git;第二,生产环境的连接字符串应该走部署平台的环境变量,而不是硬编码在代码里;第三,数据库连接串要区分普通连接与直连,尤其在 serverless 部署环境下,连接池行为和本地完全不同,用错了会出现连接耗尽。更换 Session 密钥的成本比想象中高,它会强制所有在线用户重新登录,所以密钥应该在上线前就确定好,后续尽量少动。
6. 如果第一次重新来,第一天我就会确定的五个习惯
6.1 命名和目录约定越早统一越好
t3code 早期我同时在 Post 模型里用了 authorId,在 Favorite 模型里用了 userId,字段风格不完全统一,导致写查询时经常会想这个字段到底是 userId 还是 creatorId。假如第一天就把命名规则定下来:外键统一叫 xxxId,每张表主键统一叫 id,时间字段统一 createdAt 和 updatedAt,后续可以省掉大量无谓的 rename 迁移。
6.2 打开 noUncheckedIndexedAccess
TypeScript 配置里 noUncheckedIndexedAccess 是我推荐从项目第一天就打开的选项之一。它会让数组下标访问的结果带上 undefined,补代码时你就必须处理“可能越界”的情况。页面数据只要有一次因为list[0]为 undefined 白屏,你就会立刻理解这个配置的价值。
6.3 API 入参一律走 zod
不要因为前端是自己写的,就跳过入参校验。t3code 的接口今后可能会被脚本、自动化任务或者未来的小程序调用。入口统一用 zod 校验,保证的是任何来源的数据都必须满足同一套契约。zod 的 schema 同时还能被 tRPC 用于运行时校验和类型推导,维护成本已经被框架降得很低了。
6.4 路由划分按业务域,不按方法
接口文件不要按增删改查来组织,比如 createPost.ts、getPost.ts、deletePost.ts。这样写会让人觉得像一锅粥。我按业务域划分:postRouter 只管文章,tagRouter 只管标签,favoriteRouter 只管收藏。我接手项目时,根据诉求直接找到对应 router 文件,比在一堆方法文件里翻来翻去省事得多。
6.5 尽早建立最小可用的日志与备份
不要等项目上线了、出现线上事故的时候才想起来日志和备份。t3code 在开发的最后阶段就接入了简单的结构化日志,并且把备份脚本写进了部署文档。这种前期投入的成本很小,但能避免绝大多数“线上出问题却无从下手”的焦虑。
t3code 做完以后,我最大的感受是:T3 Stack 并不会替你解决所有业务问题,它只是把原本散落在前端类型、后端类型、接口文档和数据模型里的成本,统一收进了类型系统这一层。所有复杂度和权衡并不会消失,而是被转移到了更可控的地方。现在再让我回去维护一个没有端到端类型安全的老项目,我第一反应一定是先把类型链路补起来,再谈后续功能迭代。