首页
/ Next.js + Storyblok:构建静态生成 CMS 博客的完整实战指南

Next.js + Storyblok:构建静态生成 CMS 博客的完整实战指南

2026-09-06 16:19:40作者:毕习沙Eudora

本文以 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 看,核心依赖为:

  • nextlatest)+ react/react-dom(18.x):框架与运行时;
  • storyblok-js-client(2.5.0):Storyblok 官方 JS 客户端,本例中主要复用其 richTextResolver 模块把 Storyblok 富文本结构渲染成 HTML;
  • remark / remark-html:Markdown 转 HTML(封装在 markdownToHtml.js 中);
  • date-fnsclassnames:日期格式化与类名合并;
  • 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 博客示例,可对照学习不同数据源的接入差异,如 ContentfulPayloadPrismicSanityWordPressGhostTina 等(完整清单见 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 设为 StoriesRestrict to content type 设为 author,即只能选择已发布的 author 条目

然后填充各字段:Title 填任意文本、Image 填图片 URL、Intro 填导语、Long Text 填正文、Author 选择上一步创建的 author。完成后点击 Publish

可以继续创建更多文章,注意每一篇都要发布——未发布的草稿只有开启 Draft Mode 后才能被看到(见下文预览模式章节)。

字段与代码的对应关系:postcontent 中会包含 titleimageintrolong_textauthor(嵌套对象,含 namecontent),以及 Storyblok 系统字段 slugpublished_atfirst_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;
}

两个关键机制值得注意:

  1. Version 请求头:取值为 "published""draft"。这是整个预览模式的数据面基础——同一套查询,仅切换该请求头,就能在"已发布内容"与"含草稿内容"之间切换;
  2. 认证:通过 Token 请求头传递 STORYBLOK_API_KEY;任何 GraphQL 错误都会被打印并抛出 Failed to fetch API,保证构建时不会静默产出空数据。

在此基础上暴露了四个查询函数:

  • getAllPostsWithSlug():查询 PostItems 全部条目的 slug,供 getStaticPaths 构建静态路径列表;
  • getAllPostsForHome(preview):按 first_published_at:desc(首次发布时间倒序)查询全部文章的 slug、发布时间与 content(含 long_textintrotitleimageauthor),供首页使用,preview 参数决定是否取草稿版本;
  • getPostAndMorePosts(slug, preview):一次查询同时返回指定文章(PostItem(id: $slug))与"更多文章"列表(PostItems(per_page: 3),客户端再过滤掉当前文章并截取 2 篇);注意查询变量传入的 slug 会被拼成 `posts/${slug}` 前缀,因为条目存放在 Posts 文件夹下,Storyblok 的资源 ID 为 文件夹/条目slug
  • getPreviewPostBySlug(slug):仅查询 PostItemslug 字段且强制 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-clientRichTextResolver 在构建期(或按需渲染期)把 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.jsexit-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 跳转到该文章页,此时 getStaticPropspreview = true 重新执行,fetchAPIVersion 头切换为 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) 中定义的脚本为 devnext)、buildnext build)、startnext start),生产验证即 npm run buildnpm run start

部署到 Vercel(README Step 10)

按 README 的部署指引,有两种方式:

  1. 部署本地项目:将项目推送到代码托管平台(GitHub/GitLab/Bitbucket)后导入 Vercel。关键步骤:导入时务必在 Vercel 的 Environment Variables 中配置与本地 .env.local 一致的 STORYBLOK_API_KEYSTORYBLOK_PREVIEW_SECRET,否则构建期拉取数据与预览模式都会失败;
  2. 使用官方模板部署:通过 README 中的 Deploy 按钮基于本示例模板直接创建项目,并在部署流程中填入上述两个环境变量。

部署后的运行模式与本地一致:静态 HTML 在构建时生成,编辑者在 Storyblok 发布新内容后触发重新构建即可上线;未发布内容可随时通过 /api/preview 预览。

小结:示例承载的核心知识点

回看这个 43 行 README 背后的完整实现,它浓缩了 Next.js 静态生成应用的四个核心能力:

  1. 构建期数据获取getStaticPaths + getStaticProps 配合 fallback: true,把 CMS 内容编译为静态 HTML(pages/index.jspages/posts/[slug].js);
  2. 预览模式全链路:secret 校验 → CMS 存在性校验 → Draft Mode cookie → 请求头切换 draft 版本 → 防开放重定向的 307 跳转(pages/api/preview.jslib/api.js);
  3. 远程图片优化白名单images.remotePatterns 声明式放行第三方图片域名;
  4. headless CMS 接入范式:GraphQL 单端点、内容类型建模(author/post 及字段类型)、slug 前缀(posts/{slug})与富文本 RichTextResolver 渲染——这些模式同样适用于官方仓库中的其他 CMS 示例。

如需扩展,可对照 PayloadSanity 等同族示例比较不同 CMS 的数据模型差异,或查阅仓库文档目录下的 Pages 路由文档架构文档 进一步理解静态生成的构建流程。

登录后查看全文
热门项目推荐
相关项目推荐