Writing
Reuse Markdown snippets and variables across documentation
Three guides that share one setup section through MDX includes, with one intentional variation, version strings defined once, and a way to check every page a change reaches.
By Hayden Bleasel9 min read

To stop install steps and version strings drifting between pages, give each one a single source. In Blume, shared instructions live in a partial: a Markdown or MDX file whose name starts with an underscore, spliced into any page by an <include> statement. The one value that differs between pages goes on that statement as an attribute. Values that repeat everywhere, like an SDK version or an API host, go under variables in blume.config.ts. You change a line once, and every page that uses it changes on the next build.
This guide builds that for three Acme guides that share a setup section: send an email, send an SMS, and receive delivery webhooks. The webhook guide needs an API key with a different scope, and that's the one intentional variation. You end up with two partials, two variables, and a way to list every page a change reaches before you ship it.
If only a version number repeats, variables alone cover it, and you can skip the partials. Props replace text and nothing else: they can't show or hide a block. When pages need different paragraphs rather than different values, keep those paragraphs on the page, or use a View when one page serves several audiences.
Start from what repeats
Here's the Acme docs project this guide works in. Each of the three guides opens with the same setup: install the SDK at a pinned version, create an API key, and create a client. Copied by hand, those sections drift. One page gets the new version, another keeps the old one, and a reader copies the stale command.
docs/
_snippets/
setup.mdx
api-key.mdx
guides/
send-email.mdx
send-sms.mdx
delivery-webhooks.mdx
index.mdx
blume.config.tsBlume leaves any file or folder whose name starts with an underscore out of routing, navigation, search, and the sitemap, so _snippets/ holds shared content without turning it into pages. The pages use .mdx because the setup has a package-install block, which renders as package manager tabs only in MDX.
Extract the setup into an MDX include
Move the setup section out of one of the pages, word for word, into a partial:
## Set up the SDK
Install version 1.4.0 of the Acme SDK:
```package-install
npm install @acme/messages@1.4.0
```
Create an API key in the Acme dashboard with the **messages:write** scope, and
export it in the shell that runs your code:
```bash
export ACME_API_KEY="paste-your-key-here"
```
Then create a client. It sends every request to https://api.acme.example/v1
with your key:
```ts acme.ts
import { Acme } from "@acme/messages";
export const acme = new Acme({ apiKey: process.env.ACME_API_KEY });
```Then replace the section in each page with one include statement on a line of its own:
---
title: Send your first email
description: Send a transactional email with the Acme SDK.
---
<include>/_snippets/setup.mdx</include>
## Send the email
```ts send-email.ts
import { acme } from "./acme";
const message = await acme.messages.send({
channel: "email",
to: "ada@example.com",
template: "welcome",
});
console.log(message.id);
```A path that starts with / resolves from your content root (docs/), so the same statement works at any folder depth. A path without it, like ../_snippets/setup.mdx, resolves from the including file. Put the same statement at the top of send-sms.mdx and delivery-webhooks.mdx. The SMS page has the same shape as the email page, with channel: "sms" and a phone number in to.
At build time the partial's text replaces the statement, as if you'd written it inline. Its ## Set up the SDK heading joins each page's table of contents, search indexes it on each page, and it appears in each page's .md mirror and in llms-full.txt. Front matter in a partial is dropped, so the including page's own front matter wins. With blume dev running, saving a partial reloads every page that includes it.
Keep code samples in real files
A statement whose target isn't .md or .mdx embeds that file as a code block, with the language taken from the extension. A sample can then live as a real source file that your tests import or typecheck, and the page shows exactly that file:
<include meta='title="acme.ts"'>/_snippets/acme.ts</include>The file still has to sit inside your content root. Keep placeholders out of it: props on its own include statement don't reach it, and the file has to stay valid code for your tests.
Pass the one thing that differs
The webhook guide registers an endpoint, so its key needs the webhooks:write scope, not messages:write. As written, the partial tells webhook readers the wrong thing. Rather than copy the partial, pass the scope in as a prop.
While you're there, move the API key paragraph into a partial of its own, so a page that only needs a key can include that part alone. Write the scope as {{scope}}:
Create an API key in the Acme dashboard with the **{{scope}}** scope, and
export it in the shell that runs your code:
```bash
export ACME_API_KEY="paste-your-key-here"
```In setup.mdx, replace that paragraph and its code block with a nested include. A relative path inside a partial resolves from the partial's own folder:
<include>./api-key.mdx</include>Then give each page its scope as an attribute on the statement:
---
title: Receive delivery webhooks
description: Get a request each time a message is delivered or fails.
---
<include scope="webhooks:write">/_snippets/setup.mdx</include>
## Register your endpoint
```ts register-webhook.ts
import { acme } from "./acme";
const webhook = await acme.webhooks.create({
url: "https://example.com/acme/webhooks",
events: ["message.delivered", "message.failed"],
});
console.log(webhook.id);
```In send-email.mdx and send-sms.mdx, the statement becomes:
<include scope="messages:write">/_snippets/setup.mdx</include>Every attribute except lang and meta is a prop, and its value is plain text. A prop applies to the included file and to anything that file includes, which is how scope reaches api-key.mdx through setup.mdx. A prop also wins over a site-wide variable with the same name. A page that only needs the key includes the smaller partial directly, with its own scope:
<include scope="messages:read">/_snippets/api-key.mdx</include>Write {{scope}} in prose, not inside backticks. Once the site defines variables (next section), a reference in prose that nothing fills fails the build, while one inside code is left as written with no error. That difference matters when a page forgets the prop.
Move repeated values into variables
The SDK version and the API host are still literal text, in the partial and anywhere else a page mentions them. Define them once in your config:
import { defineConfig } from "blume";
export default defineConfig({
title: "Acme Messages",
variables: {
"sdk-version": "1.4.0",
"api-url": "https://api.acme.example/v1",
},
});Then write {{name}} wherever a page needs the value. Here's the setup partial in its final form:
## Set up the SDK
Install version {{sdk-version}} of the Acme SDK:
```package-install
npm install @acme/messages@{{sdk-version}}
```
<include>./api-key.mdx</include>
Then create a client. It sends every request to {{api-url}} with your key:
```ts acme.ts
import { Acme } from "@acme/messages";
export const acme = new Acme({ apiKey: process.env.ACME_API_KEY });
```Pages outside the partial read the same values, so a version mentioned on the home page can't fall behind the install command:
---
title: Acme Messages
description: Send transactional email and SMS from your app.
---
These guides use version {{sdk-version}} of the Acme SDK.References work in prose, headings, links, code blocks, inline code, and component props, in pages and in the partials they include. Names take letters, digits, _, and -, and each value is plain text on one line. Blume replaces them before anything reads the page, so the HTML, search, the .md mirrors, and llms-full.txt all show the value. Front matter is the exception: it stays as written, so keep variables out of titles and descriptions.
Defining variables also turns on a check. A {{name}} in prose that no variable or prop fills is a build error, BLUME_UNDEFINED_VARIABLE, reported at its line. In a site that defines no variables, the check is off.
Change a value and check every page
When the next SDK release ships, the change is one line:
variables: {
- "sdk-version": "1.4.0",
+ "sdk-version": "1.5.0",
"api-url": "https://api.acme.example/v1",
},Check the sources before you build. blume validate scans every page with its includes expanded, so it reports a missing include target, an include loop, an undefined variable, and a broken link inside a partial, all without a build:
npx blume validateIt exits non-zero when it finds an error, so the same command works as a CI step.
Find every page that uses a partial
To see which pages a partial reaches, search for its file name:
grep -rn "setup.mdx" docsThat lists the statements that include it directly, here the three guides. For a nested partial, the search finds the partial that includes it, so repeat it for that file: grep -rn "api-key.mdx" docs points at setup.mdx, which points at the three guides.
The built site gives the complete answer, because every page's .md mirror has its includes spliced in and its variables filled. Build, then list the pages that contain the setup heading and confirm that none still carries the old version:
npx blume build
grep -rl --include="*.md" "## Set up the SDK" dist
grep -rn --include="*.md" "@acme/messages@1.4.0" distThe first search lists each page that includes the setup, directly or through another partial, so compare it with the pages you expect. The second should print nothing. With blume dev running, you can read a single page the same way at its .md URL, like http://localhost:4321/guides/delivery-webhooks.md.
Give shared files an owner
Single-source documentation moves risk as well as text: an edit to setup.mdx now changes three pages at once. Keep each kind of change in one home, so a reviewer can tell from the diff what it reaches:
- Values live in
blume.config.ts, undervariables. - Shared prose lives in
docs/_snippets/. - Variations live on the page that needs them, as props on its include statement.
Blume's diagnostics follow the same split. A broken link inside a partial is reported against the partial, not against each page that includes it, and an undefined name is reported at the line that uses it, even when that line is in a partial. You fix each problem in the file that owns it.
On GitHub, a CODEOWNERS file requests a review from the right team whenever a pull request touches those files:
/docs/_snippets/ @acme/docs
/blume.config.ts @acme/docsGitHub reads the file from .github/, the repository root, or docs/, uses the first one it finds, and lets the last matching line win. The team needs write access to the repository. To block merges without that review, turn on "Require review from Code Owners" in the branch's protection rule.
If your site is translated, blume translate doesn't translate partials, since every locale shares them. Keep partials language-neutral, like code and tables, or write one partial per locale and include it from that locale's pages.
Troubleshooting
The build fails with BLUME_UNDEFINED_VARIABLE in a partial
A page included the partial without its prop. The error points at the partial's line, like docs/_snippets/api-key.mdx:1, not at the page that forgot the prop. To find that page, list the statements that include the partial without the attribute:
grep -rn "_snippets/setup.mdx" docs | grep -v 'scope='A reference inside inline code or a code block isn't checked, so a missing prop there ships as literal braces. The check also needs at least one variable defined in the config.
A title shows the braces instead of the value
Variables don't apply to front matter, so a title or description with {{sdk-version}} shows it as written. Move the version into the page body, or write it out in the title.
BLUME_INCLUDE_NOT_FOUND or BLUME_INCLUDE_OUTSIDE_ROOT
The path points at a file that isn't there. Relative paths resolve from the file that holds the statement, which for a nested include is the partial, not the page. A leading / means the content root, not the repository root. BLUME_INCLUDE_OUTSIDE_ROOT means the target sits outside docs/, like a sample in your source tree: Blume refuses it because version snapshots and ejected projects wouldn't have the file. Move it under docs/. Both errors fail blume build; in blume dev, the page shows an include error where the partial should be.
BLUME_INCLUDE_CYCLE
A partial includes itself, directly or through another partial. The error prints the chain from the page through each partial, like this one for a loop between the two Acme partials:
Circular include: guides/send-email.mdx -> _snippets/setup.mdx -> _snippets/api-key.mdx -> _snippets/setup.mdx.Remove the statement that closes the loop. Blume reports the loop once for each page that reaches it.
The page shows the raw include tag
An include is block-level, so it needs a line of its own, not a place inside a sentence. A statement a formatter wraps over several lines still works, as long as no blank line splits it. A line that starts <include but doesn't close into a statement raises a BLUME_INCLUDE_MALFORMED warning at that line.
A partial shows up as its own page
Its file or folder name doesn't start with an underscore, or your config sets content.exclude, which replaces the default ["**/_*", "**/.*"] instead of adding to it. Rename the folder, or add "**/_*" back to the list. A partial built as a page usually fails with BLUME_UNDEFINED_VARIABLE too, since no prop fills its {{scope}} there.
Next step
Check snippets on every pull request
Run validate in CI, so a missing include, an include loop, or an undefined variable fails the pull request that caused it.
npx blume validateA step here not working for you? Report a broken step.