首页
/ Next.js × Enterspeed:构建静态生成博客的完整实战指南(SSG + Preview Mode)

Next.js × Enterspeed:构建静态生成博客的完整实战指南(SSG + Preview Mode)

2026-09-06 15:32:49作者:庞眉杨Will

本篇指南基于 Next.js 官方仓库中的 cms-enterspeed 示例(examples/cms-enterspeed/README.md),系统讲解如何使用 Next.js 的静态生成(Static Generation)能力,结合 Enterspeed 这一 CMS 平台完成内容摄取(Ingest)、Schema 视图定义、数据拉取与博客渲染的全流程。读完本文,你将掌握:如何向 Enterspeed 的 Ingest API 提交 blog / blogPost 两类内容、如何编写 Blog listBlog post 两个 Schema、如何用 getStaticProps / getStaticPaths 拉取 Delivery API 数据,以及如何通过 Next.js 的 Draft Mode 实现发布前预览。

示例定位与技术栈

该示例是一个"静态生成的博客",Enterspeed 作为数据源。核心依赖非常精简,见 package.json

依赖 版本 用途
next latest 框架主体,提供 SSG 与 Draft Mode
react / react-dom ^18.2.0 UI 渲染
date-fns 2.28.0 文章日期格式化
classnames 2.3.1 组件类名组合
tailwindcss(dev) ^3.0.15 样式,配置见 tailwind.config.js

仓库中还提供线上演示站点(next-blog-demo.enterspeed.com)供参考效果。

目录结构一览(均位于 examples/cms-enterspeed/ 下):

快速开始:用 create-next-app 引导项目

按 README 说明,使用 create-next-app 引导示例:

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

随后进入 enterspeed-app 目录,按下面的配置步骤操作即可。

第 1 步:配置 Enterspeed 账号

在开始之前需要一个 Enterspeed 站点(到 Enterspeed 官网注册即可)。创建账号后,你需要依次完成:

  1. 创建一个 tenant(租户);
  2. 创建一个 data source(数据源);
  3. 创建一个 environment(环境);
  4. 创建并配置 domains(域名);
  5. 创建一个 environment client(环境客户端)。

其中第 5 步生成的 API Key 就是我们后续写入 ENTERSPEED_PRODUCTION_ENVIRONMENT_API_KEY 环境变量的值。这个 Key 会作为 X-Api-Key 请求头随每次 Delivery API 调用发出——这一点可以直接在 lib/api.ts 中印证:

const call = async (query: string, preview: boolean) => {
  const url = `https://delivery.enterspeed.com/v1?${query}`;
  const response = await fetch(new Request(url), {
    headers: {
      "Content-Type": "application/json",
      "X-Api-Key": preview ? PREVIEW_API_KEY : PRODUCTION_API_KEY,
    },
  });
  return response.json();
};

可以看到:正式数据与预览数据分别使用 ENTERSPEED_PRODUCTION_ENVIRONMENT_API_KEYENTERSPEED_PREVIEW_ENVIRONMENT_API_KEY 两个 Key,通过 preview 布尔参数切换,这正是 Preview Mode 双数据源机制的落点。

第 2 步:向 Enterspeed 摄取内容(Ingest API)

内容需要从现有 CMS / PIM 等来源进入 Enterspeed,可以使用 Enterspeed 提供的集成,或者直接用 Ingest API。示例采用 curl 请求把演示数据灌入 Enterspeed。

需要摄取两种内容类型

  • blog 类型:作为博客文章的集合 / 父节点;
  • blogPost 类型:具体的博客文章实体,通过 originParentId 挂到 blog 节点之下。

先创建 blog 类型(注意 /ingest/v2/1 中的 1 即该实体在 Enterspeed 中的 ID):

curl --location --request POST 'https://api.enterspeed.com/ingest/v2/1' \
--header 'X-Api-Key: [YOUR DATA SOURCE API KEY]' \
--header 'Content-Type: application/json' \
--data-raw '{
  "type": "blog",
  "url": "/blog"
}'

再摄取一篇具体的博客文章(originParentId: "1" 指向上面创建的 blog 实体):

