Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

Content sources

Publish developer documentation from Contentful

Model docs pages and code blocks in Contentful, review drafts with a preview token, and publish a static docs site that rebuilds whenever an editor publishes.

By 10 min read

To publish developer documentation from Contentful, create a content type for docs pages with a title, a slug, a description, and a rich text body, then point Blume's Contentful source at that content type. Blume reads your published entries through the Content Delivery API when the site builds, turns each body into Markdown, and serves the pages beside any Markdown files in your repository. Run the same project with --preview and it reads drafts through the Content Preview API instead.

By the end, your writers draft and publish in Contentful, drafts can be reviewed locally or on a protected preview deployment before they ship, code samples render as highlighted code, and publishing an entry rebuilds the live site.

Blume imports content at build time, so there's no live preview pane in Contentful's editor: a change shows up after a sync or a rebuild. The source only calls Contentful's global API hosts and has no option to change them, so it doesn't fit a space on Contentful's EU data residency, which is served from separate hosts like preview.eu.contentful.com. And if everyone who writes your docs works in Git, plain Markdown files are simpler: that's Blume's default, with no CMS at all.

Model technical documentation in Contentful

Blume reads one content type per source and maps four fields by ID: title, description, slug, and body. Rich text has no code block node, so this model adds a second content type, codeBlock, that writers embed in a body wherever a sample belongs.

Keep the model in your repository as a migration script, so it's reviewed like code and can recreate the model in any space or environment. Save this as migrations/01-docs-model.cjs. The .cjs extension matters: blume init makes the project an ES module package, and the Contentful CLI loads migrations with require.

module.exports = function (migration) {
  const codeBlock = migration
    .createContentType("codeBlock")
    .name("Code block")
    .displayField("title");
  codeBlock.createField("title").name("Title").type("Symbol").required(true);
  codeBlock
    .createField("language")
    .name("Language")
    .type("Symbol")
    .required(true)
    .validations([{ in: ["bash", "json", "yaml", "ts", "js", "python", "go"] }]);
  codeBlock.createField("code").name("Code").type("Text").required(true);
  codeBlock.changeFieldControl("language", "builtin", "dropdown");
  codeBlock.changeFieldControl("code", "builtin", "multipleLine");

  const docPage = migration
    .createContentType("docPage")
    .name("Doc page")
    .displayField("title");
  docPage.createField("title").name("Title").type("Symbol").required(true);
  docPage
    .createField("slug")
    .name("Slug")
    .type("Symbol")
    .required(true)
    .validations([
      { unique: true },
      { regexp: { pattern: "^[a-z0-9-]+(/[a-z0-9-]+)*$" } },
    ]);
  docPage.createField("description").name("Description").type("Symbol");
  docPage
    .createField("body")
    .name("Body")
    .type("RichText")
    .required(true)
    .validations([
      {
        enabledNodeTypes: [
          "heading-2",
          "heading-3",
          "heading-4",
          "ordered-list",
          "unordered-list",
          "hr",
          "blockquote",
          "table",
          "hyperlink",
          "asset-hyperlink",
          "embedded-asset-block",
          "embedded-entry-block",
        ],
      },
      { enabledMarks: ["bold", "italic", "code", "strikethrough"] },
      {
        nodes: {
          "embedded-entry-block": [{ linkContentType: ["codeBlock"] }],
        },
      },
    ]);
  docPage.changeFieldControl("slug", "builtin", "slugEditor");
};

The body's validations are the important part. They leave out Heading 1, because the title is already the page's top heading, and they leave out links to entries and inline entries, which don't become working links or content on the site (more on that below). Embedded entries are limited to code blocks.

Sign in to the Contentful CLI, which stores a management token in your home directory, then run the migration against your space:

npx contentful-cli@4.0.10 login
npx contentful-cli@4.0.10 space migration --space-id your-space-id migrations/01-docs-model.cjs

The CLI shows the planned changes and asks before applying them. Running the script against another space or environment recreates the model there. To export the model a space holds as JSON, leaving out entries, assets, roles, and webhooks:

npx contentful-cli@4.0.10 space export --space-id your-space-id \
  --skip-content --skip-roles --skip-webhooks --content-file docs-model.json

space import, with another --space-id and the same --content-file, loads it into another space.

Create delivery and preview keys

Blume reads published content with a Content Delivery API token and drafts with a Content Preview API token. They aren't interchangeable: the Preview API rejects delivery tokens, which keeps unpublished content from leaking through a production key.

