Next.js 结合 ButterCMS 构建静态生成内容站的完整实战:cms-buttercms 示例解析
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
本文以 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.0、buttercms: ^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即为完整依赖清单(buttercms、camelcase-keys、date-fns、bootstrap、tiny-slider、sharp等)。
方式二:通过 Create-Next-App 引导
使用npx(npm)、yarn或pnpm执行 create-next-app 并指定示例名,即可一键拉取该示例模板:
npx create-next-app --example cms-buttercms cms-buttercms-appyarn create next-app --example cms-buttercms cms-buttercms-apppnpm 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=false时preview才会为0;PREVIEW=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.js 的MyApp.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 = 10(defaultPostCount),支持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].js 的getStaticPaths在构建期列出所有落地页 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/image(layout="fill"+objectFit="cover")。
3. ISR 增量再验证:分类页
pages/blog/category/[slug].js 在getStaticProps中返回了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.js 的getInitialProps中获取并注入到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.events的routeChangeStart/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 描述了本示例最有实用价值的功能之一:草稿内容预览。行为与开启方式如下:
- 在 ButterCMS 后台新建一篇博客,标题设为
Draft Post Test,填入任意正文与摘要; - 关键:不要点击 Publish,而是点击Save Draft;
- 先在
.env.local中设置PREVIEW=false并重启本地开发服务器,访问博客列表(如http://localhost:3000/#blog)——此时看不到这篇草稿,因为其状态尚未变为published; - 随后从
.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-agilitycms、cms-contentful、cms-datocms、cms-ghost、cms-prismic、cms-sanity、cms-storyblok、cms-wordpress 以及 blog-starter。
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考