Next.js 结合 Agility CMS 构建 SSG 博客:Page Management 模型与 Preview Mode 实战指南
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
本文基于 Next.js 官方仓库中的examples/cms-agilitycms示例,完整讲解如何用 Agility CMS 的 Page Management 能力驱动一个静态生成(Static Generation)博客:从 Content Definition、Module、Page Template 的 CMS 侧建模,到 Next.js 侧基于 sitemap 的 catch-all 路由渲染,再到 Preview Mode(草稿模式)的密钥校验实现,最终给出可复制的 18 步落地流程和源码级原理剖析。
一、这个示例与常规 CMS 博客的本质区别
绝大多数 CMS 博客示例(如 WordPress、Ghost、Contentful 等)只解决"内容从哪来"的问题:CMS 提供文章数据,前端页面结构写死在代码里。而 cms-agilitycms 示例 采用 Agility CMS 的Page Management特性,让 CMS 最终决定网站有哪些页面以及每个页面上放什么内容。
其核心设计原则是:Editors(编辑)对页面拥有完全控制权,无需接触代码,而开发者只负责构建供编辑组合页面的 UI 组件。对应到技术模型上,就是示例文档中强调的一句关键映射:
Agility CMS 中的
Modules和Page Templates,分别对应你网站中的React Components。
整个网站由**一个 Page Template(One Column Template)+ 一组 Modules(Intro、Hero Post、More Stories、Post Details)**构成。编辑在 CMS 里增删页面、往页面的内容区里拖模块,Next.js 端在构建时从 CMS 拉取 sitemap 和页面数据,把"模块名"动态解析成对应的 React 组件来渲染——页面结构完全由 CMS 侧数据驱动。
这种模式下,新增一个落地页、调整某页模块顺序,都不需要开发者改代码重新发布。
二、快速开始:使用 create-next-app 脚手架
通过 npm、Yarn 或 pnpm 中的任意一种执行create-next-app并指定cms-agilitycms示例,即可初始化项目:
npx create-next-app --example cms-agilitycms cms-agilitycms-appyarn create next-app --example cms-agilitycms cms-agilitycms-apppnpm create next-app --example cms-agilitycms cms-agilitycms-app生成的项目中与本文原理剖析强相关的文件布局如下(以仓库内 examples/cms-agilitycms 目录为准):
- pages/[...slug].tsx:唯一的 catch-all 页面路由,承载所有 CMS 页面
- pages/api/preview.ts 与 pages/api/exit-preview.ts:Preview Mode 的进入/退出 API 路由
- lib/api.ts:CMS 客户端封装、sitemap 拉取、页面 props 组装
- lib/preview.ts:预览密钥生成与校验
- lib/components/:Page Template 与 Content Zone 的渲染骨架
- components/:各 Module 对应的 React 组件(
hero-post.tsx、intro.tsx、more-stories.tsx、post-details.tsx等) - lib/constants.ts:CMS 语言与频道常量(
en-us/website)
依赖方面,package.json 中数据抓取依赖@agility/content-fetch(Agility 官方 Node SDK),UI 基于 React 18 + Tailwind CSS,next使用latest版本,脚本为标准三件套:dev/build/start。
三、CMS 侧建模(Step 1 ~ Step 6):内容定义与 Shared Content
以下全部在 Agility CMS 的 Content Manager 界面中完成。
Step 1:创建账号与项目
在 Agility CMS 注册账号后创建新Project,模板选择Blank (advanced users)得到空白实例。
Step 2:创建 Author 内容定义
- 进入Settings>Content Definitions。
- 点击New创建新的 Content Definition。
- Title填
Author(Reference Name 会自动填充)。 - 在Form Builder页签添加两个字段:
Name:Field Label 为 "Name",Field Type 为TextPicture:Field Label 为 "Picture",Field Type 为Image
- 点击Save & Close保存。
Step 3:基于 Author 创建List
进入Shared Content,点击+ (New),填写:
- Type:
Content List - Content Definition:
Author - Display Name:
Authors(Reference Name 会随之自动填充)
Step 4:创建 Post 内容定义
进入Settings>Content Definitions,点击New,Title填Post,然后在Form Builder中按下表添加字段(其余设置无需改动):
| 字段 | Field Type | 附加配置 |
|---|---|---|
Title | Text | 无 |
Slug | Text | 无 |
Date | Date/Time | 无 |
AuthorID | Number | 勾选Hide field from input form |
Author | Linked Content | Content Definition选Author;Content View选Shared Content;Shared Content选Authors;Render As选Dropdown List;Save Value To Field设为AuthorID |
Excerpt | Text | 无 |
Content | HTML | 无 |
Cover Image | Image | 无 |
完成后点击Save & Close。注意Author字段是Linked Content类型:编辑在表单里从Authors列表下拉选择作者,而实际保存的值写入隐藏的AuthorID数字字段——这是 Agility CMS 关联内容的数据存储方式。
Step 5:创建Dynamic Page List
进入Shared Content,点击+ (New),填写:
- Type:
Dynamic Page List - Content Definition:
Post - Display Name:
Posts(Reference Name 自动填充)
Dynamic Page List 与普通 List 的区别在于:它后续可以驱动 Dynamic Page,即"每个 Post 内容项自动变成一个 URL 页面"。
Step 6:填充内容
- Authors列表:点击+ New创建 1 个作者内容项,文本可填虚拟数据,头像图片可自备,完成后点击Save和Publish。
- Posts列表:建议至少创建 2 个文章内容项;
Content字段支持写 markdown;Pick 之前创建好的 Author;每项保存后必须点击Publish,否则文章停留在Staging(暂存)状态,Live API 抓取不到。
这一点与后文 Preview Mode 直接相关:Staging 内容只有预览密钥才能读到,普通访问读不到。
四、UI 组件层建模(Step 7 ~ Step 11):Modules 与 Page Template
Module 定义对应"页面里可被编辑拖拽的 React 组件"。以下四个 Module 在 CMS 中只需要定义"名字"和少量字段,真正的渲染逻辑在仓库的 components/ 目录中实现。
Step 7:定义IntroModule
Settings>Module Definitions>New:
- Title:
Intro - Description:
Displays an intro message.
不添加任何字段——因为该模块展示的内容直接硬编码在前端模板里。
Step 8:定义Hero PostModule
- Title:
Hero Post - Description:
Displays the latest Post.
同样不加字段:默认取最新一篇文章,数据关联在 Post 自身。
Step 9:定义More StoriesModule
- Title:
More Stories - Description:
Displays a listing of Posts. - 添加一个字段:
Title,Field Type 为Text(用于自定义列表标题)。
Step 10:定义Post DetailsModule
- Title:
Post Details - Description:
Displays the details of a Post. - 不添加字段,数据关联在 Post 自身。
Step 11:定义One ColumnPage Template
Settings>Page Templates>New:
- Name:
One Column Template - Digital Channel Type:
Website - 在Module Zones中点击+ (New):
- Display Name设为
Main Content Zone(Reference Name 自动填充为MainContentZone) - 点击Save应用该内容区
- Display Name设为
最后Save & Close。Content Zone 是模板内的"插槽",编辑把 Module 放进这个插槽,前端按区名渲染。
五、页面结构建模(Step 12 ~ Step 14):静态页与动态页
Step 12:创建home页面
Pages中点击页面树的+ (New)新建Page:
- Type:
Page - Page Template:
One Column Template - Menu Text:
Home(Page Title 与 Page Name 自动填充)
点击Save创建/home页面。然后向Main Content Zone依次添加三个模块:
- + (New)选
Intro,模块上点击Save & Close回到页面; - + (New)选
Hero Post,Save & Close; - + (New)选
More Stories,Title设为More Stories,Save & Close。
最后点击页面上的Publish,发布页面及其全部模块。
Step 13:创建posts文件夹
Pages中选中Website频道,在站点根部的页面树点击+ (New)新建Folder:
- Type:
Folder - Menu Text:
Posts(Folder Name 自动填充为posts)
Save后务必对文件夹点击Publish——文件夹不发版,其下的动态页路径会不出现在 Live sitemap 中。
Step 14:创建 Dynamic Pageposts-dynamic
选中/posts文件夹,在其下新建Dynamic Page:
- Type:
Dynamic Page - Page Template:
One Column Template - Build Pages From:
Posts(即 Step 5 的 Dynamic Page List) - Sitemap Label:
posts-dynamic - Page Path Formula:
##Slug## - Page Title Formula与Menu Text Formula:
##Title##
公式中的##Slug##、##Title##是 Agility 的字段插值语法:每新增一个 Post 内容项,CMS 就按该 Post 的Slug字段值自动生成/posts/<slug>页面。随后在Main Content Zone中添加Post Details与More Stories(Title 填More Stories)两个模块,Save & Close后点击Publish。
至此 CMS 侧共三类 URL 来源:/home静态页、/posts文件夹、/posts/<slug>动态页——这三者都会进入 sitemap,也正是 Next.js 端getStaticPaths的路径来源(见下节)。
六、环境变量配置(Step 15):四个密钥的完整说明
把示例目录中的.env.local.example复制为.env.local(已被 Git 忽略):
cp .env.local.example .env.local.env.local.example(见 .env.local.example)只声明了四个空变量,实际值来自 CMS 后台两处:
- 菜单Getting Started页点击API Keys,弹出
Content API Details窗口,点击Show API Key(s); - Settings>Global Security中获取 Security Key。
填入.env.local:
AGILITY_CMS_GUID=... AGILITY_CMS_API_FETCH_KEY=... AGILITY_CMS_API_PREVIEW_KEY=... AGILITY_CMS_SECURITY_KEY=...结合 lib/api.ts 与 lib/preview.ts 的源码,四个变量的实际用途如下:
| 变量 | 来源 | 源码中的用途 |
|---|---|---|
AGILITY_CMS_GUID | Content API Details 的Instance GUID | agility.getApi()创建客户端时的实例标识,live 与 preview 两个客户端共用 |
AGILITY_CMS_API_FETCH_KEY | Live API Key | 生产态liveClient的 API Key,只能读已发布内容 |
AGILITY_CMS_API_PREVIEW_KEY | Preview API Key | previewClient的 Key,客户端初始化时带isPreview: true,可读取未发布的 Staging 内容 |
AGILITY_CMS_SECURITY_KEY | Settings>Global Security | 服务端用于生成/校验agilitypreviewkey,防止任何人伪造预览请求 |
从源码结构看,lib/api.ts 中同时构建了两个@agility/content-fetch客户端并通过getClient(preview)按 Draft Mode 状态切换:
const liveClient = agility.getApi({ guid: process.env.AGILITY_CMS_GUID, apiKey: process.env.AGILITY_CMS_API_FETCH_KEY, }); const previewClient = agility.getApi({ guid: process.env.AGILITY_CMS_GUID, apiKey: process.env.AGILITY_CMS_API_PREVIEW_KEY, isPreview: true, });这意味着 SSG 构建时用 live 客户端,getStaticProps里preview === true(即 Preview Mode 请求)时切换为 preview 客户端,从而拉取草稿数据。
七、本地运行(Step 16)与页面路由原理
npm install npm run dev # or yarn install yarn dev博客运行在http://localhost:3000。
为什么一个 catch-all 路由能渲染整个网站
从源码结构看,整个前端只有一个页面文件 pages/[...slug].tsx。它的 SSG 三要素:
getStaticPaths——路径完全来自 CMS sitemap:调用 lib/api.ts 中的getAgilityPaths(),该方法请求getSitemapFlat(频道website、语言en-us),过滤掉isFolder === true的节点,返回形如['/home', '/posts/posts-dynamic/xxx', ...]的路径数组,并返回fallback: true:
export async function getStaticPaths() { const paths = await getAgilityPaths(); return { paths: paths, fallback: true, }; }这正是 Page Management 模型的落点:Next.js 不维护任何硬编码路由列表,URL 空间由 CMS 的页面树决定。fallback: true保证编辑在 CMS 里新发布的页面,即使尚未重新构建,也能触发按需生成。
getStaticProps——按路径取页面并解析模块:核心是getAgilityPageProps,其流程为:
- 由
params.slug拼出请求路径(空参数时为/,此时取 sitemap 第一个节点); - 拉取
sitemapFlat,用路径查到pageID,再调client.getPage取整页数据(含模板名与各 Content Zone 内的模块); - 遍历
page.zones,对每个模块调用requireComponentDependencyByName(moduleItem.module)把模块名字符串解析为 React 组件; - 若组件挂载了
getCustomInitialProps静态方法,则在 SSG 阶段就调用它拉取该模块所需的附加数据,保证数据在构建时固化而非客户端请求。
模块名到组件文件的解析规则在 lib/dependencies.ts 中实现:先尝试 kebab-case(如HeroPost→hero-post),再尝试 PascalCase;查找顺序为先 components/ 目录(业务组件),找不到再回退 lib/components/。也就是说,CMS 里 Module 的 Reference Name 直接决定 require 哪个.tsx文件——编辑在 CMS 改名模块,前端就必须有同名文件,否则构建时报Could not find a component with the name ...。
模板的二次解析:页面拿到pageTemplateName(去掉非字母数字字符后的模板名,如OneColumnTemplate)后,交由 lib/components/page-template.tsx 用同样的requireComponentDependencyByName解析出模板组件;lib/components/one-column-template.tsx 只渲染一个MainContentZone:
export default function OneColumnTemplate(props) { return ( <> <ContentZone name="MainContentZone" {...props} /> </> ); }而 lib/components/content-zone.tsx 读取page.zones[zoneName]中的模块数组,逐个解析组件并把 SSG 阶段准备好的数据作为 props 展开传入:
return modules.map((m, i) => { const AgilityModule = requireComponentDependencyByName(m.moduleName); return <AgilityModule key={i} {...m.item} />; });带自有数据的模块:以 components/hero-post.tsx 为例,Hero Post模块不接收 CMS 字段,而是挂载静态方法getCustomInitialProps,在构建时调用client.getLatestPost()取最新文章。APIClient(lib/api.ts)封装了三个常用查询:getAllPosts(Content Listposts,contentLinkDepth: 1以便联表拉出作者信息)、getLatestPost(取 1 篇后归一化)、getPostsForMoreStories(取 5 篇并排除当前文章)。lib/normalize.ts 把 Agility 的fields结构统一成{ title, slug, excerpt, date, content, author{name, picture}, coverImage{responsiveImage} }的前端友好形状,并为图片 URL 追加?w=2000&h=1000&q=70这类 CDN 尺寸参数。
八、Preview Mode 实战(Step 18):从编辑点击"Preview"到看到草稿
这是文档最核心也最容易配置出错的部分,下面结合仓库源码完整拆解。
8.1 在 CMS 中配置域名
部署后记录你的站点 URL(形如https://<your-vercel-domain>.vercel.app),然后:
- Settings>Domain Configuration;
- 点击列表中名为
Website的频道; - 点击+ (New)添加域名:
- Name:
Production - Domain URL:你的生产部署 URL
- 勾选Preview Domain
- 点击Save
- Name:
8.2 制造一条 Staging 内容并进入预览
打开任一 Post,修改标题(例如在标题前加[Staging]),点击Save但不要点Publish——此时文章处于 Staging 状态。点击该 Post 详情页的Preview按钮,CMS 会带着agilitypreviewkey(以及动态页的contentid)重定向到你的站点;进入Preview Mode后即可导航到目标 Post,看到未发布的标题。页面顶部会出现Click here to exit preview mode退出入口。
若要预览具体的某条 Post(而不是落在
/页),需进入Shared Content中Posts列表的Settings页签,将Item Preview Page设为~/posts/posts-dynamic,Item Preview Query String Parameter设为contentid。
8.3 源码级校验链路
整条 Preview Mode 链路横跨"浏览器端 + 两个 API 路由 + SSG 数据源",全部可在仓库中验证:
第一环:查询参数捕获。lib/use-preview-redirect.ts 是一个客户端 hook,被[...slug].tsx页面调用:一旦 URL 上出现agilitypreviewkey(可能还带contentid),立即跳转/api/preview?slug=...&agilitypreviewkey=...。这解释了为什么 CMS 的 Preview 重定向可以落在任意页面:预览入口被统一收敛到 API 路由。
第二环:密钥生成与比对。lib/preview.ts 的generatePreviewKey用AGILITY_CMS_SECURITY_KEY派生预期密钥——取字符串-1_${SECURITY_KEY}_Preview,逐字符转 UTF-16 字节,做SHA-512哈希后 Base64 编码。Agility CMS 后台用同一算法生成agilitypreviewkey并放进 Preview 链接,因此校验本质是:
const correctPreviewKey = generatePreviewKey(); if (agilityPreviewKey !== correctPreviewKey) { return { error: true, message: `Invalid agilitypreviewkey.` }; }没有共享密钥就无法伪造合法预览链接。校验还包含validateSlugForPreview:对普通页直接查 sitemap 是否含该 slug;对动态页则用contentID在 sitemap 里反查对应节点并解析出真实路径,查不到即返回 401(错误提示会明确指向"请检查 List Preview Page / Item Preview Page / Item Preview Query String Parameter (contentid) 配置")。
第三环:开启 Draft Mode。pages/api/preview.ts 校验通过后执行 Next.js 的res.setDraftMode({ enable: true })写入草稿 cookie,并 307 重定向到校验解析出的 slug;pages/api/exit-preview.ts 则setDraftMode({ enable: false })删除 cookie 并跳回/。Draft Mode 开启后,Next.js 对该路径跳过静态缓存、以preview: true执行getStaticProps,getClient(true)切换到 preview 客户端,于是读到 Staging 内容——这就是"编辑改标题不发布、前端就能预览"的完整闭环。
九、部署(Step 17)
- 部署本地项目:把项目推送到 GitHub/GitLab/Bitbucket 后导入 Vercel。务必在导入时打开Environment Variables,把上面四个变量配置成与
.env.local一致,否则 Live/Preview 客户端初始化会因缺失 Guid 或 Key 而失败。 - 从模板一键部署:也可使用示例 README 提供的 Vercel 模板按钮,部署时按提示填写
AGILITY_CMS_GUID、AGILITY_CMS_API_FETCH_KEY、AGILITY_CMS_API_PREVIEW_KEY、AGILITY_CMS_SECURITY_KEY四个环境变量。
十、小结:编辑与开发者的职责边界
| 角色 | 在 CMS 中的操作 | 对应前端产物 |
|---|---|---|
| 编辑 | 新建/发布 Page、Dynamic Page Item、Post、调整 Zone 内模块顺序 | sitemap 变化 → 新 URL 与页面 HTML |
| 开发 | 新增 Module Definition 之外的 React 组件、Page Template 组件 | components/ 与 lib/components/ 下的.tsx文件 |
| 双方 | 环境变量、Domain Configuration | Preview Mode 可用 |
这套示例的完整价值在于演示了一个可落地的分工范式:URL 空间、页面结构、模块编排全部上移到 CMS 的 Page Management 层;Next.js 端用"sitemap 驱动getStaticPaths+ 模块名动态解析组件 +getCustomInitialProps构建期取数"三件套承接渲染;Preview Mode 则用共享密钥哈希 + Draft Mode 把 Staging 内容安全地暴露给编辑。相关仓库中同系列的 CMS 示例可参考 cms-wordpress、cms-ghost、cms-contentful、cms-sanity、blog-starter 等,结构上均为"Content Definition/List → SSG 取数 → 页面组件"的模式,区别在于是否引入了本文这种页面级编排能力。
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考