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

Content sources

Combine Markdown and Notion in one documentation site

One docs site where engineers write Markdown in Git and teammates edit Notion, with a section each, checks that keep their URLs apart, and one build for both.

By 8 min read

Blume can build one docs site from two sources at once: a folder of Markdown that engineers change through pull requests, and a Notion database that the rest of the team edits in Notion. Every build reads both, so readers get one sidebar, one set of header tabs, and one search index across the whole site.

The way to make it work is to split by section, not by page. Each section belongs to exactly one source, and nothing syncs between them: Blume reads Notion at build time and never writes back, and Markdown pages never show up in Notion. In this guide, engineers own a Setup section in Git, the support team owns a User handbook section in Notion, and you add the checks that keep their URLs from colliding and their drafts from leaking.

If everyone who writes docs is comfortable in Git, one Markdown source is simpler. If the Notion side only needs a public page, Notion's own web publishing covers it. This guide assumes you already have a Notion connection token and a database ID; the Notion guide walks through getting both.

Give each section one owner

Decide which URLs each source owns before you write any config. For the Acme docs at docs.acme.example, the split looks like this:

SectionURLsSourceGoes live when
Home and Setup/, /setup/…docs/ in Git, owned by engineeringA pull request merges
User handbook/handbook/…A Notion database, owned by supportStatus is Done and the site rebuilds

Each side then enforces ownership with its own permissions.

In Git: code owners

A CODEOWNERS file routes every pull request to the team that owns the files it touches:

# The site config and dependencies affect both sections
/blume.config.ts @acme/docs-maintainers
/package.json    @acme/docs-maintainers

# The Setup section
/docs/setup/     @acme/platform

# The handbook lives in Notion, so nothing should be added here
/docs/handbook/  @acme/docs-maintainers

Turn on Require review from Code Owners in the branch protection rule or ruleset for your main branch, so an owner has to approve. The last line guards a folder that should never exist; you'll see why under Keep the routes apart.

In Notion: database permissions

Share the handbook database with three levels of access:

  • Can edit content for the support writers. They can create pages and set property values, but they can't change the database's properties, so nobody renames Status by accident.
  • Full access for one docs maintainer, who makes any change to the database's properties together with the matching change to blume.config.ts.
  • Can comment for reviewers.

Anyone who can edit a page can change its Status, so Status is a publish switch, not an approval step. If handbook pages need sign-off, agree on who sets Done.

Add both sources

The Git side is a normal Blume content folder:

.
├── blume.config.ts
├── package.json
└── docs/
    ├── index.mdx
    └── setup/
        ├── index.mdx
        ├── install.mdx
        └── configure.mdx

The Notion database needs a title property, plus Slug (text), Order (number), Status (status), and optionally Description (text). Give one page the Slug index and the title User handbook: it becomes the page at /handbook itself, so the section has a landing page. Install the Notion SDK the source uses:

npm install @notionhq/client@5.26.0

Then list both sources in your config:

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

export default defineConfig({
  title: "Acme Docs",
  content: {
    sources: [
      // Git: the home page and everything under /setup
      filesystem({ root: "docs" }),
      // Notion: everything under /handbook
      notion({
        database: "8f2c1e0a4b7d4f3c9e6a5d2b1c0f9e8d",
        prefix: "handbook",
        publishedValue: "Done",
      }),
    ],
  },
  navigation: {
    tabs: [
      { label: "Setup", path: "/setup" },
      { label: "User handbook", path: "/handbook" },
    ],
  },
});

A sources array replaces Blume's default content folder, so the filesystem() entry is what keeps your Markdown on the site. prefix puts every Notion page under /handbook. publishedValue names the Status option that means published, and the tabs give each section its own header tab and its own sidebar.

The source reads its token from NOTION_TOKEN. Put it in .env.local, which Blume loads for you, and keep that file out of Git:

echo "NOTION_TOKEN=paste-your-token-here" >> .env.local
echo ".env.local" >> .gitignore
npx blume dev

The Setup tab shows your Markdown pages, and the User handbook tab shows the Notion pages sorted by Order. A Slug with a slash, like billing/plans, puts the page in a Billing group inside the handbook. Search finds pages from both sections.

Keep the routes apart

Blume builds each URL from the source's prefix and the page's path. docs/setup/install.mdx is served at /setup/install. A Notion page with the Slug exports is served at /handbook/exports, and one without a Slug at its title, lowercased and hyphenated. A Notion Slug can't leave the prefix: /setup/install and ../setup/install both land at /handbook/setup/install.

So two pages can only claim the same URL in three ways:

  • A Markdown file under docs/handbook/ with the same path as a Notion page.
  • Two Notion pages with the same Slug, or the same title and no Slug.
  • A notion() source with no prefix, whose pages then share the root with docs/.

Each one is a BLUME_DUPLICATE_ROUTE error that names both pages, with the Notion one under the source's prefix:

Two files resolve to /handbook/exports: filesystem:handbook/exports.mdx and handbook:exports.mdx