In Contentful, open Settings then API keys, and add an API key. Check that it has access to the environment you read (master by default). Copy three values from it: the Space ID, the Content Delivery API access token, and the Content Preview API access token. Put both tokens in .env.local, which Blume loads for you, and keep the file out of Git:

cat >> .env.local <<'EOF'
CONTENTFUL_ACCESS_TOKEN=paste-the-delivery-token
CONTENTFUL_PREVIEW_TOKEN=paste-the-preview-token
EOF
echo ".env.local" >> .gitignore

Connect Contentful to Blume

In an empty folder, run npx blume init. When it asks where your content lives, pick both filesystem and contentful: the local docs/ folder holds pages you'd rather keep in Git, like the home page. The Contentful source calls the REST API directly, so there's no SDK to install. Edit blume.config.ts so it reads:

import { defineConfig } from "blume";
import { contentful, filesystem } from "blume/sources";

export default defineConfig({
  title: "Acme Docs",
  content: {
    sources: [
      filesystem({ root: "docs" }),
      contentful({
        space: "your-space-id",
        contentType: "docPage",
        prefix: "guides",
      }),
    ],
  },
});

Publish a doc page or two in Contentful, then run npx blume dev. Each entry is served at its slug under the prefix, so install becomes /guides/install. A slug with slashes nests, and the slug index becomes the landing page at /guides. The pages sit in a sidebar group of their own, sorted by title, since the source has no order field. To read a locale other than your space's default, set locale.

How rich text maps to docs pages

