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

Content sources

Build a documentation website with Payload CMS

Publish a Payload collection as a searchable docs site, with Lexical pages, images, and code blocks, draft previews, and a rebuild whenever an editor publishes.

By 13 min read

To turn a Payload collection into a docs site, point Blume's payload() source at it. On every build, Blume reads the collection's published documents through Payload's REST API, turns their Lexical rich text into Markdown, and builds static pages with a sidebar, search, and Markdown copies for agents. Editors keep writing in the Payload admin panel.

By the end of this guide, you have a Payload docs collection with drafts and code blocks, an API key that lets Blume read it, a protected preview of unpublished pages, and a production site that rebuilds whenever an editor publishes. The example is a knowledge base for Acme, a fictional API that sends email and SMS.

Payload 3 runs inside a Next.js app, so if you already render a Next.js frontend and need only a few help pages, querying Payload from that app is the smaller change. If your writers are happy with Markdown in Git, skip the CMS: Blume reads a folder of Markdown with no source config at all. This guide is for when docs belong in Payload beside the rest of your content, but readers need docs navigation, search, and a site you deploy on its own.

Define the docs collection

You need a Payload 3 app. To start one, run npx create-payload-app@3 -n cms -t blank and pick a database. The blank template comes with a Users collection and a Media collection for uploads, and Media is readable by anyone, which matters later.

Add a collection for the docs:

import type { CollectionConfig } from 'payload'
import {
  BlocksFeature,
  CodeBlock,
  HeadingFeature,
  lexicalEditor,
} from '@payloadcms/richtext-lexical'

export const Docs: CollectionConfig = {
  slug: 'docs',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'slug', '_status', 'updatedAt'],
  },
  access: {
    // Only signed-in users and API keys can read docs through the API.
    read: ({ req }) => Boolean(req.user),
  },
  versions: { drafts: true },
  fields: [
    { name: 'title', type: 'text', required: true },
    { name: 'slug', type: 'text', required: true, unique: true, index: true },
    { name: 'description', type: 'textarea' },
    {
      name: 'content',
      type: 'richText',
      editor: lexicalEditor({
        features: ({ defaultFeatures }) => [
          // Blume has no Markdown for relationship nodes, so don't offer them.
          ...defaultFeatures.filter((feature) => feature.key !== 'relationship'),
          // The page title is the h1, so the body starts at h2.
          HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
          BlocksFeature({
            blocks: [
              CodeBlock({
                defaultLanguage: 'ts',
                languages: { ts: 'TypeScript', bash: 'Shell', json: 'JSON' },
              }),
            ],
          }),
        ],
      }),
    },
  ],
}

Blume maps title, description, slug, content, and updatedAt by default, so these field names need no mapping; for other names, set the source's fields option. The read rule keeps the collection out of Payload's public API, so drafts can't leak through it, and the docs site becomes the only public copy. versions.drafts adds Payload's _status field and the Save Draft and Publish buttons. The editor keeps Payload's default features except relationships, which Blume can't render, and adds Payload's premade code block, whose block type is Code. Payload marks that block experimental, so check it when you upgrade Payload.

Then add a collection whose only job is to hold API keys. Nobody signs in as an integration, so it turns off email and password login:

import type { CollectionConfig } from 'payload'

export const Integrations: CollectionConfig = {
  slug: 'integrations',
  admin: { useAsTitle: 'name' },
  auth: {
    useAPIKey: true,
    disableLocalStrategy: true,
  },
  fields: [{ name: 'name', type: 'text', required: true }],
}

Register both in your config:

import { Docs } from './collections/Docs'
import { Integrations } from './collections/Integrations'

export default buildConfig({
  // ...everything else the template set
  collections: [Users, Integrations, Media, Docs],
})

Seed some content

This script uses Payload's Local API to create an image, two published pages, and a draft. Lexical stores rich text as JSON, so a few helpers build the nodes: a nested list, a checklist, an upload, a code block, and a link.

import config from '@payload-config'
import { getPayload } from 'payload'
import sharp from 'sharp'

const payload = await getPayload({ config })

// Lexical JSON helpers: a run of text, and an element node with children.
type LexicalNode = { type: string; version: number; [key: string]: unknown }
const BOLD = 1
const CODE = 16
const text = (value: string, format = 0): LexicalNode => ({
  type: 'text', text: value, format, detail: 0, mode: 'normal', style: '', version: 1,
})
const node = (type: string, children: LexicalNode[], extra: object = {}): LexicalNode => ({
  type, children, direction: 'ltr', format: '', indent: 0, version: 1, ...extra,
})
const item = (children: LexicalNode[], extra: object = {}) =>
  node('listitem', children, { value: 1, ...extra })
