一个应用、一次部署:把 CMS 放进 Next.js 里
这个站把 Payload 3 放进了 Next.js 应用里,一份代码,一次部署。这样做的好处,以及它坑过我的四次。
这个网站是一个 Next.js 应用。你看到的页面、我写文章用的后台,还有两者背后的接口,都在同一份代码里,部署到 Vercel 也只有一次。CMS 用的是 Payload 3,它不是跑在网站旁边,而是跑在网站里面,挂在同一个 App Router 的 /admin 下。数据放在 Supabase 的 Postgres 里,Supabase 在这里只当 Postgres 用,别的功能一概不开。
这篇写这种做法带来的方便,和它坑过我的四次。每一次都对应公开仓库里的一个 PR,不是凭记忆复述的。
合在一起的好处
查数据就是调函数。页面要文章,直接在同一个进程里调 Payload 的 Local API,不走 HTTP,也没有一个需要同步维护的 API 客户端:
const { docs } = await payload.find({
collection: 'blogs',
where: { slug: { equals: slug }, status: { equals: 'published' } },
depth: 1,
limit: 1,
locale: asLocale(locale),
})类型直接从 schema 里来。每个集合都用 TypeScript 定义,Payload 据此生成 payload-types.ts。我改了一个字段名,读这个字段的页面当场编译不过。前后端分开的话,同样的错误要到线上才露出来,表现是某个字段突然空了。
部署也只有一次。不用给第二个服务管版本,没有跨域,也不用操心哪边先发布。表结构的改动和用到它的代码一起上线,CI 还会核对迁移建出来的表结构和代码里定义的是否完全一致。
好处就这些。下面是代价。
坑一:Local API 不做访问控制
通过 HTTP 访问时,Payload 会执行每个集合的访问规则,匿名访客读不到草稿。Local API 默认跳过这些规则,因为它假定调用方是可信的服务端代码。所以公开页面发出的每一个查询,都得自己负责过滤。
这一点我是知道的,还是错了一次。文章页的查询过滤了 status: 'published',生成页面 metadata 的那个函数却没有。草稿的地址照常返回 404,但 Next 仍然把 metadata 放进了 404 响应的数据里,于是只要猜中草稿的 slug,就能拿到它的标题、摘要和封面图地址。我是在一篇新文章还处于草稿状态时,在生产环境上确认的。
修复只有一行。后来一直留着的是一个回归检查:CI 的 seed 会建一篇草稿,冒烟测试断言它在两种语言下都返回 404,响应里也找不到它的标题。把这个检查对准修复前的版本跑,它会报错。
坑二:查询不带 locale,会悄悄回落
Payload 按语言分别存储多语言字段,这个站的默认语言是中文。payload.find() 不传 locale 并不会报错,而是直接返回默认语言的内容。英文页面少传了这个参数,就会显示中文,构建和类型检查都不会拦。
这个错我在三个不同的地方犯了三次。第三次之后,我不再指望自己能记住,项目的工作笔记里把它列为已知的头号陷阱。不过它到现在还只是一条约定,编译器管不到。如果从头再来,我会把它做成编译期就能发现的错误。
坑三:开发模式从不做静态渲染
首批四篇文章上线后,所有文章页都返回 500。原因出在语言那一层的 layout:它向 next-intl 要文案时没说是哪种语言,next-intl 只好去读请求头来判断。读请求头属于动态 API,在静态渲染时调用会直接抛错。
这个问题被两件事藏住了。一是文章页是那个 layout 下唯一静态渲染的路由,而之前一篇文章都没有,所以它在生产上从来没被渲染过。二是 next dev 根本不做静态渲染:几分钟前,同样这几篇文章在本地还是 200。
修法是在 layout 里加一行 setRequestLocale(locale)。必须加在 layout 里,因为 layout 先渲染。我一开始加在 page 里,没有用。从那以后,凡是改了渲染路径,我都用 next build && next start 验证,不再相信开发服务器。
还有一个同类的问题:首页写了 revalidate = 3600,却每次请求都重新渲染。原因是 layout 没有用 generateStaticParams 声明 [locale] 有哪些取值,Next 只好把它下面的页面全当成动态页面。在 layout 里补上这一个函数,首页、项目列表和工具页就从每次现渲染变成了直接命中缓存。
坑四:骨架屏把 404 变成了 200
我给三个列表页加了 loading.tsx 骨架屏。过了一阵才发现,随便编一个地址,比如 /blog/no-such-post,返回的是 HTTP 200,正文却是 404 页面。
loading.tsx 会给它所在的整个子树套上一层 Suspense,而详情页恰好都在这棵子树里。响应一旦开始流式输出,状态码就随第一块数据发出去了,远早于页面执行到 notFound()。排查花了不少工夫,一项项排除,最后我把文章页一字不改地复制到另一个目录,复制出来的那份返回 404。两者唯一的区别,就是原页面旁边的那个 loading.tsx。
最后把列表页连同骨架屏一起移进了路由组 (list)。路由组不影响 URL,Suspense 的范围也就不再罩住详情页了。
缓存什么时候失效
所有公开页面都是静态生成的,最多一小时刷新一次。这样又便宜又快,代价是每次改完内容,页面最多要错一个小时。所以在集合的 hook 里,只要保存的改动访客看得见,就立刻让缓存失效:
// src/lib/revalidate.ts,有删节
export function revalidateSite(reason: string): boolean {
try {
revalidatePath('/', 'layout') // 所有页面
revalidatePath('/sitemap.xml') // 要单独点名,见下文
revalidateTag(CACHE_TAGS.sidebar, { expire: 0 })
revalidateTag(CACHE_TAGS.search, { expire: 0 })
return true
} catch (err) {
console.warn(`[revalidate] skipped after ${reason}`)
return false
}
}它一次让整个站点失效,不去推算一次改动会影响哪些页面。全站也就几十个页面,失效后各自在下一次访问时重建;要是去列举受影响的页面,首页、每篇文章旁边的侧栏、sitemap、搜索,总会漏掉一个。
有两个细节是到了生产环境才发现的。在本地,按 layout 失效也会顺带刷新 sitemap,到了 Vercel 上却不会,所以 sitemap 要单独点名。另外,写数据库的脚本运行在 Next 之外,碰不到缓存,所以脚本写完以后,会调用一个带鉴权的小接口,由它来执行同一个函数。
还会这样做吗?
像这样的站点,会。写内容的人和发布代码的人是同一个人,主要成本只是几条要守的约定:每个查询都要过滤,每次都传 locale,渲染相关的改动要在生产构建上验证。
如果编辑和开发是两个团队,我会重新考虑。后台和网站共用一次部署,一个出问题的 CMS 改动可能连带公开页面一起挂掉,编辑那边的发布节奏也只能跟着网站走。到了那一步,拆成两个服务、中间隔一层 API,多出来的开销就值了。
暂无评论
成为第一个发表评论的人吧。
发表评论
评论经审核后显示,不会出售你的邮箱。
