Content sources
Use Strapi to manage a documentation website
Editors write and publish in Strapi, readers get a static docs site, and every publish starts a fresh build, with drafts kept off production.
By Hayden Bleasel10 min read

Point Blume's strapi() source at a Strapi content type. Editors write and publish in Strapi, and Blume reads the published entries through Strapi's REST API when the site builds, turning each one into a static page with its own URL, sidebar entry, and search entry. The docs site is plain HTML: it doesn't call Strapi when someone reads a page. A Strapi webhook starts a new build whenever an editor publishes, so a change reaches readers when that build finishes, not the moment they click Publish.
By the end of this guide you have a Doc content type in Strapi 5, a Blume site that serves those entries under /guides beside local Markdown pages, drafts kept off production but reviewable on a protected preview, and a rebuild on every publish. The steps were checked against Strapi 5.55.1. Blume also reads Strapi 4's response shape, but the admin steps here are Strapi 5's.
Skip the CMS if only developers write your docs: Blume's default is a folder of Markdown in Git. If your writers already live in Notion, publish from a Notion database instead. And if content must change the instant it's published, or differ per reader, you need a frontend that queries Strapi on every request, which Blume doesn't do.
Define a docs content type
If you don't have a Strapi project yet, create one with its default SQLite database and start it:
npx create-strapi@5.55.1 acme-cms --non-interactive --skip-cloud
cd acme-cms
npm run developOpen http://localhost:1337/admin and create the first administrator. In the Content-Type Builder, choose Create new collection type, name it Doc, and add four fields:
| Field | Type | What Blume does with it |
|---|---|---|
title | Text (Short text), required | The page title and sidebar label |
description | Text (Long text) | The description under the title, and for search engines |
slug | UID, with Attached field set to title | The page's path under the prefix |
content | Rich text (Blocks) | The page body |
Those are the names Blume looks for by default, so nothing needs mapping. Leave Draft & Publish on: it's what keeps unfinished pages off the site. The builder saves the type as a schema file. Commit it, because the Content-Type Builder only edits types while Strapi runs with strapi develop, so this file is how the type reaches your production Strapi:
{
"kind": "collectionType",
"collectionName": "docs",
"info": {
"singularName": "doc",
"pluralName": "docs",
"displayName": "Doc"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": { "type": "string", "required": true },
"description": { "type": "text" },
"slug": { "type": "uid", "targetField": "title", "required": true },
"content": { "type": "blocks" }
}
}Add content and a read-only token
In the Content Manager, create a Doc titled Authenticate with an API key, with the slug authentication. In its content, add a paragraph, a Heading 2 called Create a key, an image from the Media Library with alternative text, and a code block. Publish it. Then create a second Doc, Send your first message, with the slug send-your-first-message, and save it without publishing.
Blume authenticates with an API token. Go to Settings, then Global Settings, then API Tokens, and choose Create new API Token. Set Token type to Read-only, pick a Token duration, save, and copy the token. Check what Strapi returns:
export STRAPI_API_TOKEN=paste-your-token-here
curl -H "Authorization: Bearer $STRAPI_API_TOKEN" http://localhost:1337/api/docsStrapi 5.55.1 answers with this, trimmed to the parts that matter:
{
"data": [
{
"id": 2,
"documentId": "zlm1bcbg70ln4rr8sa05ms78",
"title": "Authenticate with an API key",
"description": "Create an API key and send it with every request.",
"slug": "authentication",
"content": [
{
"type": "heading",
"level": 2,
"children": [{ "type": "text", "text": "Create a key" }]
},
{
"type": "image",
"image": {
"name": "api-key-settings.png",
"alternativeText": "The API keys page in Acme settings",
"url": "/uploads/api_key_settings_15ff683d25.png",
"mime": "image/png",
"provider": "local"
},
"children": [{ "type": "text", "text": "" }]
}
],
"createdAt": "2026-09-27T23:48:03.735Z",
"updatedAt": "2026-09-27T23:48:03.735Z",
"publishedAt": "2026-09-27T23:48:03.738Z"
}
],
"meta": {
"pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 1 }
}
}Three things to notice. Only the published Doc is there, because the REST API returns published versions unless asked for drafts. The fields sit at the top level beside documentId; Strapi 4 wraps them in attributes, which Blume flattens. And the image block carries its file's URL and alt text inside the content field, so there's nothing to populate for it.
Add the Strapi source
In a new folder, start a Blume project:
npx blume init acme-docsWhen it asks where your content lives, pick both filesystem and strapi. That keeps a local docs/ folder for pages you'd rather write in Markdown, like the home page. The Strapi source needs no extra package. Point it at your content type:
import { defineConfig } from "blume";
import { filesystem, strapi } from "blume/sources";
export default defineConfig({
title: "Acme Docs",
content: {
sources: [
filesystem({ root: "docs" }),
strapi({
url: process.env.STRAPI_URL ?? "http://localhost:1337",
contentType: "docs",
prefix: "guides",
}),
],
},
});url is Strapi's origin: Blume adds /api itself. contentType is the plural API ID, and every entry is served under prefix. If your fields have other names, or you used a Rich text (Markdown) field instead of Blocks, map them with fields, for example fields: { body: "markdown" }. The keys are title, description, slug, body, and lastModified (which defaults to updatedAt).
The source reads its token from STRAPI_API_TOKEN. Blume loads .env.local for you, so put it there and keep the file out of Git:
echo "STRAPI_API_TOKEN=paste-your-token-here" >> .env.local
echo ".env.local" >> .gitignoreCheck the imported pages
Run npx blume dev and open /guides/authentication. Blume turned the entry into a Markdown page like this, trimmed here:
---
title: Authenticate with an API key
description: Create an API key and send it with every request.
---
Every request to the Acme API needs an **API key**. Create one in [your settings](https://app.acme.example/settings/api-keys).
## Create a key
Paragraphs, headings, nested lists, quotes, code blocks with their language, links, images, and bold, italic, strikethrough, and inline code all carry over. Underline doesn't: the text stays, plain. The title is already the page's top heading, so write sections with Heading 2 and Heading 3. The Blocks editor has no tables, callouts, or tabs, and Strapi pages render as plain Markdown, so a page that needs components belongs in an MDX file in docs/.
Strapi pages sit in a Guides group in the sidebar, sorted by title, since Blume reads no order field from Strapi. To set the order, add folder meta for the prefix, listing slugs:
import { defineMeta } from "blume";
export default defineMeta({
pages: ["authentication", "send-your-first-message"],
});The dev server fetches Strapi once and then serves that snapshot, even across restarts. After an edit in Strapi, run npx blume sync and the running server reloads. To pick up edits on their own while you work, set pollInterval (in seconds) on the source. It only affects the dev server.
Make images work on a static site
With Strapi's default local upload provider, an image's URL is a path like /uploads/api_key_settings_15ff683d25.png. Blume resolves it against url, as you saw above, so the published page loads the image from your Strapi server. That's the one part of the page that still depends on Strapi, and readers' browsers must be able to reach it. You have two ways to remove that dependency:
- An upload provider. Store media in a bucket or CDN with a Strapi upload provider. Its URLs are already absolute, and Blume passes them through.
- Build-time optimization. Authorize Strapi's host with
image: { domains: ["cms.acme.example"] }inblume.config.ts, and Blume downloads and optimizes those images when it builds, like local ones.
An image block is a copy of the file's details from when it was inserted. Changing the alt text in the Media Library later doesn't change the page: insert the image again in the entry and republish.
Markdown fields work differently. Blume passes their text through as written and doesn't resolve relative /uploads/ paths in them, so those images break. Strapi's Markdown editor inserts media as absolute URLs on the server the admin talks to, so an image added while you work on a local Strapi points at localhost. Check image URLs in Markdown fields before you publish.
You don't need to set populate for any of this. Blume sends populate=* by default, which fills media and relation fields, and a Blocks field needs none. Strapi 5 rejects a Blocks field's name as a populate value with a 400.
Preview drafts
Start the dev server in preview mode:
npx blume dev --previewBlume now asks Strapi 5 for drafts too, so Send your first message appears, and a published Doc with unpublished edits shows its latest draft. Without the flag, Blume requests only published entries, so drafts never reach a production build. After more edits in Strapi, run npx blume sync --preview. Keep the flag: a plain blume sync regenerates the site from published content. Preview and published content are cached separately, so a production build never falls back to drafts.
Deploy with protected previews
The site builds to static files. This guide uses Vercel, where production can run the normal build and preview deployments can run the preview build behind a sign-in, so editors 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 Blume 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
STRAPI_API_TOKEN, andSTRAPI_URLset to your production Strapi, likehttps://cms.acme.example, for Production and Preview. - Under Deployment Protection, turn on Vercel Authentication with Standard Protection, which protects every deployment except your production domains.
The build, not the reader, calls Strapi, so the token never reaches the browser. It also means Vercel's build machines must reach your Strapi: one on localhost or a private network can't be read from there. In that case, build inside your network and upload dist/.
To give editors 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 Strapi, go to Settings, then Global Settings, then Webhooks, and choose Create new webhook:
- Name it Rebuild docs, and paste the deploy hook as its URL.
- Under Events, in the Entry row, check Publish, Unpublish, and Delete.
- Save, then choose Trigger to send a test request. A new deployment should start in Vercel.
Strapi sends a POST with a JSON description of the entry, and Vercel ignores the body. Publishing, unpublishing, or deleting now starts a build, and saving a draft doesn't: leave Update unchecked, since every draft save fires it. Webhooks can't be limited to one content type, so a publish anywhere in Strapi starts a docs build. 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
The build fails with 401 Unauthorized or 403 Forbidden
BLUME_SOURCE_FETCH_FAILED quotes Strapi's status. The token isn't set where the build runs, has expired, or is a Custom token without find on Doc. Blume fetches before it checks for missing variables, so on a fresh build this error comes without a BLUME_MISSING_SECRET warning. A changed variable only applies to new builds, so redeploy.
The build fails with 404 Not Found
Check contentType is the plural API ID (docs, not doc), and that url doesn't end in /api, since Blume adds it.
No Strapi pages show up
Nothing is published. Production builds only read published entries, so run the curl command above: if data is empty, publish in Strapi. In dev, run npx blume sync too.
Images are broken
Open the image URL on its own. A relative /uploads/ path from a Markdown field, a localhost URL, or a Strapi that isn't publicly reachable each breaks it. Fix the URL in Strapi, or move media to an upload provider.
A published change isn't on the site
Check the webhook has the Publish event and that its Trigger test starts a Vercel deployment. If the deployment ran, look at its build log for a Strapi error.
The site shows old content with a warning
BLUME_SOURCE_OFFLINE means the fetch failed and Blume served the last snapshot it had. 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 Strapi
Pick strapi when init asks where your content lives, then point the source at your docs content type.
npx blume initA step here not working for you? Report a broken step.