curl --location --request POST 'https://api.enterspeed.com/ingest/v2/2' \
--header 'X-Api-Key: [YOUR DATA SOURCE API KEY]' \
--header 'Content-Type: application/json' \
--data-raw '{
  "type": "blogPost",
  "url": "/preview-mode-for-static-generation",
  "originParentId": "1",
  "properties": {
      "title": "Preview Mode for Static Generation",
      "featuredImage": "https://res.cloudinary.com/enterspeed/image/upload/v1648804237/Next.js%20-%20Example%20With%20Enterspeed/cover5.webp",
      "date": "2022-04-01T01:07:42",
      "author": {
          "name": "Vercel Team",
          "avatar": {
              "url": "https://res.cloudinary.com/enterspeed/image/upload/v1648804719/Next.js%20-%20Example%20With%20Enterspeed/vercel-avatar.webp"
          }
      },
      "categories": ["Next.js", "Static Generation"],
      "excerpt": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Praesent elementum facilisis leo vel fringilla est ullamcorper eget. At imperdiet dui accumsan sit amet nulla facilisi morbi tempus.",
      "content": "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Praesent elementum facilisis leo vel fringilla est ullamcorper eget. At imperdiet dui accumsan sit amet nulla facilisi morbi tempus. Praesent elementum facilisis leo vel fringilla. Congue mauris rhoncus aenean vel. Egestas sed tempus urna et pharetra pharetra massa massa ultricies.</p><p>Venenatis cras sed felis eget velit. Consectetur libero id faucibus nisl tincidunt. Gravida in fermentum et sollicitudin ac orci phasellus egestas tellus. Volutpat consequat mauris nunc congue nisi vitae. Id aliquet risus feugiat in ante metus dictum at tempor. Sed blandit libero volutpat sed cras. Sed odio morbi quis commodo odio aenean sed adipiscing. Velit euismod in pellentesque massa placerat. Mi bibendum neque egestas congue quisque egestas diam in arcu. Nisi lacus sed viverra tellus in. Nibh cras pulvinar mattis nunc sed. Luctus accumsan tortor posuere ac ut consequat semper viverra. Fringilla ut morbi tincidunt augue interdum velit euismod.</p><h2>Lorem Ipsum</h2><p>Tristique senectus et netus et malesuada fames ac turpis. Ridiculus mus mauris vitae ultricies leo integer malesuada nunc vel. In mollis nunc sed id semper. Egestas tellus rutrum tellus pellentesque. Phasellus vestibulum lorem sed risus ultricies tristique nulla. Quis blandit turpis cursus in hac habitasse platea dictumst quisque. Eros donec ac odio tempor orci dapibus ultrices. Aliquam sem et tortor consequat id porta nibh. Adipiscing elit duis tristique sollicitudin nibh sit amet commodo nulla. Diam vulputate ut pharetra sit amet. Ut tellus elementum sagittis vitae et leo. Arcu non odio euismod lacinia at quis risus sed vulputate.</p>",
      "tags": ["SSG", "Preview"]
  }
}'

请求体中的 properties 字段即最终前端渲染所需的全部字段:titlefeaturedImagedateauthor(含头像)、categoriesexcerptcontent(HTML 字符串)、tags。这些字段与前端类型定义 types/postType.ts 完全对应:

type PostType = {
  url: string;
  title: string;
  featuredImage: string;
  date: string;
  author: AuthorType;
  excerpt: string;
  categories: string[];
  content: string;
  tags: string[];
};

第 3 步(原文档 Step 4):在 Enterspeed 中创建 Schema

内容摄取完成后,接下来是创建 Schema。Enterspeed 的 Schema 用于把原始数据转换成生成的视图(views),供前端应用直接消费——这是该示例区别于普通 REST API 型 CMS 的关键:前端不直接消费原始实体,而是消费 Schema 生成的、结构化的视图。

需要创建两个 Schema:Blog listBlog post。将下面的代码填入对应 Schema,保存并部署(deploy)后,Enterspeed 会基于已摄取的内容生成视图。

Blog list Schema

这个 Schema 的要点:sourceEntityTypes 指定 blog 类型实体作为源;route.handles 声明路由句柄 blogList(前端正是用它拉取);blogListItems 通过 $lookup 查找所有 originParentId 等于当前 blog 实体 originId 的文章,并映射出列表所需字段:

{
  "sourceEntityTypes": ["blog"],
  "route": {
    "handles": ["blogList"]
  },
  "properties": {
    "blogListItems": {
      "type": "array",
      "input": {
        "$lookup": {
          "operator": "equals",
          "sourceEntityProperty": "originParentId",
          "matchValue": "{originId}"
        }
      },
      "items": {
        "type": "object",
        "properties": {
          "url": "{item.url}",
          "title": "{item.properties.title}",
          "featuredImage": "{item.properties.featuredImage}",
          "date": "{item.properties.date}",
          "excerpt": "{item.properties.excerpt}",
          "author": {
            "type": "object",
            "properties": {
              "name": "{item.properties.author.name}",
              "avatar": {
                "type": "object",
                "properties": {
                  "url": "{item.properties.author.avatar.url}"
                }
              }
            }
          }
        }
      }
    }
  }
}

