首页
/ Next.js 结合 ButterCMS 构建静态生成内容站的完整实战:cms-buttercms 示例解析

Next.js 结合 ButterCMS 构建静态生成内容站的完整实战:cms-buttercms 示例解析

2026-09-06 15:10:34作者:柯茵沙

本文以 Next.js 官方仓库中的 cms-buttercms 示例 为主线,讲解如何用 ButterCMS 作为数据源搭建一个包含落地页、博客、分类/标签页、站内搜索的完整网站,覆盖安装、环境变量配置、预览模式与 Vercel 部署全流程;读完后你将掌握 Next.js Pages Router 的 getStaticPaths/getStaticProps/revalidate(ISR)在真实 CMS 集成中的落地方式,并能对照 API 封装层路由配置 理解每个关键参数的作用。

示例定位:一个开箱即用的 CMS 驱动站点

该示例的 README 将其定义为"fully-functional, drop-in proof-of-concept"——一个与 ButterCMS 账号完全打通的 Next.js 起步项目。它包含以下能力:

  • 动态内容全集成:主菜单、落地页、博客文章、分类、标签,全部来自 ButterCMS 账号中的动态数据;
  • 静态生成(Static Generation)为核心渲染策略:页面在构建期/请求期由 CMS 数据预渲染;
  • 内置搜索功能:博客支持按关键词搜索、按分类与标签过滤;
  • 自定义主题:基于 Bootstrap 的完整站点主题,落地页采用"API 驱动的分节组件"模式;
  • 示例内容自动创建:注册 ButterCMS 免费试用后,账号后台会自动生成本示例所需的全部样例内容(落地页、菜单、博客文章等),无需手动造数据。

从源码结构看,示例采用 Pages Router(pages/ 目录 + getInitialProps/getStaticProps/getServerSideProps),package.json 中声明 next: ^12.1.0buttercms: ^1.2.8,即该集成模式验证于 Next.js 12 时代的 Pages Router,其静态生成思想在 App Router 中同样适用。

目录结构总览

examples/cms-buttercms/
├── components/
│   ├── blog/                  # 博客组件:文章列表、文章卡片、分类侧栏、搜索框
│   ├── landing-page-sections/ # 落地页分节组件:hero、features、testimonials 等
│   ├── main-menu/             # 主菜单
│   ├── _app 相关布局组件       # 页头/页脚/加载器/回顶按钮等
├── lib/api.js                 # ButterCMS 客户端与全部数据获取函数(核心)
├── pages/
│   ├── blog.js                # 博客列表(静态生成)
│   ├── blog/[slug].js         # 单篇文章(静态生成 + fallback)
│   ├── blog/category/[slug].js# 分类页(ISR,revalidate: 1)
│   ├── blog/tag/[slug].js     # 标签页
│   ├── blog/search.js         # 搜索结果页(SSR)
│   ├── landing-page/[slug].js # 落地页(静态生成 + fallback)
│   ├── _app.js / _document.js
│   └── missing-token.js       # 未配置 API Key 时的提示页
├── .env.local.example
├── app.json                   # Vercel App 清单(一键部署元数据)
└── next.config.js

一、获取与安装示例

README 提供了两条安装路径,对应"从零克隆"与"脚手架引导"两种工作流。

方式一:克隆仓库后用 npm / Yarn 安装

git clone https://github.com/ButterCMS/nextjs-starter-buttercms.git
cd nextjs-starter-buttercms
npm install   # 或 yarn install

在本地复现时,等价做法是直接查看本仓库中的 examples/cms-buttercms 目录,其 package.json 即为完整依赖清单(buttercmscamelcase-keysdate-fnsbootstraptiny-slidersharp 等)。

方式二:通过 Create-Next-App 引导

使用 npx(npm)、yarnpnpm 执行 create-next-app 并指定示例名,即可一键拉取该示例模板:

npx create-next-app --example cms-buttercms cms-buttercms-app
yarn create next-app --example cms-buttercms cms-buttercms-app
pnpm create next-app --example cms-buttercms cms-buttercms-app

其中 --example cms-buttercms 对应本仓库的示例目录名,cms-buttercms-app 为新建的目标项目目录名。

二、配置:API Key、环境变量与启动

Step 1. 获取 ButterCMS API Token

在 ButterCMS 注册账号后,后台会直接展示一个免费 API Token,后续所有数据请求都依赖它。

Step 2. 设置环境变量

本示例通过 .env.local.example 给出环境变量模板,其内容只有一行:

NEXT_PUBLIC_BUTTER_CMS_API_KEY=your_auth_token

按 README 的操作步骤:

cp .env.local.example .env.local

然后在 .env.local 中设置各变量:

  • NEXT_PUBLIC_BUTTER_CMS_API_KEY:填入你的 ButterCMS API Key。变量带 NEXT_PUBLIC_ 前缀,意味着客户端代码也能读到(本示例中它同时用于服务端渲染守卫,见下文 missing-token 重定向)。
  • PREVIEW(可选):控制是否允许 CMS 中的草稿内容被返回。该变量在 lib/api.js 中的解析逻辑值得逐行看清:
const previewSetting = process.env.PREVIEW;
// make preview mode by default
const preview =
  previewSetting === "true" || previewSetting === undefined ? 1 : 0;

try {
  butter = Butter(process.env.NEXT_PUBLIC_BUTTER_CMS_API_KEY, preview);
} catch (e) {
  console.log(e);
}

从源码可以看出:预览(草稿可见)模式默认开启——只有显式设置 PREVIEW=falsepreview 才会为 0PREVIEW=true 或未设置都视为开启。第二个参数 preview 会透传给 Butter SDK 客户端,决定 API 是否返回未发布的草稿内容。

Step 3. 开发模式运行

注册 ButterCMS 后,账号内已自动创建本示例所需的全部示例内容。运行:

npm install
npm run dev

# 或

yarn install
yarn dev

启动后访问 http://localhost:3000,即可看到完整的起步站点:基于 API 组件的落地页、基于 API 的主菜单以及博客。

未配置 Token 时的兜底行为

值得注意的细节是,示例做了"缺 Key 不崩"的优雅降级。next.config.js 中的 redirects() 会根据 Token 是否存在动态决定重定向规则:

redirects() {
  const sourcesRequiringAuthToken = [
    "/",
    "/landing-page/:slug*",
    "/blog/:path*",
  ];

  return process.env.NEXT_PUBLIC_BUTTER_CMS_API_KEY
    ? [
        {
          source: "/missing-token",
          destination: "/",
          permanent: false,
        },
      ]
    : sourcesRequiringAuthToken.map((source) => ({
        source: source,
        destination: "/missing-token",
        permanent: false,
      }));
},
  • 有 Token:仅保留 /missing-token → / 的回退重定向;
  • 无 Token:首页、所有落地页与博客路径全部 302 到 /missing-token,由 pages/missing-token.js 渲染的提示页承接。

同样地,pages/_app.jsMyApp.getInitialProps 中只在 authToken 存在时才调用 getMainMenu() 拉取菜单,否则传入空数组 [],保证无 Key 时应用仍可渲染布局。

三、API 封装层:lib/api.js 的核心函数

整个示例与 ButterCMS 的通信被集中封装在 examples/cms-buttercms/lib/api.js 中,这是理解数据流的枢纽。除客户端初始化外,关键函数签名如下:

函数 作用 关键参数与行为
getLandingPage(slug) 按 slug 获取单张落地页 调用 butter.page.retrieve("landing-page", slug)
getLandingPages() 获取全部落地页 内部以 defaultPageSize = 100 分页,while 循环翻页直到 next_page 为空
getPostsData({ page, pageSize, tag, category }) 文章列表 默认 pageSize = 10defaultPostCount),支持 tag_slug / category_slug 过滤
getPost(slug) 获取单篇文章 调用 butter.post.retrieve(slug)
getMainMenu() 获取主菜单 butter.content.retrieve(["navigation_menu"]) 中取名为 "Main menu"menu_items
getCategories() / getTags() 分类/标签列表 分别调用 butter.category.list() / butter.tag.list()
searchPosts({ query }) 关键词搜索 调用 butter.post.search(query)