blume build stops on it. blume dev reports it and keeps serving. Fix it in the section that shouldn't have the page: delete the stray Markdown file, or change one Notion page's Slug.

What the check doesn't catch

A Markdown file under docs/handbook/ that doesn't match a Notion page collides with nothing. It builds, and it joins the handbook's sidebar group as if support had written it. That's what the /docs/handbook/ line in CODEOWNERS is for.

Drafts are the other gap. A production build drops drafts before it checks routes, so a Notion draft that will collide passes today's build and breaks the one after someone sets it to Done. blume dev and any run with --preview include drafts, so they report the collision now. The CI check below uses that.

Know what Status publishes

The Status property decides which handbook pages reach production. With publishedValue: "Done":

The page's StatusIn a production build
DonePublished
Not started, In progress, or any other optionA draft, left out
An option spelled differently, like doneA draft: the match is exact, capitals included
No valuePublished
No property named Status (renamed or deleted)Every page published
Status is a checkbox, multi-select, or other typeEvery page published

Blume only reads a Status of the status or select type. Leave out publishedValue and Blume looks for Published, which Notion's default options don't have: every page with a status becomes a draft, and only pages with no status go live.

Review drafts before they go live

Setup pages go through pull requests, reviewed by their code owners, with your host's preview deployment for each branch. Handbook pages stay In progress while writers work and reviewers comment in Notion, and go live when someone sets them to Done.

To see handbook drafts rendered, use the dev server: it shows drafts like any other page. It serves a snapshot of Notion, so pull the latest first, and a running server reloads:

npx blume sync

To share a rendered copy with drafts, run npx blume build --preview and deploy that output only behind access protection, never to your public domain.

Check both sections in CI

Add NOTION_TOKEN as a repository secret, then run two checks on every pull request and every hour, since Notion edits never open a pull request:

name: Check docs

on:
  pull_request:
  schedule:
    - cron: "0 * * * *"
  workflow_dispatch:

jobs:
  check:
    runs-on: ubuntu-latest
    env:
      NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npx blume validate
      - run: npx blume check --preview --strict

blume validate reads the site the way a production build does and checks every internal link in both sections. It catches a Setup page that links to a handbook page that's still a draft or whose Slug changed, and a Notion page that links to a Setup page that moved. blume check --preview --strict includes drafts and fails on any content error, so a draft collision fails here before it can fail a deploy.

Publish both through one build

Every blume build fetches the Notion database fresh, reads docs/, and writes one site with one navigation and one search index. A merge to your main branch starts that build on your host. A Notion edit doesn't, so give Notion a way to start one: a deploy hook called from a Notion automation or a scheduled workflow. The Notion guide covers both options.

One build also means one failure: if the Notion fetch fails, a Setup change can't deploy either. On a host that deploys atomically, like Vercel, the failed build doesn't replace the live site.

Nothing syncs between the sources

The Notion connection needs only the Read content capability, because Blume never writes to Notion. It writes each Notion page as an MDX file inside .blume/ and regenerates that folder on every build, so editing those files changes nothing that lasts.

To move a page from one section to the other, do it by hand. Say the API keys page moves from the handbook to Setup:

  1. Write it as docs/setup/api-keys.mdx.
  2. Set the Notion page's Status to an option other than Done, like a new Archived option.
  3. Redirect the old URL to the new one.
redirects: [
  { from: "/handbook/api-keys", to: "/setup/api-keys", status: 308 },
],

Blume doesn't flag an exact redirect whose old URL is still a published page, so archive the Notion page before you rely on the redirect.

Troubleshooting

The build fails with BLUME_DUPLICATE_ROUTE

Two pages claim one URL. The message names both: a filesystem: path is a file in docs/, and a handbook: path is a Notion page. If both are Notion pages, two rows share a Slug or a title. Rename one, or remove the file that doesn't belong in that section.

Every handbook page disappeared

No page's Status matches publishedValue. Check the spelling and capitals against the option in Notion, and whether someone renamed the Done option. In dev, run npx blume sync after a change in Notion, since the server serves its snapshot until you do.

Handbook drafts went live

Blume couldn't read a Status. The property was renamed, deleted, or changed to a type other than status or select, or the drafts have no status at all. Restore the property, or point properties.status at its new name.

A Setup pull request fails on the Notion source

A fresh CI runner has no snapshot of Notion to fall back on, so a failed fetch stops the run with BLUME_SOURCE_FETCH_FAILED and Notion's own error. Check that NOTION_TOKEN is set as a repository secret. GitHub doesn't pass secrets to workflows run from a fork, so pull requests from forks can't run these checks.

A link between the sections is broken

blume validate reports BLUME_BROKEN_LINK when a link points at a page the production build doesn't have. A handbook link usually broke because the page is still a draft or its Slug changed. Links inside Notion should use site paths like /setup/install: a link to another Notion page opens Notion, not your site.

Next step

Add Notion beside your Markdown

Install the Notion SDK in your Blume project, then add a prefixed notion() source next to filesystem().

npm install @notionhq/client
Read the Notion 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