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

Content sources

Build one docs portal from multiple GitHub repositories

One docs site that pulls each repository's docs folder from GitHub at build time, under its own path, pinned to a release that moves only when you change it.

By 10 min read

To build one docs site from several GitHub repositories, give each repository an mdxRemote() content source with its own prefix. When you build, Blume lists each repository's docs folder through the GitHub API, fetches its Markdown and MDX files, and renders them beside your local pages. The result is one site with one search index, where each repository keeps its own URL space.

By the end of this guide, the Acme docs portal owns its home page and a shared API keys page, mounts the CLI repository's docs at /cli and the Node SDK's at /sdk, and pins each to a release tag, so upstream edits reach the site only when you move the pin. It also covers tokens, cross-repository links and images, route collisions, and caching.

If all your docs already live in one repository, you don't need remote sources: see Add documentation to a monorepo. Also note that mdxRemote() fetches page text only. It doesn't download images, and a page can't import files from its own repository. If your pages depend on those, copy each repository's docs folder into your content folder in CI instead, so Blume reads them as local files.

The source repositories

Each product repository keeps its docs in a docs/ folder beside its code. Swap in your own owners, repositories, and tags as you go. The CLI repository, acme/cli, has two pages:

---
title: Acme CLI
description: Send messages and manage templates from your terminal.
---

The `acme` command wraps the Acme Messages API. Create an
[API key](/api-keys) first, then [send a message](./send.mdx).
---
title: Send a message
description: Send an email or SMS from the command line.
---

```bash
acme messages send --channel email --to ada@example.com --template welcome
```

The command prints the new message's ID. To send from code instead, use the
[Node SDK](/sdk/quickstart).

![The send command and its output](/images/cli/send.png)

The SDK repository, acme/node-sdk, has three:

---
title: Node SDK
description: Call the Acme Messages API from Node.js.
---

Start with the [quickstart](./quickstart.mdx).
---
title: Quickstart
description: Send your first message from Node.js.
---

The SDK reads your key from the `ACME_API_KEY` environment variable. See
[API keys](/api-keys) to create one. If a call fails, check
[Errors](./errors.mdx). To send from a terminal, use the [CLI](/cli/send).
---
title: Errors
description: What the SDK throws when the API rejects a request.
---

A failed request throws an error with the HTTP status and the API's error
code. A `401` means the key is missing or revoked: [create a new one](/api-keys).

Tag each repository at the commit you want to publish:

git tag v1.4.0
git push origin v1.4.0

This guide uses v1.4.0 for the CLI and v2.3.0 for the SDK.

Create the portal

The portal is its own repository. Scaffold it:

npx blume init acme-docs

Pick the docs template. When it asks where your content lives, keep filesystem and add mdx-remote, then keep the default docs content folder. You'll replace the generated config in the next section. Replace docs/index.mdx and add a page both repositories link to:

---
title: Acme docs
description: Send transactional email and SMS from the CLI or the Node SDK.
---

- [CLI](/cli): send messages and manage templates from a terminal.
- [Node SDK](/sdk): call the Acme Messages API from Node.js.

Both need an [API key](/api-keys).
---
title: API keys
description: Create the API key the CLI and the SDK authenticate with.
---

Create a key in the Acme dashboard, then store it in an environment variable
named `ACME_API_KEY`. The CLI and the SDK both read it from there.

Mount each repository under its own prefix

Replace blume.config.ts with one local source and one remote source per repository:

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

export default defineConfig({
  title: "Acme Docs",
  description: "Docs for the Acme Messages API, its CLI, and its Node SDK.",
  content: {
    sources: [
      filesystem({ root: "docs" }),
      mdxRemote({
        prefix: "cli",
        github: { owner: "acme", repo: "cli", ref: "v1.4.0", path: "docs" },
      }),
      mdxRemote({
        prefix: "sdk",
        github: { owner: "acme", repo: "node-sdk", ref: "v2.3.0", path: "docs" },
      }),
    ],
  },
  navigation: {
    tabs: [
      { label: "CLI", path: "/cli" },
      { label: "Node SDK", path: "/sdk" },
    ],
  },
});

A sources array replaces Blume's default content folder, so the filesystem() entry is what keeps your local pages. Each mdxRemote() reads the files under path at ref (which defaults to main) and mounts them under /<prefix>. The prefix also names the source in diagnostics and in its cache folder. You get these routes:

FileRoute
Portal docs/index.mdx/
Portal docs/api-keys.mdx/api-keys
acme/cli docs/index.mdx/cli
acme/cli docs/send.mdx/cli/send
acme/node-sdk docs/index.mdx/sdk
acme/node-sdk docs/quickstart.mdx/sdk/quickstart
acme/node-sdk docs/errors.mdx/sdk/errors