落地页的分页拉取是一个典型实现,展示了如何处理 CMS API 的分页元数据:

export async function getLandingPages() {
  let paginatedLandingPages = [];
  let currentPage = 1;
  while (!!currentPage) {
    const landingPagesData = await getLandingPagesData(currentPage);
    paginatedLandingPages.push(...landingPagesData.pages);
    currentPage = landingPagesData.nextPage;
  }

  return paginatedLandingPages;
}

其中 getLandingPagesData 返回 pages / prevPage / nextPage 三元组,nextPage 来自响应 meta.next_page,为 null 时循环终止。所有函数统一采用 try/catch 抛出 e.response.data.detail,把 CMS 侧错误以可读形式上抛,由页面层的 catch 决定降级策略(返回 notFound 或空数组)。

四、静态生成在各页面的具体落地

README 明确说明该示例展示的是 Next.js 的 Static Generation 能力。对照源码,五种页面使用了三种数据获取策略:

1. 博客列表(纯静态):pages/blog.js

pages/blog.js 使用 getStaticProps 拉取文章与分类,并在 API 失败时降级为空列表而非报错:

export async function getStaticProps() {
  const butterToken = process.env.NEXT_PUBLIC_BUTTER_CMS_API_KEY;

  if (butterToken) {
    try {
      const blogPosts = (await getPostsData()).posts;
      const categories = await getCategories();

      return { props: { posts: camelcaseKeys(blogPosts), categories } };
    } catch (e) {
      console.log("Could not get posts", e);

      return {
        props: { posts: [], categories: [] },
      };
    }
  }

  return { props: { posts: [], categories: [] } };
}

注意两处工程细节:camelcaseKeys 把 Butter API 返回的 snake_case 字段(如 category_slug)统一转成 camelCase,使 React 侧属性访问更符合习惯;if (butterToken) 守卫与前述重定向策略呼应。

2. 动态路径 + fallback:落地页与文章详情页

pages/landing-page/[slug].jsgetStaticPaths 在构建期列出所有落地页 slug:

export async function getStaticPaths() {
  const butterToken = process.env.NEXT_PUBLIC_BUTTER_CMS_API_KEY;

  if (butterToken) {
    try {
      const landingPages = await getLandingPages();

      return {
        paths: landingPages.map((page) => `/landing-page/${page.slug}`),
        fallback: true,
      };
    } catch (e) {
      console.error("Couldn't load content for Landing pages.", e);
    }

    return { paths: [], fallback: false };
  }
}

fallback: true 的意义在于:构建期未预渲染的 slug 不会被拒绝,而是先展示 router.isFallback 时的 <Preloader /> 加载态,再在服务端补渲染 getStaticProps。对应的 getStaticProps 同时拉取页面数据与最新 2 篇文章(getPostsData({ page: 1, pageSize: 2 })),供落地页尾部嵌入博客区块;取数失败时返回 notFound: true,页面侧配合 router.isFallback!page 两个分支分别渲染加载器与 ErrorPage statusCode={404}

pages/blog/[slug].js 遵循完全相同的模式,并额外展示了 SEO 元数据的写法:从文章字段生成 <title>meta description 以及 Open Graph / Twitter Card 标签;正文通过 dangerouslySetInnerHTML 注入 CMS 返回的 HTML,配图则使用 next/imagelayout="fill" + objectFit="cover")。

3. ISR 增量再验证:分类页

pages/blog/category/[slug].jsgetStaticProps 中返回了 revalidate: 1

