Next.js + Storyblok:构建静态生成 CMS 博客的完整实战指南
本文以 Next.js 官方示例仓库中的 cms-storyblok 示例 为主体,完整讲解如何用 Storyblok 作为数据源构建一个静态生成(Static Generation)的博客:从 create-next-app 引导项目、Storyblok 后台内容建模、GraphQL 数据访问层、getStaticPaths/getStaticProps 静态生成,到 Draft Mode(预览模式)的安全实现与图片优化配置。读完后,你可以独立搭建一个内容更新即构建的博客站点,并理解 Next.js 官方文档中 Static Generation 与 Preview Mode 两大特性在真实项目中的落地方式。
项目结构与技术栈
示例位于 examples/cms-storyblok,是一个标准的 Pages Router 博客项目:
examples/cms-storyblok/
├── components/ # 博客展示组件(封面、文章头、更多文章列表、预览提示条等)
├── lib/
│ ├── api.js # Storyblok GraphQL 数据访问层
│ ├── constants.js # 站点常量(CMS 名称、OG 图等)
│ └── markdownToHtml.js
├── pages/
│ ├── index.js # 首页(静态生成)
│ ├── posts/[slug].js # 文章详情页(动态路由 + 静态生成)
│ └── api/
│ ├── preview.js # 开启 Draft Mode
│ └── exit-preview.js # 退出 Draft Mode
├── styles/ # Tailwind 全局样式
├── .env.local.example # 环境变量模板
├── package.json
└── tailwind.config.js
从 package.json 看,核心依赖为:
next(latest)+react/react-dom(18.x):框架与运行时;storyblok-js-client(2.5.0):Storyblok 官方 JS 客户端,本例中主要复用其richTextResolver模块把 Storyblok 富文本结构渲染成 HTML;remark/remark-html:Markdown 转 HTML(封装在 markdownToHtml.js 中);date-fns、classnames:日期格式化与类名合并;- devDependencies 为
tailwindcss+postcss+autoprefixer,样式体系是 Tailwind CSS。
README 说明该示例展示的是 Next.js 的 Static Generation(静态生成)特性:页面 HTML 在构建时生成并缓存,请求时无需执行页面逻辑,获得极快的响应。数据源则是 Storyblok 这类 headless CMS——内容编辑与前端渲染解耦,编辑者只需在 Storyblok 后台发布,站点在构建/重新部署时拉取已发布内容。
引导项目:使用 create-next-app 初始化
在仓库中运行 Next.js 官方脚手架命令即可克隆本示例(对应 create-next-app 包):
# npm
npx create-next-app --example cms-storyblok cms-storyblok-app
# Yarn
yarn create next-app --example cms-storyblok cms-storyblok-app
# pnpm
pnpm create next-app --example cms-storyblok cms-storyblok-app
三个命令分别适用于 npm / Yarn / pnpm 环境,--example cms-storyblok 指定使用本示例模板,第二个参数 cms-storyblok-app 是新项目目录名。
相关示例:官方仓库还提供了大量同构的 CMS 博客示例,可对照学习不同数据源的接入差异,如 Contentful、Payload、Prismic、Sanity、WordPress、Ghost、Tina 等(完整清单见 README 的 Related examples 小节)。
Storyblok 后台配置:六步内容建模
这部分对应 README 的 Step 1 ~ Step 6,是示例能跑起来的前提,需按顺序完整执行。
Step 1. 创建 Storyblok 账号与 Space
在 Storyblok 官网注册并登录控制台,注册时选择 Create a new space(创建新空间),空间名称任意。Space 相当于一个内容容器,后续所有内容类型(content type)与条目(entry)都归属于它。
Step 2. 创建 Authors 文件夹与内容类型
在 Dashboard 中创建一个名为 Authors 的文件夹:
- Default content type 选择 Add new(新建内容类型);
- 内容类型命名为
author; - 蓝图(blueprint)选择 Blank(空白模板)。
Step 3. 创建一条 author 条目
在 Authors 文件夹中新建条目,Name 随意(如 Test Author)。创建后点击 Define schema(定义字段结构):
- 添加字段
picture,将其 Type 设为 Asset,并选择 Images; - 上传一张图片(例如来自 Unsplash 的图)到
picture; - 最后点击 Publish 发布。
这一步决定了 author 内容类型的数据形状:content.picture 是一个图片资源字段。
Step 4. 创建 Posts 文件夹与 post 内容类型
发布 author 后,点击侧边栏 Content 返回 Dashboard,新建文件夹 Posts:
- Default content type 选择 Add new;
- 内容类型命名为
post; - 蓝图选择 Post(Storyblok 预置的文章模板,会自动带上标题、正文、slug 等常见字段)。
Step 5. 创建 post 条目并定义字段
在 Posts 文件夹中新建条目(Name 任意),点击 Define schema 添加以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
title |
Text | 文章标题 |
image |
Text | 封面图 URL(本例填 Unsplash 图片地址) |
intro |
Text | 摘要/导语 |
long_text |
Richtext | 正文(富文本) |
author |
Single-Option | Source 设为 Stories,Restrict to content type 设为 author,即只能选择已发布的 author 条目 |
然后填充各字段:Title 填任意文本、Image 填图片 URL、Intro 填导语、Long Text 填正文、Author 选择上一步创建的 author。完成后点击 Publish。
可以继续创建更多文章,注意每一篇都要发布——未发布的草稿只有开启 Draft Mode 后才能被看到(见下文预览模式章节)。
字段与代码的对应关系:
post的content中会包含title、image、intro、long_text、author(嵌套对象,含name与content),以及 Storyblok 系统字段slug、published_at、first_published_at。这些数据正是后续 GraphQL 查询与页面渲染消费的形状。
Step 6. 配置环境变量
进入 Space 的 Settings 菜单,点击 API-Keys,复制页面上的 preview token。
示例目录提供了环境变量模板 .env.local.example,将其复制为 .env.local(该文件已被 Git 忽略,不会提交):
cp .env.local.example .env.local
然后在 .env.local 中设置两个变量:
STORYBLOK_API_KEY:上一步复制的 API key(preview token),用于调用 Storyblok GraphQL 接口;STORYBLOK_PREVIEW_SECRET:任意随机字符串(避免空格),例如MY_SECRET。它用于校验 Draft Mode 开启请求的合法性,防止未授权访问。
数据访问层:GraphQL 查询与 draft/published 双版本
lib/api.js 是示例的数据访问层,封装了对 Storyblok GraphQL 端点 https://gapi.storyblok.com/v1/api 的访问。核心是一个统一的 fetchAPI 函数:
async function fetchAPI(query, { variables, preview } = {}) {
const res = await fetch("https://gapi.storyblok.com/v1/api", {
method: "POST",
headers: {
"Content-Type": "application/json",
Token: process.env.STORYBLOK_API_KEY,
Version: preview ? "draft" : "published",
},
body: JSON.stringify({ query, variables }),
});
const json = await res.json();
if (json.errors) {
console.error(json.errors);
throw new Error("Failed to fetch API");
}
return json.data;
}
两个关键机制值得注意:
Version请求头:取值为"published"或"draft"。这是整个预览模式的数据面基础——同一套查询,仅切换该请求头,就能在"已发布内容"与"含草稿内容"之间切换;- 认证:通过
Token请求头传递STORYBLOK_API_KEY;任何 GraphQL 错误都会被打印并抛出Failed to fetch API,保证构建时不会静默产出空数据。
在此基础上暴露了四个查询函数:
getAllPostsWithSlug():查询PostItems全部条目的slug,供getStaticPaths构建静态路径列表;getAllPostsForHome(preview):按first_published_at:desc(首次发布时间倒序)查询全部文章的slug、发布时间与content(含long_text、intro、title、image、author),供首页使用,preview参数决定是否取草稿版本;getPostAndMorePosts(slug, preview):一次查询同时返回指定文章(PostItem(id: $slug))与"更多文章"列表(PostItems(per_page: 3),客户端再过滤掉当前文章并截取 2 篇);注意查询变量传入的 slug 会被拼成`posts/${slug}`前缀,因为条目存放在Posts文件夹下,Storyblok 的资源 ID 为文件夹/条目slug;getPreviewPostBySlug(slug):仅查询PostItem的slug字段且强制preview: true,专供预览接口校验"该草稿是否真实存在"。
静态生成:getStaticPaths 与 getStaticProps
文章详情页 pages/posts/[slug].js
pages/posts/[slug].js 演示了 Next.js 动态路由的完整静态生成流程:
export async function getStaticPaths() {
const allPosts = await getAllPostsWithSlug();
return {
paths: allPosts?.map((post) => `/posts/${post.slug}`) || [],
fallback: true,
};
}
export async function getStaticProps({ params, preview = null }) {
const data = await getPostAndMorePosts(params.slug, preview);
return {
props: {
preview,
post: {
...data.post,
html: data.post?.content?.long_text
? new RichTextResolver().render(data.post.content.long_text)
: null,
},
morePosts: data.morePosts,
},
};
}
getStaticPaths从 CMS 拉取全部已发布文章的 slug,映射为/posts/{slug}路径数组,fallback: true表示构建时只为已知路径预渲染;遇到未知路径时会先用 "Loading…" 兜底 UI 响应,再按需渲染并缓存。页面组件中router.isFallback即为该状态的判断依据,此时展示<PostTitle>Loading…</PostTitle>;getStaticProps中的preview参数由 Next.js 根据 Draft Mode cookie 自动注入——这正是预览模式能让同一页面取到草稿数据的关键。正文 HTML 通过storyblok-js-client的RichTextResolver在构建期(或按需渲染期)把 Storyblok 的富文本结构渲染成 HTML 字符串,再交给PostBody组件输出;- 若
post为空且非 fallback 状态,页面渲染ErrorPage statusCode={404},即"slug 不存在"的 404 兜底。
首页 pages/index.js
pages/index.js 更简单:getStaticProps({ preview = null }) 调用 getAllPostsForHome(preview) 取全部文章,组件内取第一条作为 hero 文章(HeroPost,展示标题、封面、日期、作者、摘要),其余传入 MoreStories 网格展示。日期字段使用 first_published_at || published_at 的降级策略,保证草稿(尚未发布过)也能显示合理日期。
从源码结构看,整个博客没有任何 API 路由参与页面数据获取(/api/ 下只有 preview.js 与 exit-preview.js 两个与 Draft Mode 相关的轻量路由),数据获取全部发生在构建/渲染阶段——这是静态生成模式的典型特征:编辑者发布新文章后,触发一次重新构建(或部署),新文章即进入静态产物。
Draft Mode 预览模式:从 Cookie 到安全校验
Next.js 的 Preview Mode 允许临时查看未构建的页面。本示例把它用于预览 Storyblok 中未发布的草稿文章,由三个部分协作完成:
1. 开启入口 pages/api/preview.js
pages/api/preview.js 是开启预览的 API 路由,实现了完整的安全链路:
// 校验 secret 与 slug 参数,secret 应只有本路由与 CMS 知晓
if (
req.query.secret !== process.env.STORYBLOK_PREVIEW_SECRET ||
!req.query.slug
) {
return res.status(401).json({ message: "Invalid token" });
}
// 先查 CMS 确认该 slug 存在,防止为任意路径开启预览
const post = await getPreviewPostBySlug(req.query.slug);
if (!post) {
return res.status(401).json({ message: "Invalid slug" });
}
// 设置 cookie 开启 Draft Mode
res.setDraftMode({ enable: true });
// 重定向到从 CMS 取回的文章路径
// 注意:不直接重定向到 req.query.slug,避免开放重定向漏洞
res.writeHead(307, { Location: `/posts/${post?.PostItem?.slug}` });
res.end();
三个安全细节:secret 必须与 STORYBLOK_PREVIEW_SECRET 完全一致;slug 必须真实存在于 Storyblok(getPreviewPostBySlug 以 draft 版本查询);307 重定向的目标使用从 CMS 取回的 slug 而非用户传入的原始值,注释中明确说明这是为了避免开放重定向(open redirect)漏洞。res.setDraftMode({ enable: true }) 会设置 __NEXTPREVIEWDATA cookie,此后该会话内的页面请求都会让 getStaticProps 收到 preview = true。
2. 退出入口 pages/api/exit-preview.js
pages/api/exit-preview.js 只做两件事:res.setDraftMode({ enable: false }) 移除 cookie,然后 307 重定向回首页。
3. 页面级提示条 components/alert.js
components/alert.js 在预览状态下显示顶部提示 "This is page is a preview.",并提供指向 /api/exit-preview 的 "Click here to exit preview mode" 链接;非预览状态则显示源码仓库地址提示。该提示条由 components/layout.js 中的 Layout 组件统一渲染,preview 状态通过 getStaticProps 返回的 props 一路传递到页面组件。
实操:试用预览模式
按 README 的 Step 9 操作:在 Storyblok 中再创建一篇新文章(可以复制已有文章),但只保存、不发布。此时访问 http://localhost:3000 不会看到这篇文章(它未发布,不在静态路径里)。然后访问:
http://localhost:3000/api/preview?secret=<secret>&slug=<slug>
<secret>:.env.local中设置的STORYBLOK_PREVIEW_SECRET;<slug>:该文章的 slug(在 Storyblok 条目的 Config 区域可以看到,注意对应posts/{slug}中的{slug}部分)。
请求校验通过后,浏览器会携带 Draft Mode cookie 跳转到该文章页,此时 getStaticProps 以 preview = true 重新执行,fetchAPI 的 Version 头切换为 draft,草稿文章即可被看到,页面顶部出现预览提示条;点击 "Click here to exit preview mode" 即退出预览、回到已发布内容。
图片优化配置
Storyblok 条目与文章封面都可能引用外部图片(Storyblok 资产域名 a.storyblok.com,示例中也允许 Unsplash 的 images.unsplash.com)。要安全地对这些远程图片启用 next/image 优化,需要在 next.config.js 中声明白名单:
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'a.storyblok.com',
port: '',
pathname: '**',
search: '',
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
port: '',
pathname: '**',
search: '',
},
],
},
}
remotePatterns 采用"协议 + 主机名 + 路径"的 glob 匹配方式,只有命中白名单的图片 URL 才会走 Next.js 的图片优化管线(自动格式协商、尺寸裁剪、缓存),其余远程 URL 会被拒绝。需要说明的是:README 中该步骤标注为 "Step 9. Configure Image Optimization",而当前仓库的示例目录下并未附带 next.config.js 文件,这份配置属于按 README 指引需要在本地补充的配置文件——若使用 npx create-next-app --example cms-storyblok 引导项目后遇到图片 400 错误,应先检查该配置是否存在。
图片的实际使用见 components/cover-image.js:以固定 width={2000} height={1000} 渲染 <Image>,并通过 slug 参数决定是否为封面图包裹 <Link> 跳转。
开发与部署
本地开发(README Step 8)
npm install
npm run dev
# 或
yarn install
yarn dev
启动后博客运行在 http://localhost:3000。[package.json](https://gitcode.com/GitHub_Trending/next/next.js/blob/1b5400c92633ca56c81c4c0a670e3416992ef64e/examples/cms-storyblok/package.json?utm_source=gitcode_repo_files) 中定义的脚本为 dev(next)、build(next build)、start(next start),生产验证即 npm run build 后 npm run start。
部署到 Vercel(README Step 10)
按 README 的部署指引,有两种方式:
- 部署本地项目:将项目推送到代码托管平台(GitHub/GitLab/Bitbucket)后导入 Vercel。关键步骤:导入时务必在 Vercel 的 Environment Variables 中配置与本地
.env.local一致的STORYBLOK_API_KEY和STORYBLOK_PREVIEW_SECRET,否则构建期拉取数据与预览模式都会失败; - 使用官方模板部署:通过 README 中的 Deploy 按钮基于本示例模板直接创建项目,并在部署流程中填入上述两个环境变量。
部署后的运行模式与本地一致:静态 HTML 在构建时生成,编辑者在 Storyblok 发布新内容后触发重新构建即可上线;未发布内容可随时通过 /api/preview 预览。
小结:示例承载的核心知识点
回看这个 43 行 README 背后的完整实现,它浓缩了 Next.js 静态生成应用的四个核心能力:
- 构建期数据获取:
getStaticPaths+getStaticProps配合fallback: true,把 CMS 内容编译为静态 HTML(pages/index.js、pages/posts/[slug].js); - 预览模式全链路:secret 校验 → CMS 存在性校验 → Draft Mode cookie → 请求头切换 draft 版本 → 防开放重定向的 307 跳转(pages/api/preview.js、lib/api.js);
- 远程图片优化白名单:
images.remotePatterns声明式放行第三方图片域名; - headless CMS 接入范式:GraphQL 单端点、内容类型建模(
author/post及字段类型)、slug前缀(posts/{slug})与富文本RichTextResolver渲染——这些模式同样适用于官方仓库中的其他 CMS 示例。
如需扩展,可对照 Payload、Sanity 等同族示例比较不同 CMS 的数据模型差异,或查阅仓库文档目录下的 Pages 路由文档 与 架构文档 进一步理解静态生成的构建流程。
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 StartedRust0627
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