Blog post Schema

文章详情 Schema 的要点:以 blogPost 实体为源,route.url 直接取自实体的 {url}(因此文章 URL 为 /preview-mode-for-static-generation,构建出的页面即 /posts/preview-mode-for-static-generation);actions 中声明了 process 动作并关联 originParentId{p.xxx} 是属性字段的简写引用:

{
  "sourceEntityTypes": ["blogPost"],
  "route": {
    "url": "{url}"
  },
  "actions": [
    {
      "type": "process",
      "originId": {
        "$exp": "{originParentId}"
      }
    }
  ],
  "properties": {
    "url": "{url}",
    "type": "{type}",
    "title": "{p.title}",
    "featuredImage": "{p.featuredImage}",
    "date": "{p.date}",
    "author": {
      "type": "object",
      "properties": {
        "name": "{p.author.name}",
        "avatar": {
          "type": "object",
          "properties": {
            "url": "{p.author.avatar.url}"
          }
        }
      }
    },
    "categories": {
      "type": "array",
      "input": "{p.categories}",
      "items": {
        "type": "string",
        "value": "{item}"
      }
    },
    "tags": {
      "type": "array",
      "input": "{p.tags}",
      "items": {
        "type": "string",
        "value": "{item}"
      }
    },
    "content": "{p.content}"
  }
}

第 4 步(原文档 Step 5):配置环境变量

复制示例目录下的 .env.local.example.env.local(该文件被 Git 忽略):

cp .env.local.example .env.local

仓库中 .env.local.example 的内容为:

ENTERSPEED_PRODUCTION_ENVIRONMENT_API_KEY=

# Only required if you want to enable preview mode
# ENTERSPEED_PREVIEW_ENVIRONMENT_API_KEY=
# ENTERSPEED_PREVIEW_SECRET

随后在 .env.local 中把 ENTERSPEED_PRODUCTION_ENVIRONMENT_API_KEY 设置为在 Enterspeed 中创建的 Environment client API key。三个变量的职责如下:

变量 必需性 作用
ENTERSPEED_PRODUCTION_ENVIRONMENT_API_KEY 必需 生产环境客户端 Key,SSG 构建/请求时拉取已发布内容
ENTERSPEED_PREVIEW_ENVIRONMENT_API_KEY 仅预览模式需要 预览数据源的环境客户端 Key
ENTERSPEED_PREVIEW_SECRET 仅预览模式需要 进入 Preview Mode 的口令,任意随机字符串即可(建议 URL 友好)

第 5 步(原文档 Step 6):开发模式运行

npm install
npm run dev

# 或

yarn install
yarn dev

对应 package.json 中的脚本:devnextbuildnext buildstartnext start。启动后博客运行在 http://localhost:3000

前端如何消费 Enterspeed 视图:SSG 实现细节

首页 pages/index.tsxgetStaticProps 通过句柄 blogList 拉取列表视图:

export async function getStaticProps({ preview }: { preview: boolean }) {
  const data = await getByHandle("blogList", preview);

  return {
    props: {
      posts: data.blogListItems,
      preview: preview || null,
    },
  };
}

其中 getByHandle 的实现(lib/api.ts)会请求 https://delivery.enterspeed.com/v1?handle=blogList 并返回响应体中 views[handle],即前面 Blog list Schema 声明的 route.handles 与 Schema properties 的对应关系在这里闭环。

文章详情页 pages/posts/[slug].tsx 则展示了完整的 getStaticPaths + getStaticProps 组合:

export async function getStaticPaths({ preview }: { preview: boolean }) {
  const data = await getByHandle("blogList", preview);

  return {
    paths: data.blogListItems.map((post) => ({
      // Remove starting and ending slash from url
      params: { slug: post.url.replace(/^\/|\/$/g, "") },
    })),
    fallback: false,
  };
}

export async function getStaticProps({
  params,
  preview,
}: {
  params: Params;
  preview: boolean;
}) {
  // Adding starting slash to the URL again
  const data = await getByUrl(encodeURIComponent(`/${params.slug}`), preview);

  return {
    props: {
      post: data,
      preview: preview || null,
    },
  };
}