return {
  props: { posts: camelcaseKeys(blogPosts), categories, slug },
  revalidate: 1,
};

这意味着分类页在首次请求生成后,最多 1 秒就会触发后台再验证——CMS 中该分类的新文章几乎实时出现在已缓存的静态页面上。getStaticPaths 则遍历 getCategories() 生成 /blog/category/:slug 路径集合,同样使用 fallback: true

4. 搜索页(SSR):pages/blog/search.js

pages/blog/search.js 是唯一采用服务端逐请求渲染的页面,因为搜索词无法在构建期枚举:

export async function getServerSideProps({ query: { query } }) {
  const blogPosts = await searchPosts({ query });
  const categories = await getCategories();

  return {
    props: { posts: camelcaseKeys(blogPosts), categories, query },
  };
}

这与 README 中"already-implemented search functionality"的描述对应:搜索走 ButterCMS 的 post.search 接口,每次请求实时执行。

5. 全局菜单:_app.js 的 getInitialProps

主菜单不是页面级数据,而是全局数据,因此放在 pages/_app.jsgetInitialProps 中获取并注入到 HeaderSection / FooterSection

MyApp.getInitialProps = async (appContext) => {
  const appProps = await App.getInitialProps(appContext);
  const authToken = process.env.NEXT_PUBLIC_BUTTER_CMS_API_KEY;
  let mainMenu = [];

  if (authToken) {
    try {
      mainMenu = await getMainMenu();
    } catch (e) {
      console.error("Couldn't load main menu links.", e);
    }
  }

  return { ...appProps, mainMenu };
};

同文件中还通过 Router.eventsrouteChangeStart/Complete/Error 事件驱动全局 <Preloader>,实现路由切换时的加载动画。

五、next.config.js:路由重写与远程图片域

examples/cms-buttercms/next.config.js 除前述 redirects 外,还包含两类配置:

首页重写——把根路径固定指向 ButterCMS 中的样例落地页:

async rewrites() {
  return [
    {
      source: "/",
      destination: "/landing-page/landing-page-with-components",
    },
  ];
},

远程图片白名单——next/image 默认禁止加载任意外域图片,而 Butter 的媒体托管在 cdn.buttercms.com,因此需要显式声明:

images: {
  remotePatterns: [
    {
      protocol: "https",
      hostname: "cdn.buttercms.com",
      port: "",
      pathname: "/my-account/**",
    },
  ],
},