const body = (...children: LexicalNode[]) => ({
  root: { type: 'root', children, direction: 'ltr' as const, format: '' as const, indent: 0, version: 1 },
})

// A placeholder diagram, drawn in memory so the seed needs no files.
const png = await sharp({
  create: { width: 1200, height: 600, channels: 3, background: '#4f46e5' },
}).png().toBuffer()
const diagram = await payload.create({
  collection: 'media',
  data: { alt: 'A message moving from the API to the recipient' },
  file: { data: png, mimetype: 'image/png', name: 'message-flow.png', size: png.length },
})

await payload.create({
  collection: 'docs',
  data: {
    _status: 'published',
    title: 'Send your first message',
    slug: 'send-your-first-message',
    description: 'Send a transactional email with the Acme Messages API.',
    content: body(
      node('paragraph', [
        text('Every request needs an API key in the '),
        text('Authorization', CODE),
        text(' header.'),
      ]),
      node('heading', [text('Before you start')], { tag: 'h2' }),
      node('list', [
        item([text('Create an API key in the dashboard.')]),
        item([node('list', [item([text('Use a test key while you build.')])], {
          listType: 'bullet', tag: 'ul',
        })], { value: 2 }),
        item([text('Verify the domain you send from.')], { value: 2 }),
      ], { listType: 'number', start: 1, tag: 'ol' }),
      node('list', [
        item([text('API key created')], { checked: true }),
        item([text('Sender domain verified')], { checked: false }),
      ], { listType: 'check', tag: 'ul' }),
      { type: 'upload', relationTo: 'media', value: diagram.id, fields: {}, format: '', version: 3 },
      node('heading', [text('Send a request')], { tag: 'h2' }),
      {
        type: 'block', format: '', version: 2,
        fields: {
          blockType: 'Code', blockName: '', language: 'bash',
          code: 'curl -X POST https://api.acme.example/v1/messages -H "Authorization: Bearer $ACME_API_KEY" -d @message.json',
        },
      },
      node('quote', [
        text('Test keys never deliver.', BOLD),
        text(' Messages sent with them only show up in the dashboard.'),
      ]),
      node('paragraph', [
        text('Next, '),
        node('link', [text('reuse message bodies with templates')], {
          fields: { linkType: 'custom', url: '/guides/message-templates', newTab: false },
          version: 3,
        }),
        text('.'),
      ]),
    ),
  },
})

await payload.create({
  collection: 'docs',
  data: {
    _status: 'published',
    title: 'Use message templates',
    slug: 'message-templates',
    description: 'Reuse a message body with variables.',
    content: body(node('paragraph', [text('A template holds a message body with variables like {{name}}.')])),
  },
})

await payload.create({
  collection: 'docs',
  draft: true,
  data: {
    _status: 'draft',
    title: 'Track delivery with webhooks',
    slug: 'webhooks',
    description: 'Get a request when a message is delivered.',
    content: body(node('paragraph', [text('Acme calls your endpoint when a message is delivered or bounces.')])),
  },
})

console.log('Seeded the docs collection.')
process.exit(0)

Run it once from the Payload app's folder:

npx payload run src/seed.ts

Slugs are unique, so a second run stops at the first page. The link points at /guides/message-templates, the path that page will have on the docs site.

Create an API key for Blume

Start Payload with npm run dev, open http://localhost:3000/admin, and create your first user. Then:

  1. Open Integrations and choose Create New.
  2. Name it Blume and check Enable API Key.
  3. Copy the key. Payload shows it only once.
  4. Save.

Blume sends the key as Authorization: users API-Key <key>. The first word names the collection the key belongs to, and it defaults to users, so this setup needs authCollection set to integrations. Payload invalidates every API key when its PAYLOAD_SECRET changes, so keep that secret stable across deploys.

Connect Blume

Create the docs site in its own folder, beside the Payload app:

npx blume init acme-docs

Pick the docs template, and when it asks where your content lives, pick filesystem and payload. The Payload source needs no SDK, since it speaks the REST API directly. Replace the generated config with:

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