几个值得注意的实现细节:

  1. slug 与 URL 的换算getStaticPaths 中把 Enterspeed 返回的 post.url(如 /preview-mode-for-static-generation)去掉首尾斜杠作为动态段 sluggetStaticProps 中再补回前导斜杠并做 encodeURIComponent,交给 getByUrl 请求 ?url=... 查询。getByUrl 返回的是响应体中的 route 字段,即 Blog post Schema 按 route.url 匹配到的视图;
  2. fallback: false:未在构建时预渲染的 URL 直接 404,组件内还有兜底逻辑——router.isFallback 时显示 Loading…!post?.url 时渲染 ErrorPage statusCode={404}
  3. preview 透传:两个数据获取函数都接收 Next.js 注入的 preview(Draft Mode 激活状态),据此切换生产/预览 API Key,并把 preview: preview || null 传给组件控制 UI(如页头提示)。

页面样式方面,next.config.js 声明了 next/image 的远程图源白名单(演示图托管在 Cloudinary):

module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "res.cloudinary.com",
        port: "",
        pathname: "/my-account/**",
      },
    ],
  },
};

如果演示封面图换到其他 CDN,需要相应调整 hostnamepathname,否则 next/image 会拒绝加载。

第 6 步(原文档 Step 7,可选):配置 Preview Mode

此步骤可选。 大多数 CMS 都支持"发布前预览"。Enterspeed 的做法是:为预览数据单独建一个 data source,让 CMS 把草稿内容同时发送到这个预览数据源(官方例如在 Enterspeed Umbraco 集成中实现了该机制)。完成后:

  1. 基于预览数据源创建一个新的 environment client;
  2. 把新客户端的 API Key 填入 .env.localENTERSPEED_PREVIEW_ENVIRONMENT_API_KEY
  3. ENTERSPEED_PREVIEW_SECRET 设为任意随机字符串(建议 URL 友好)。

此时 .env.local 应为:

ENTERSPEED_PRODUCTION_ENVIRONMENT_API_KEY=

# Only required if you want to enable preview mode
ENTERSPEED_PREVIEW_ENVIRONMENT_API_KEY=
ENTERSPEED_PREVIEW_SECRET

重要:修改环境变量后需要重启 Next.js 服务器使其生效。

预览模式的源码机制

进入与退出 Preview Mode 分别由两个 API Route 完成。pages/api/preview.js

if (req.query.secret !== process.env.ENTERSPEED_PREVIEW_SECRET) {
  return res.status(401).json({ message: "Invalid token" });
}

res.setDraftMode({ enable: true });
res.redirect("/");

它校验 query 中的 secret 与环境变量一致后,调用 res.setDraftMode({ enable: true }) 写入 Draft Mode cookie,再重定向回首页。此后构建期 getStaticProps / getStaticPaths 中的 preview 参数变为 truelib/api.ts 自动改用预览 API Key 拉取草稿数据。

退出则由 pages/api/exit-preview.js 完成:res.setDraftMode({ enable: false }) 移除 cookie,并以 307 重定向回首页。

第 7 步(原文档 Step 8):体验 Preview Mode

直接访问 http://localhost:3000 时,你看到的仍是已发布内容。要看到预览(草稿)数据,访问:

http://localhost:3000/api/preview?secret=<secret>

其中 <secret> 即你在 ENTERSPEED_PREVIEW_SECRET 中设置的字符串。激活后即可在博客中看到草稿文章(如演示的 Preview Mode 文章)。退出预览模式访问:

http://localhost:3000/api/exit-preview

部署

可以将该应用部署到 Vercel。两种方式:

  1. 部署本地项目:把项目推送到 Git 仓库后在 Vercel 导入。注意:导入时务必在 Environment Variables 中配置与 .env.local 一致的三个变量;
  2. 使用模板一键部署:通过 Vercel 的 "Deploy with Vercel" 按钮克隆本示例部署,系统会提示填写 ENTERSPEED_PRODUCTION_ENVIRONMENT_API_KEY(连接 Enterspeed 所必需)。

相关示例

仓库 examples/ 目录下还有一系列同架构(SSG 博客)的 CMS 集成示例可对照参考,例如:

小结

cms-enterspeed 示例完整演示了"内容摄取 → Schema 视图 → SSG 渲染 → Draft Mode 预览"这条静态生成博客的闭环链路:Enterspeed 侧通过 Ingest API 与两个 Schema(Blog list 用句柄路由、Blog post 用 URL 路由)生成结构化视图;Next.js 侧通过 lib/api.ts 封装的两个函数 getByHandle / getByUrl 配合 getStaticProps / getStaticPaths 完成构建期渲染,并用双 API Key + setDraftMode 实现发布前预览。理解这套模式后,你可以把它迁移到任意提供"视图/查询接口"的 CMS 上,只需替换 lib/api.ts 中的数据获取实现。

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