Next.js 静态博客实战:用 Contentful 构建支持 Draft Mode 与 On-Demand Revalidation 的博客
本文以 Next.js 仓库中的 examples/cms-contentful 示例为主体,完整讲解如何用 Contentful 作为数据源搭建一个静态生成的博客:从内容模型(Content Model)定义、批量初始化脚本、环境变量配置,到 Draft Mode 草稿预览与基于 Webhook 的按需重新验证(On-Demand Revalidation)全链路。读完后你将掌握该示例的完整配置流程,并理解其背后 revalidateTag、draftMode()、Contentful GraphQL API 等关键实现。
示例项目总览
该示例展示 Next.js 的静态生成(Static Generation)能力,以 Contentful 作为博客数据源,支持多作者、多文章,并内置草稿预览与按需重新验证两套工作流。项目基于 App Router(app/ 目录)编写,使用 TypeScript 与 Tailwind CSS,核心结构如下:
- app/page.tsx:首页,渲染 Hero 文章 + 文章列表;
- app/posts/[slug]/page.tsx:文章详情页,通过
generateStaticParams预渲染所有文章; - app/api/draft/route.ts、app/api/revalidate/route.ts、app/api/disable-draft/route.ts:Draft Mode 启用/关闭与按需重新验证三个 API 路由;
- lib/api.ts:封装对 Contentful GraphQL API 的查询逻辑;
- lib/setup.js:配合
contentful-import批量导入内容模型的初始化脚本; - lib/export.json:由
setup脚本导入的内容模型定义文件; - next.config.js:仅配置
images.loader: "custom",图片由 Contentful 的 CDN 直接提供; - package.json:关键依赖包括
@contentful/rich-text-react-renderer(富文本渲染)、@contentful/vercel-nextjs-toolkit(Draft Mode 处理器)、contentful-import(初始化脚本)与 Tailwind CSS。
快速开始:用 create-next-app 初始化项目
按照 README,可以通过 create-next-app 以 npm、Yarn 或 pnpm 引导该示例:
npx create-next-app --example cms-contentful cms-contentful-app
yarn create next-app --example cms-contentful cms-contentful-app
pnpm create next-app --example cms-contentful cms-contentful-app
Contentful 侧配置
第 1 步:创建账号与 Space
在 Contentful 创建账号后,从 Dashboard 新建一个空的 Space,可任意命名。后续所有内容模型与内容条目都归属于这个 Space。
第 2 步:创建内容模型
内容模型定义应用的数据结构。本示例需要两个内容类型:Author(作者) 和 Post(文章)。既可以运行脚本自动创建,也可以手动创建以熟悉 Contentful 界面。
方式一:运行 setup 脚本自动创建
先在 Contentful Dashboard 的 Settings > General Settings 中复制 Space ID;再进入 Settings > CMA tokens,点击 Create personal access token 创建一个个人访问令牌(它与登录用户拥有相同权限,切勿公开分享,仅用于初始化 Space,用后可删除)。
然后执行:
npx cross-env CONTENTFUL_SPACE_ID=YOUR_SPACE_ID CONTENTFUL_MANAGEMENT_TOKEN=XXX npm run setup
对应 package.json 中的脚本定义:
"setup": "node ./lib/setup.js"
从 lib/setup.js 的实现看,脚本会先校验两个环境变量是否齐全(缺失时直接抛出错误并提示正确用法),然后调用 contentful-import 的 spaceImport,把 lib/export.json 中定义的内容结构推送到目标 Space。执行成功后控制台会打印导入摘要:
┌──────────────────────────────────────────────────┐
│ The following entities are going to be imported: │
├─────────────────────────────────┬────────────────┤
│ Content Types │ 2 │
├─────────────────────────────────┼────────────────┤
│ Editor Interfaces │ 2 │
├─────────────────────────────────┼────────────────┤
│ Locales │ 1 │
├─────────────────────────────────┼────────────────┤
│ Webhooks │ 0 │
├─────────────────────────────────┼────────────────┤
│ Entries │ 0 │
├─────────────────────────────────┼────────────────┤
│ Assets │ 0 │
└─────────────────────────────────┴────────────────┘
✔ Validating content-file
✔ Initialize client (1s)
✔ Checking if destination space already has any content and retrieving it (2s)
✔ Apply transformations to source data (1s)
✔ Push content to destination space
方式二:手动创建内容模型
创建 Author 内容类型:进入 Space 的 Content model,新建内容类型,Name 填 Author,Api Identifier 填 author,然后添加字段:
name:Text 字段(类型 short text),Field ID 设为namepicture:Media 字段(类型 one file),Field ID 设为picture
创建 Post 内容类型:Name 填 Post,Api Identifier 填 post,添加以下字段:
title:Text 字段(类型 short text)content:Rich text 字段excerpt:Text 字段(类型 Long text, full-text search)coverImage:Media 字段(类型 one file)date:Date and time 字段slug:Text 字段。可选:在字段设置的 Appearance 中选择 Slug,将其显示为title字段的 slugauthor:Reference 字段(类型 one reference),引用 Author 类型
这些字段名与 lib/api.ts 中 POST_GRAPHQL_FIELDS 查询的字段一一对应(slug、title、coverImage、date、author.name/picture、excerpt、content)。
第 3 步:验证内容模型
无论通过脚本还是手动完成,最终的模型应包含 Author 与 Post 两个内容类型,字段与上文定义一致。
第 4 步:填充内容
进入 Space 的 Content 区域,点击 Add entry:
- 先创建 1 个 Author 条目,文本可用占位数据,图片可下载任意图片素材;
- 再创建至少 2 个 Post 条目,填写标题、富文本正文、摘要、封面图、日期与 slug,并关联前面创建的 Author。
重要:每个条目和素材都必须点击 Publish。否则条目停留在草稿状态,不会出现在博客中(但可以被 Draft Mode 预览,见下文)。
第 5 步:配置环境变量
进入 Settings > API keys,页面会提供一个默认的 Delivery / Preview token,可直接使用(也可新建)。然后把项目根目录的 .env.local.example(对应 examples/cms-contentful/.env.local.example)复制为 .env.local(该文件会被 Git 忽略):
cp .env.local.example .env.local
各变量含义:
CONTENTFUL_SPACE_ID:API Key 中的 Space ID;CONTENTFUL_ACCESS_TOKEN:Content Delivery API 的 access token,用于获取已发布内容;CONTENTFUL_PREVIEW_ACCESS_TOKEN:Content Preview API 的 access token,用于 Draft Mode 下获取草稿内容;CONTENTFUL_PREVIEW_SECRET:任意 URL 友好的值,Contentful Dashboard 会将其作为查询参数传给站点以启用 Next.js Draft Mode;CONTENTFUL_REVALIDATE_SECRET:任意值,作为 Webhook 请求的 secret 头,用于 On-Demand Revalidation。
最终 .env.local 形如:
CONTENTFUL_SPACE_ID=...
CONTENTFUL_ACCESS_TOKEN=...
CONTENTFUL_PREVIEW_ACCESS_TOKEN=...
CONTENTFUL_PREVIEW_SECRET=...
CONTENTFUL_REVALIDATE_SECRET=...
第 6 步:启动开发模式
npm install
npm run dev
# or
yarn install
yarn dev
博客随后运行在 http://localhost:3000。
数据获取层:lib/api.ts 的实现
从 lib/api.ts 源码看,所有数据访问统一走 Contentful GraphQL API:
async function fetchGraphQL(query: string, preview = false): Promise<any> {
return fetch(
`https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${
preview
? process.env.CONTENTFUL_PREVIEW_ACCESS_TOKEN
: process.env.CONTENTFUL_ACCESS_TOKEN
}`,
},
body: JSON.stringify({ query }),
next: { tags: ["posts"] },
},
).then((response) => response.json());
}
两个值得注意的设计:
- 双令牌切换:
preview参数决定使用CONTENTFUL_PREVIEW_ACCESS_TOKEN还是CONTENTFUL_ACCESS_TOKEN,并在 GraphQL 查询中通过preview: true/false请求草稿数据,这正是 Draft Mode 能展示未发布内容的基础; next: { tags: ["posts"] }:给 fetch 请求打上posts缓存标签,使该请求的缓存结果可以被revalidateTag("posts")精确失效——这是按需重新验证能生效的关键。
对外导出三个函数:getAllPosts(首页,按日期降序取全部有 slug 的文章)、getPostAndMorePosts(详情页,取指定文章 + 另外 2 篇推荐)、getPreviewPostBySlug(Draft Mode 下按 slug 取草稿版本)。
页面侧的消费方式:
- app/posts/[slug]/page.tsx 中通过
generateStaticParams在构建期为每篇文章生成静态路由:
export async function generateStaticParams() {
const allPosts = await getAllPosts(false);
return allPosts.map((post) => ({
slug: post.slug,
}));
}
- 页面组件通过
draftMode()(来自next/headers)读取当前是否处于草稿模式,再决定拉取已发布内容还是预览内容,见 app/page.tsx 中的const { isEnabled } = await draftMode()。
Draft Mode:预览未发布的草稿内容
示例内置了三个 API 路由:
- app/api/draft/route.ts 仅一行代码,复用 Contentful 官方工具包的处理器:
//@ts-ignore
export { enableDraftHandler as GET } from "@contentful/vercel-nextjs-toolkit/app-router";
enableDraftHandler 会校验请求中的 secret 参数与 CONTENTFUL_PREVIEW_SECRET 是否一致,并携带 slug 参数重定向到对应文章页,同时开启草稿模式 Cookie;
- app/api/disable-draft/route.ts 调用
draftMode().disable()手动退出草稿模式; - app/api/revalidate/route.ts 用于按需重新验证(见下一节)。
在 Contentful 中接入 Content Preview:进入 Settings > Content preview,为开发环境新建一个内容预览(例如命名 Development),在 Content preview URLs 中勾选 Post 并填写:
http://localhost:3000/api/draft?secret=<CONTENTFUL_PREVIEW_SECRET>&slug={entry.fields.slug}
其中 <CONTENTFUL_PREVIEW_SECRET> 替换为 .env.local 中的对应值。保存后:
- 打开某篇文章条目,更新标题(例如在标题前加
[Draft]); - 条目状态会自动变为 CHANGED,此时不要发布,保持草稿状态;
- 点击侧边栏的 Open preview 按钮,即可在站点上看到更新后的草稿标题;
- 想退出 Draft Mode 时,在浏览器访问
/api/disable-draft即可。
On-Demand Revalidation:Webhook 触发的按需重新验证
发布内容后无需重新构建,即可让站点反映最新内容。在 Contentful Space 的 Settings > Webhooks 中新建 Webhook:
- 命名:任意;
- Activate:勾选激活;
- POST URL:使用上一步部署(或本地)的站点地址加
/api/revalidate路径,例如https://<YOUR_VERCEL_DEPLOYMENT_URL>/api/revalidate; - Triggers:可对所有事件触发,也可仅选择 Entries / Assets 的 Publishing 与 Unpublishing 事件;
- Secret Header:添加名为
x-vercel-reval-key的请求头,值填CONTENTFUL_REVALIDATE_SECRET; - Content type:选择
application/json。
服务端校验逻辑在 app/api/revalidate/route.ts 中一目了然:
import { NextRequest, NextResponse } from "next/server";
import { revalidateTag } from "next/cache";
export async function POST(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
const secret = requestHeaders.get("x-vercel-reval-key");
if (secret !== process.env.CONTENTFUL_REVALIDATE_SECRET) {
return NextResponse.json({ message: "Invalid secret" }, { status: 401 });
}
revalidateTag("posts");
return NextResponse.json({ revalidated: true, now: Date.now() });
}
即:先比对 x-vercel-reval-key 头与 CONTENTFUL_REVALIDATE_SECRET 环境变量,不匹配返回 401;匹配则调用 revalidateTag("posts"),使 lib/api.ts 中带 tags: ["posts"] 的缓存失效,下次请求重新拉取数据并重新生成首页与对应文章页面。
验证是否成功:在 Contentful 中编辑一篇帖子并点击 Publish,已部署站点应立即展示新内容而不触发构建;可在 Contentful 的 Webhook 请求日志中确认收到 200 状态码(若调用失败,优先检查部署环境中的 CONTENTFUL_REVALIDATE_SECRET 是否与 Webhook 请求头一致)。
部署
- 部署本地项目:将代码推送到代码托管平台后导入部署平台,导入时务必在 Environment Variables 中补齐与
.env.local一致的全部变量。 - 从模板部署:直接使用该示例的模板一键部署,会自动部署 Next.js 项目并通过 Contentful 集成连接你的 Space;若使用 Draft Mode,还需额外添加
CONTENTFUL_PREVIEW_SECRET环境变量。
相关示例
仓库 examples/ 目录下还提供了一批同类 CMS 集成示例,可作对比参考:WordPress、Sanity、Ghost、Prismic、DatoCMS、Payload、Storyblok、Cosmic、Tina、Blog Starter 等(完整列表见 examples/cms-contentful/README.md)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00