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 Hayden Bleasel10 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. A space on Contentful's EU data residency is served from separate hosts, so set the source's host to cdn.eu.contentful.com and its previewHost to 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",
"entry-hyperlink",
"asset-hyperlink",
"embedded-asset-block",
"embedded-entry-block",
],
},
{ enabledMarks: ["bold", "italic", "code", "strikethrough"] },
{
nodes: {
"entry-hyperlink": [{ linkContentType: ["docPage"] }],
"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 inline entries, which don't become content on the site. Links to entries are limited to doc pages, and embedded entries to code blocks (more on both below).
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.cjsThe 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.jsonspace 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. The blume init step below adds that file to .gitignore, so it stays out of Git:
cat >> .env.local <<'EOF'
CONTENTFUL_ACCESS_TOKEN=paste-the-delivery-token
CONTENTFUL_PREVIEW_TOKEN=paste-the-preview-token
EOFConnect Contentful to Blume
In the folder that holds migrations/ and .env.local, 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; to order them, add a number field to Doc page and name its ID in fields.order. 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 editor | On the docs page |
|---|---|
| Headings, marks, lists, quotes, rules, tables | The same, in Markdown. A table's first row becomes its header. |
| A link to a URL | A link. A path like /guides/quickstart stays on the site. |
| A link to an asset | A link to the file on Contentful's CDN |
| An embedded image | An image loaded from Contentful's CDN, with the asset's description (or its title) as alt text |
| Any other embedded file, like a PDF | A link to the file, labeled with its title or file name |
| An embedded code block entry | An HTML comment, so nothing visible, until you add a serializer |
| A link to another doc page entry | A link to that page, at the route Blume builds it at |
| A link to an entry of any other type | The link's text only, with no link |
Those last two rows are why the model limits entry links to doc pages. Only an entry of the source's own content type becomes a page, so only a link to one has somewhere to go. Blume builds the link from the linked page's slug, so install becomes /guides/install. To link to a page from the docs/ folder, writers use an ordinary link to its path, 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 dev and blume build don't warn about an unset CONTENTFUL_ACCESS_TOKEN up front. The engine still checks: without the token, it stops before its first request with BLUME_MISSING_SECRET.
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:
- Publish a doc page with the slug
install. - Create a page with the slug
webhooksand don't publish it. - 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 devThe 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 --previewNow the Webhooks page is in the sidebar, and the Install page shows the new description and code block:
| In Contentful | blume dev and blume build | With --preview |
|---|---|---|
| Published | Shown | Shown |
| Changed | The last published version | The latest edits |
| Draft | Missing | Shown |
| An unpublished code block or image on a published page | Left out of the page | Shown |
| A link to a draft page on a published page | Its text only | A link |
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 previewThe 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 or links to has to be published too, and the build warns BLUME_SOURCE_UNRESOLVED_LINK for each one that isn't.
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"
}
}- Push the project to a GitHub repository and import it in Vercel.
- Set the build command to
npm run build:vercel, the output directory todist, and Node.js to 22 or later. - Under Environment Variables, add
CONTENTFUL_ACCESS_TOKENfor Production andCONTENTFUL_PREVIEW_TOKENfor Preview. Each build reads only the token its mode needs. - 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 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. A missing CONTENTFUL_ACCESS_TOKEN never gets as far as a request: the source stops first with BLUME_MISSING_SECRET.
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, and the build warns BLUME_SOURCE_UNRESOLVED_LINK naming its ID. Publish it, and the next build includes it.
A link to another doc page is plain text
If the build warns BLUME_SOURCE_UNRESOLVED_LINK naming the linked entry's ID, that page isn't published: publish it, and the next build links it. With no warning, the entry isn't one the source builds as a page, such as an entry of another content type.
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 initA step here not working for you? Report a broken step.