export default defineConfig({
  title: "Acme Docs",
  content: {
    sources: [
      filesystem({ root: "docs" }),
      payload({
        prefix: "guides",
        url: process.env.PAYLOAD_URL ?? "http://localhost:3000",
        collection: "docs",
        authCollection: "integrations",
      }),
    ],
  },
});

The source reads the key from PAYLOAD_API_KEY. Blume loads .env.local for you, so put both values there and keep the file out of Git:

echo "PAYLOAD_URL=http://localhost:3000" >> .env.local
echo "PAYLOAD_API_KEY=paste-your-key-here" >> .env.local
echo ".env.local" >> .gitignore

With Payload still running, run npx blume dev. The two published pages appear under /guides/ in a Guides sidebar group, sorted by title, since the source maps no order field. Each document's slug is its path under the prefix, and its updatedAt is the date a Last updated line shows, once you turn that on. The draft stays out.

Check what carried over

Open /guides/send-your-first-message. Blume lowers each Lexical node to Markdown like this:

In PayloadOn the page
Paragraphs, headings, quotes, horizontal rulesAs written
Bold, italic, strikethrough, inline codeAs written
Underline, subscript, superscript, alignment, indentationPlain text, formatting dropped
Bullet, numbered, and check lists, nested or notMarkdown lists
Links to a URLLinks, for web, mail, phone, and relative addresses
Internal links to another documentThe link's text, with no link
UploadsAn image at the upload's URL, with its alt text
Blocks and inline blocksA serializer's output, or nothing
Relationships, tables, anything elseNothing

Where Blume has no Markdown for a node, it writes a comment into the page source, like <!-- unsupported Lexical block: Code -->, and nothing renders there. It doesn't warn, so the "Send a request" section is missing its command, with no error. To list every such node, read the snapshot Blume keeps of the source. Run this in the docs project after blume dev or blume build has fetched it:

node -e '
const { readdirSync, readFileSync } = require("node:fs");
const dir = ".blume/cache/guides";
for (const snapshot of readdirSync(dir))
  for (const page of JSON.parse(readFileSync(dir + "/" + snapshot + "/entries.json", "utf8")))
    for (const [, what] of page.raw.matchAll(/unsupported (.+?) ?(?:-->|\*\/)/g))
      console.log(snapshot, page.ref, "-", what);
'

guides is the source's prefix. With the seed content, it prints one line, for the code block in send-your-first-message.md. Run it again whenever editors start using a new block.

Render code blocks with a serializer

A serializer turns a block into Markdown or a Blume component, keyed by the block's type. The payload() adapter takes plain options only, so to pass functions, build the source with its engine, payloadSource, and hand it to custom():

import { defineConfig } from "blume";
import { custom, filesystem } from "blume/sources";
import { payloadSource } from "blume/sources/payload.ts";