Blume lowers each body to Markdown, escaping what writers type so a { or < in prose renders as written. Most of the editor maps across, but not every relationship becomes a link:

In the rich text editorOn the docs page
Headings, marks, lists, quotes, rules, tablesThe same, in Markdown. A table's first row becomes its header.
A link to a URLA link. A path like /guides/quickstart stays on the site.
A link to an assetA link to the file on Contentful's CDN
An embedded imageAn image loaded from Contentful's CDN, with the asset's description (or its title) as alt text
An embedded code block entryAn HTML comment, so nothing visible, until you add a serializer
A link to another entryThe link's text only, with no link

That last row is why the model disables entry links. The source doesn't know which route another entry lives at, so it keeps the words and drops the link. To link between docs pages, writers use an ordinary link to the page's path, like /guides/quickstart, and npx blume validate reports any that point at a page that doesn't exist.

Turn code block entries into code

An embedded entry becomes content through a serializer: a function, keyed by content type ID, that returns Markdown for the entry. Serializers are an option of the engine behind contentful(), so construct it directly and pass it to custom(). Replace the config with:

import { defineConfig } from "blume";
import { custom, filesystem } from "blume/sources";
import { contentfulSource } from "blume/sources/contentful.ts";

export default defineConfig({
  title: "Acme Docs",
  content: {
    sources: [
      filesystem({ root: "docs" }),
      custom(
        contentfulSource({
          name: "guides",
          prefix: "guides",
          space: "your-space-id",
          contentType: "docPage",
          serializers: {
            // Keyed by content type ID; returns Markdown for the entry.
            codeBlock: (entry) => {
              const { code = "", language = "" } = entry.fields as {
                code?: string;
                language?: string;
              };
              // A fence longer than any run of backticks in the code.
              const runs = code.match(/`+/g) ?? [];
              const fence = "`".repeat(
                Math.max(3, ...runs.map((run) => run.length + 1))
              );
              return [fence + language, code, fence].join("\n");
            },
          },
        })
      ),
    ],
  },
});

Each code block now renders as a highlighted fence in its language. With a serializer set, the source writes its pages as MDX, and it still reads drafts under --preview and caches like the built-in adapter. One difference: custom() declares no secrets, so Blume never warns about a missing CONTENTFUL_ACCESS_TOKEN. Without it, the fetch fails with a 401.

Preview drafts

Contentful tracks three states that matter here. A draft has never been published. A changed entry is published but has edits that aren't. A published entry matches what's live. Set up one of each to see how Blume treats them:

  1. Publish a doc page with the slug install.
  2. Create a page with the slug webhooks and don't publish it.
  3. Open the Install page, embed a new code block without publishing that code block, and edit the page's description without publishing the page.

The dev server serves a snapshot of Contentful from its last fetch, so pull the latest published content first, then start it:

npx blume sync
npx blume dev

The Install page shows its published version, without the new code block, and /guides/webhooks is a 404. Stop the server and start it again with drafts:

npx blume dev --preview

Now the Webhooks page is in the sidebar, and the Install page shows the new description and code block:

In Contentfulblume dev and blume buildWith --preview
PublishedShownShown
ChangedThe last published versionThe latest edits
DraftMissingShown
An unpublished code block or image on a published pageLeft out of the pageShown

After more edits in Contentful, pull them into the running server with npx blume sync --preview. Keep the flag: a plain blume sync regenerates the site from published content. To refresh on its own, set pollInterval (in seconds) on the source. Preview and published content are cached separately, so a production build never falls back to drafts.

Then check that a production build leaves the drafts out:

npx blume build
npx blume preview

The Webhooks page is a 404, and the Install page still lacks the new code block. Publish the code block and the page in Contentful, build again, and both appear. The Delivery API only resolves published entries and assets, so anything a page embeds has to be published too.

Deploy with protected previews

Blume reads Contentful while the site builds. This guide uses Vercel, where production can run the normal build and preview deployments can run the preview build behind a sign-in, so writers review drafts without running Blume themselves. Add a script that picks between them using Vercel's VERCEL_ENV variable:

{
  "scripts": {
    "dev": "blume dev",
    "build": "blume build",
    "build:vercel": "if [ \"$VERCEL_ENV\" = \"preview\" ]; then blume build --preview; else blume build; fi"
  }
}
  1. Push the project to a GitHub repository and import it in Vercel.
  2. Set the build command to npm run build:vercel, the output directory to dist, and Node.js to 22 or later.
  3. Under Environment Variables, add CONTENTFUL_ACCESS_TOKEN for Production and CONTENTFUL_PREVIEW_TOKEN for Preview. Each build reads only the token its mode needs.
  4. Under Deployment Protection, turn on Vercel Authentication with Standard Protection, which protects every deployment except your production domains.

The tokens are read while the site builds, so they never reach readers. To give writers a review copy with a fixed address, push a branch named preview. Vercel deploys it as a preview deployment, and its branch URL stays the same from one build to the next. For other hosts, the private docs section lists their protection options.

Rebuild when an editor publishes

Production only changes when it rebuilds. In your Vercel project's settings, open Git, and under Deploy Hooks create a hook for your production branch. Vercel gives you a URL that starts a build when it receives a POST request. Anyone with its URL can start a build, so treat it like a password.

In Contentful, open Settings then Webhooks, add a webhook that sends a POST to the hook's URL, and limit its triggers to publish and unpublish events for entries and assets. Don't filter it to the docPage type: republishing a code block or an image has to rebuild the pages that embed it.

Publishing or unpublishing now starts a build, and saving a draft doesn't. The site keeps serving the previous deployment until the build finishes, and a failed build leaves it in place.

A preview deployment shows drafts as they were when it built. To refresh it, create a second deploy hook for the preview branch and call it when a draft is ready for review, with curl -X POST "$PREVIEW_DEPLOY_HOOK". Calling it on every draft save would start a build per edit, and Vercel accepts at most 60 hook calls an hour per project.

Troubleshooting

--preview fails with "needs a Preview API token"

CONTENTFUL_PREVIEW_TOKEN isn't set where the command runs. Blume doesn't fall back to the delivery token, since the Preview API would reject it anyway. The error carries the code BLUME_SOURCE_MISCONFIGURED.

The build fails with 401 or 404

BLUME_SOURCE_FETCH_FAILED includes Contentful's status. A 401 means the token is missing or wrong where the build runs, or a delivery token went into CONTENTFUL_PREVIEW_TOKEN. A 404 means the space ID is wrong or the API key can't access the environment: give the key access to it in its settings. Blume fetches before it checks for missing variables, so on a fresh build this error comes without a BLUME_MISSING_SECRET warning.

A code block or image shows in preview but not on the live site

The page is published, but the entry or asset it embeds isn't. Publish it, and the next build includes it.

An embedded file shows as a broken image

Every embedded asset becomes an image, including a PDF or a ZIP. Embed only images, and link other files with a link to the asset instead.

A code block is missing even in preview

The serializer's key must match the embedded entry's content type ID exactly (codeBlock), and the source must be the custom(contentfulSource(...)) version, since the built-in contentful() takes no serializers.

Dev still shows old pages after a config change

A custom() source keeps one snapshot per mode, whatever its options, so after changing its content type, fields, or query parameters, the dev server keeps serving the old snapshot. Run npx blume sync (with --preview if you're previewing) to fetch afresh.

The site shows old content with a warning

BLUME_SOURCE_OFFLINE means the fetch failed and Blume served the last snapshot it had, with the reason in the warning. A fresh build machine has no snapshot, so there the same failure stops the build and your last deployment stays live.

Next step

Connect your Contentful space

Pick contentful when init asks where your content lives, then set your space ID and content type in the config.

npx blume init
Read the Contentful source docs

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init