/my-account/** 的 pathname 限定说明只放行当前账号媒体空间下的资源。

六、落地页的"分节组件"模式

落地页是 CMS 驱动的典型场景:营销页的区块顺序与类型都由内容方在后台配置。pages/landing-page/[slug].js 中按 CMS 返回的 page.fields.body 数组逐节渲染:

{page.fields.body.map(({ type, fields: sectionData }, index) => (
  <LandingPageSection key={index} type={type} sectionData={sectionData} />
))}

components/landing-page-sections/landing-page-section.js 负责把 type 映射到具体组件,且每个组件都用 next/dynamic 动态导入、失败时回退到 MissingSection

const sectionsComponentPaths = () => ({
  hero: dynamic(() => import(".../hero").catch(() => () => MissingSection), { loading: Preloader }),
  two_column_with_image: dynamic(() => import(".../two-column-with-image").catch(() => () => MissingSection), { loading: Preloader }),
  features: dynamic(() => import(".../features").catch(() => () => MissingSection), { loading: Preloader }),
  testimonials: dynamic(() => import(".../testimonials").catch(() => () => MissingSection), { loading: Preloader }),
});
const SectionComponent = sectionsComponentPaths()[type] || MissingSection;

从源码结构看,这套"类型 → 动态组件 + 缺失兜底"的映射表使 CMS 端新增/删减区块类型不会直接击穿前端:未知 type 渲染占位组件而非报错,未匹配到的区块数据走 MissingSection

七、预览模式(Preview Mode)实战

README 的 Step 4 描述了本示例最有实用价值的功能之一:草稿内容预览。行为与开启方式如下:

  1. 在 ButterCMS 后台新建一篇博客,标题设为 Draft Post Test,填入任意正文与摘要;
  2. 关键:不要点击 Publish,而是点击 Save Draft
  3. 先在 .env.local 中设置 PREVIEW=false 并重启本地开发服务器,访问博客列表(如 http://localhost:3000/#blog)——此时看不到这篇草稿,因为其状态尚未变为 published
  4. 随后从 .env.local删除 PREVIEW=false(即回到默认开启状态),刷新后新草稿文章即出现在列表中。

结合 lib/api.js 第 5–11 行的代码可以确认其机制:预览开关直接作为 Butter(apiKey, preview) 的第二个参数传给 SDK,由 API 侧决定草稿可见性;由于默认值为"开启",本地开发时草稿默认可见,而生产部署若不希望暴露草稿,应显式设置 PREVIEW=false

提示:README 还建议在 ButterCMS 后台为部署在 Vercel 上的页面配置 Preview URL,即可在 Butter 账号内对线上站点做实时内容预览(该功能属于 ButterCMS 平台侧能力,需在外部后台配置)。

八、部署到 Vercel

README 的 Step 5 给出两条部署路径:

从官方模板一键部署

README 提供了 Vercel "Deploy" 按钮链接,点击后会在你的 Git 账号中复制一份 starter 项目并立即部署,同时引导你填入 NEXT_PUBLIC_BUTTER_CMS_API_KEY 环境变量,形成完整的内容工作流。

从仓库文件看,一键部署所需的元数据就写在 app.json 中:

{
  "name": "ButterCMS Next.js Starter Project",
  "description": "Drop-in proof-of-concept NextJs app, fully integrated with your ButterCMS account.",
  "repository": "https://github.com/ButterCMS/nextjs-starter-buttercms",
  "keywords": ["Next.js", "buttercms", "cms", "blog"],
  "buildpacks": [{ "url": "heroku/nodejs" }],
  "env": {
    "NEXT_PUBLIC_BUTTER_CMS_API_KEY": {
      "description": "The API token of your ButterCMS account",
      "value": ""
    }
  }
}

env 段声明了部署时必须提供的 NEXT_PUBLIC_BUTTER_CMS_API_KEY(值为空,需部署者填入),这是部署清单与环境变量的直接对应关系。

部署自己的本地项目

对已做修改的本地项目,将其推送到 GitHub / GitLab / Bitbucket 并导入 Vercel。重要:导入时务必在 Vercel 控制台的 Environment Variables 中配置与本地 .env.local 一致的变量(至少包括 NEXT_PUBLIC_BUTTER_CMS_API_KEY,以及按需的 PREVIEW),否则线上会触发上文所述的 /missing-token 重定向,所有页面都落到提示页。

小结:该示例可借鉴的工程模式

examples/cms-buttercms 为样本,CMS 驱动型 Next.js 站点可提炼出以下可复用模式:

  • 单一 API 封装层:所有 CMS 调用集中在 lib/api.js,页面层只消费函数而非 SDK,便于替换数据源;
  • 静态生成为主、按需降级:列表/详情用 getStaticPaths(fallback: true) + getStaticProps,高频更新的分类页用 revalidate: 1 的 ISR,不可枚举的搜索用 getServerSideProps
  • 缺凭据友好:环境变量守卫 + 条件 redirects + missing-token 提示页,让项目在无 Key 状态下可运行、可诊断;
  • CMS 内容容错:API 失败时页面降级为空数据或 404,未知落地页区块类型回退到占位组件;
  • 预览模式默认开启PREVIEW 未设置即视为草稿可见,降低本地调试成本。

除本示例外,同一目录下的其他 CMS 集成可作横向参考,例如 cms-agilitycmscms-contentfulcms-datocmscms-ghostcms-prismiccms-sanitycms-storyblokcms-wordpress 以及 blog-starter

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