// A fenced code block that outlasts any backticks inside the code.
const fence = (code: string, language: string) => {
  const runs = code.match(/`+/g) ?? [];
  const ticks = "`".repeat(Math.max(3, ...runs.map((run) => run.length + 1)));
  return `${ticks}${language}\n${code}\n${ticks}`;
};

export default defineConfig({
  title: "Acme Docs",
  content: {
    sources: [
      filesystem({ root: "docs" }),
      custom(
        payloadSource({
          name: "guides",
          prefix: "guides",
          url: process.env.PAYLOAD_URL ?? "http://localhost:3000",
          collection: "docs",
          authCollection: "integrations",
          serializers: {
            Code: (fields) =>
              fence(String(fields.code ?? ""), String(fields.language ?? "")),
          },
        })
      ),
    ],
  },
});

The engine takes the same options plus name, which names its snapshot folder. With a serializer set, the source writes its pages as MDX, and it still escapes what editors typed, so the {{name}} on the templates page renders as written. A serializer can also return a component, like a <Callout> for a callout block.

The dev server serves a custom source from its snapshot, and editing a serializer doesn't change which snapshot it reads. After any serializer change, drop the snapshots and fetch again:

npx blume sync --force

The command now renders as a highlighted code block, and the check from the last section prints nothing, since --force also cleared the older snapshot it was reading.

Preview drafts

Restart the dev server with drafts included:

npx blume dev --preview

"Track delivery with webhooks" appears now. With --preview, Blume asks Payload for draft=true, which returns the newest version of every document, and marks the drafts as drafts. Without it, Blume skips any document whose status is still draft.

The same split applies to a published page with unpublished changes. In the admin panel, edit "Use message templates" and choose Save Draft. The dev server serves a snapshot, so run npx blume sync --preview to pull the edit into the running preview. Keep the flag: a plain blume sync regenerates the site from published content. blume dev without the flag and production builds keep the published version until you choose Publish changes. Preview and published content are cached separately, so a production build never falls back to drafts.

Then check that a production build leaves the draft out:

npx blume build
npx blume preview

/guides/webhooks is a 404, and the page is missing from the sidebar and search.

Deploy with protected previews

Blume fetches from Payload while the site builds, so your Payload app has to be deployed where the build machine can reach it. This guide assumes it's at https://cms.acme.example, and uses Vercel for the docs site, 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"
  }
}
  1. Push the docs 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 PAYLOAD_URL (https://cms.acme.example) and PAYLOAD_API_KEY, for Production and Preview.
  4. Under Deployment Protection, turn on Vercel Authentication with Standard Protection, which protects every deployment except your production domains.

The key is used only during the build, so it never reaches readers. 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.

Images are different from text. Blume serves remote images untouched, so readers' browsers load each one from Payload, at an address like https://cms.acme.example/api/media/file/message-flow.png. Keep the Media collection publicly readable and Payload online. To have Blume download and optimize them at build time instead, authorize the host in your Blume config with image: { domains: ["cms.acme.example"] }.

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. Add it to the Payload app's environment as DOCS_DEPLOY_HOOK, and call it from the Docs collection's hooks:

// Start a docs build through the deploy hook, when one is configured.
const rebuildDocs = async () => {
  if (!process.env.DOCS_DEPLOY_HOOK) return
  await fetch(process.env.DOCS_DEPLOY_HOOK, { method: 'POST' }).catch((error) =>
    console.error('Could not start a docs build', error),
  )
}

export const Docs: CollectionConfig = {
  // ...slug, admin, access, versions, and fields from before
  hooks: {
    afterChange: [
      async ({ doc }) => {
        if (doc._status === 'published') await rebuildDocs()
      },
    ],
    afterDelete: [rebuildDocs],
  },
}

Publishing or deleting a page now starts a build, and saving a draft doesn't. Unpublishing doesn't either, because the document's status is then draft, so when you take a page down, start a build by hand with curl -X POST "$DOCS_DEPLOY_HOOK". 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 403 Forbidden

BLUME_SOURCE_FETCH_FAILED with a 403 means the request reached Payload without a user it accepts, so the collection's read rule turned it away. Check that authCollection is the collection that owns the key, that PAYLOAD_API_KEY is set where the build runs, and that Payload's secret hasn't changed since you made the key. Blume fetches before it checks for missing variables, so on a fresh build this error is all you see; the BLUME_MISSING_SECRET warning only prints when a snapshot stands in for the fetch, and never for a custom() source.

A block renders as nothing

It has no serializer. Serializers are keyed by the block's type, exactly as Payload stores it, so Payload's code block needs Code, not code. Run the check under Check what carried over to list every block still missing one.

Images are broken

Check the image's address. If it starts with localhost, the build ran without PAYLOAD_URL. If Payload answers it with an error, the Media collection isn't publicly readable. If there's no image at all, the snapshot has an unsupported Lexical upload comment, which means depth is set to 0: leave it at its default of 1 so uploads carry their URLs.

A link to another page renders as plain text

It's an internal link, which points at a Payload document rather than a URL. In the link dialog, switch it to a custom URL with the page's path on the docs site, like /guides/message-templates.

The Payload app's build fails on user.email

The blank template's sample home page greets the signed-in user by email, and once Payload regenerates its types, that user can be an integration, which has no email. Change the greeting in src/app/(frontend)/page.tsx to {'email' in user ? user.email : user.name}, or delete the page if you don't use it.

The dev server shows old content

Dev fetches a source once and then serves its snapshot, even across restarts. Run npx blume sync, with --preview when you're previewing, and the running server reloads.

The build serves old content with a warning

BLUME_SOURCE_OFFLINE means Payload couldn't be reached and Blume used the last snapshot it had. The warning includes the reason. A fresh build machine has no snapshot, so there the same failure stops the build and your last deployment stays live.

Next step

Publish your Payload docs

Pick payload when init asks where your content lives, then point the source at your collection and set PAYLOAD_API_KEY.

npx blume init
Read the Payload 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