Each tab scopes the sidebar to its section, and on the home page the sidebar lists the local pages that belong to no tab. A frontmatter slug in a remote page stays inside its prefix, too. Every .md and .mdx file under path becomes a page, so keep READMEs and contributing notes out of the docs folder, or narrow include with a positive glob such as ["*.mdx", "guides/**/*.mdx"].

Order a remote section from the portal

Blume fetches only Markdown from a repository, never its meta.ts, so the SDK section sorts its index first and the rest alphabetically: Errors above Quickstart. Folder meta is matched by sidebar path rather than by source, so a meta.ts in the portal's own docs/sdk/ folder orders the remote pages mounted at /sdk:

import { defineMeta } from "blume";

export default defineMeta({
  pages: ["quickstart", "errors"],
});

To keep the order in the source repository instead, set sidebar.order in each page's frontmatter.

Pin each repository to a revision

The ref takes a branch, a tag, or a commit SHA. Blume passes it to GitHub's tree API and to the raw file URLs it reads each page from. What you pin decides what a build can publish:

  • A tag is readable and changes only if someone force-pushes it.
  • A full commit SHA can't change at all. To pin the commit a branch points at right now, run git ls-remote https://github.com/acme/cli refs/heads/main and paste the 40-character SHA it prints.
  • A branch publishes whatever it holds when a build runs, and Blume doesn't record which commit that was. GitHub also serves raw files with a five-minute cache header, so a build right after a push can list new files while still reading older copies of changed ones.

With a pin, blume.config.ts records what's published, and the portal's Git history shows when each repository moved. Each remote page's edit link carries the same ref and points at the source repository: the send page links to https://github.com/acme/cli/edit/v1.4.0/docs/send.mdx.

Give Blume a GitHub token

Public repositories work without a token, though blume dev and blume build still warn BLUME_MISSING_SECRET. Each fetch makes one GitHub API call per source to list its files, and GitHub allows 60 unauthenticated API requests an hour, so set a token in CI even for public repositories.

For private repositories, Blume reads a single variable, GITHUB_TOKEN, for every mdxRemote() source, and only ever sends it to api.github.com and raw.githubusercontent.com. That one token has to read every source repository. Create a fine-grained personal access token with the organization as its resource owner, only the source repositories selected, and read-only Contents permission. A fine-grained token reaches repositories under one owner only, so keep the source repositories in one organization.

Locally, put the token in .env.local at the portal's root as GITHUB_TOKEN= followed by the token, and keep that file out of Git. Blume loads .env and .env.local itself. To use your GitHub CLI login for one run without writing it to disk:

GITHUB_TOKEN=$(gh auth token) npx blume dev

In GitHub Actions, store the token as a repository secret named DOCS_SOURCES_TOKEN (secret names can't start with GITHUB_) and expose it as GITHUB_TOKEN, as the workflow below does. The token Actions creates for each run can't stand in: its permissions are limited to the repository running the workflow. If your host builds the site instead, set GITHUB_TOKEN in its build environment.

A source repository's docs are read on GitHub and on the portal, and only some links work in both.

  • Pages in the same folder: use relative file links like ./send.mdx. GitHub opens the file, and Blume rewrites the link to the page's route, /cli/send.
  • Another repository or a portal page: use the portal route, like /sdk/quickstart or /api-keys. These links break when someone reads the file on GitHub.
  • Anything outside the docs folder: a link like ../README.md fails blume validate with BLUME_BROKEN_LINK. Link to the file on GitHub with a full URL instead.
  • Ordering prefixes: Blume drops a numeric prefix like 02- from a remote file's route, but blume validate resolves a link to ./02-errors.mdx as /sdk/02-errors and reports it broken. Leave prefixes off remote file names and order the section with meta.ts.

Images need more care. Blume doesn't download them for a remote source or rewrite relative image paths in remote pages, and a remote page is compiled from a staging copy with no files beside it. So ![Output](./images/send.png) points at nothing, and the page fails to build. Keep images in the portal's public/ folder, one folder per repository, and reference them from the root: the send page's /images/cli/send.png is public/images/cli/send.png. blume validate reports a missing one as BLUME_BROKEN_ASSET.

For a public repository, a full raw URL such as https://raw.githubusercontent.com/acme/cli/v1.4.0/docs/images/send.png also works, and shows on GitHub too. Readers' browsers then load it from GitHub, and it fails for a private repository, since they have no token.

What Blume caches, and how to refresh it

Every successful fetch is saved as a snapshot in .blume/cache/<prefix>/published-<hash>/entries.json, where the hash covers the source's options, ref included. It holds each file's text, a content hash, and its edit URL, but no commit SHA. .blume/ is regenerated, so don't commit it.

  • blume build always fetches. If GitHub fails and a snapshot for the same options exists, it builds from the snapshot and warns BLUME_SOURCE_OFFLINE. With no snapshot, as on a fresh CI runner, it fails with BLUME_SOURCE_FETCH_FAILED. If only some files fail, it skips them with a warning of the same code and builds the rest.
  • blume dev serves a source from its snapshot when it has one, so restarting doesn't refetch. Run npx blume sync to refetch every remote source (a running dev server reloads), or npx blume sync --force to delete .blume/cache first. To refetch on a timer in dev, set pollInterval (in seconds) on a source.
  • Changing a source's options, such as its ref, path, or include, gives it a new snapshot, so the next run fetches fresh.

None of these gets around GitHub's five-minute cache on branch files. A new pin does, because it's a new URL.

Check the portal in CI

Click through both tabs in npx blume dev, then run what CI will run:

npx blume validate --strict
npx blume build

blume build fails on errors, but snapshot fallbacks and skipped files are only warnings. blume validate --strict fails on those too, and on broken links and missing images. It fetches the sources itself, so pinned refs make sure it checks what the build publishes. This workflow runs both on every pull request and push to main:

name: Docs

on:
  pull_request:
  push:
    branches: [main]

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

Ship an upstream change

With pins, an upstream edit reaches the portal as a reviewed one-line change.

Tag the change in the source repository

Merge the docs change in acme/cli and tag it:

git tag v1.5.0
git push origin v1.5.0

Move the pin on a portal branch

Create a branch in the portal and change the CLI source's ref from "v1.4.0" to "v1.5.0".

git switch -c cli-v1.5.0

Preview it

Run npx blume dev. The new ref has no snapshot yet, so Blume fetches the tag instead of serving the old copy. Check the pages that changed, and add any new images to public/images/cli/.

Review and merge

Open a pull request. CI validates and builds against the new tag, and your host's preview deployment shows the result. Merging publishes it, and reverting the one-line change rolls it back.

To publish each repository's release notes as well, see Turn GitHub releases into a product changelog.

Troubleshooting

Two files resolve to the same route

BLUME_DUPLICATE_ROUTE is a build error naming the route and both files. It usually means two sources share a prefix, a remote source has no prefix so its index.mdx lands on / beside yours, or the portal has local pages under docs/cli/ that match remote ones. Give every source a distinct prefix and keep local pages out of the remote sections.

A source fails with a 404

BLUME_SOURCE_FETCH_FAILED with -> 404 on the api.github.com tree URL means the owner, repository, or ref doesn't exist, or the token can't read the repository: GitHub can answer a missing or insufficient token with a 404 rather than a 403. Check that GITHUB_TOKEN is set where the build runs and that the token has the repository selected. A 403 or 429 can also mean a rate limit.

A section is empty, and nothing warns

If path doesn't exist at the pinned ref, or include matches none of its files, the source loads zero pages without a diagnostic, for example when a repository moved its docs folder before or after the tag you pinned. Browse the repository at that ref on GitHub, then fix path or move the pin.

The site shows older content than the repository

In dev, you're seeing the snapshot: run npx blume sync. In a build, look for BLUME_SOURCE_OFFLINE, which means the fetch failed and a snapshot was used. On a branch ref, GitHub's raw cache may still hold the old file, so pin the new commit.

A remote page fails on its frontmatter

Blume's frontmatter schema is strict, so a key another docs tool accepted fails with BLUME_FRONTMATTER_INVALID, naming the page as sdk:quickstart.mdx (the prefix, then the file's path under path). Rename or remove the key in the source repository, or declare it in the portal with frontmatter.extend.

A large repository imports only some pages

Blume lists a repository's whole tree even when path points at one folder, and GitHub caps that listing at 100,000 entries or 7 MB. Past the cap, Blume warns BLUME_SOURCE_TRUNCATED, and narrowing path doesn't help. List the files yourself with a raw base URL instead:

mdxRemote({
  prefix: "cli",
  url: "https://raw.githubusercontent.com/acme/cli/v1.4.0/docs",
  files: ["index.mdx", "send.mdx"],
}),

Next step

Mount your first repository

Scaffold a portal, select mdx-remote when it asks where your content lives, and point the source at one repository's docs folder.

npx blume init acme-docs
Read the remote MDX reference

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