Content sources
Use Sanity as a CMS for developer documentation
Editors write guides in Sanity Studio while developers keep Markdown in Git, and one docs site serves both, with drafts only in protected previews.
By Hayden Bleasel11 min read

To build a Sanity documentation website with Blume, add Blume's Sanity content source. When the site builds, it runs a GROQ query against your dataset, turns each document's Portable Text into a Markdown page, and serves those pages under a prefix beside the Markdown files your developers keep in Git. Editors write and publish in Sanity Studio, developers review their pages in pull requests, and readers get one site with one sidebar and one search index.
By the end, you have a Studio schema for guides, a docs project that reads them, code blocks and callouts from Sanity rendered as Blume's own, drafts that appear only in protected preview builds, and a production rebuild every time an editor publishes.
Blume reads Sanity at build time, so an edit reaches readers on the next build, and a preview is a rebuilt site. If your editors need Sanity's live, click-to-edit visual editing, Blume isn't the right fit. If everyone on your team is happy writing Markdown in Git, you don't need a CMS at all: Blume's default docs folder already covers that.
Define the guide schema
Work in your Studio project, or start one with npm create sanity@latest and the clean template. Guides use Sanity's code input plugin for code blocks:
npm install @sanity/code-input@7Add a callout type, the custom block editors use for notes and warnings:
import {defineField, defineType} from 'sanity'
export const callout = defineType({
name: 'callout',
title: 'Callout',
type: 'object',
fields: [
defineField({
name: 'tone',
type: 'string',
options: {list: ['note', 'tip', 'warning', 'danger'], layout: 'radio'},
initialValue: 'note',
validation: (rule) => rule.required(),
}),
defineField({
name: 'text',
type: 'text',
rows: 3,
validation: (rule) => rule.required(),
}),
],
preview: {select: {title: 'text', subtitle: 'tone'}},
})Then the guide document itself:
import {defineArrayMember, defineField, defineType} from 'sanity'
export const guide = defineType({
name: 'guide',
title: 'Guide',
type: 'document',
fields: [
defineField({name: 'title', type: 'string', validation: (rule) => rule.required()}),
defineField({
name: 'slug',
type: 'slug',
options: {source: 'title'},
validation: (rule) => rule.required(),
}),
defineField({
name: 'summary',
type: 'text',
rows: 2,
description: 'Shown under the title and in search results.',
}),
defineField({
name: 'content',
type: 'array',
of: [
defineArrayMember({
type: 'block',
// The title is the page's heading, so sections start at h2.
styles: [
{title: 'Normal', value: 'normal'},
{title: 'Heading 2', value: 'h2'},
{title: 'Heading 3', value: 'h3'},
{title: 'Quote', value: 'blockquote'},
],
marks: {
decorators: [
{title: 'Strong', value: 'strong'},
{title: 'Emphasis', value: 'em'},
{title: 'Code', value: 'code'},
{title: 'Strike', value: 'strike-through'},
],
annotations: [
defineArrayMember({
name: 'link',
type: 'object',
title: 'Link',
fields: [
defineField({
name: 'href',
type: 'url',
title: 'URL or site path',
validation: (rule) =>
rule.uri({allowRelative: true, scheme: ['http', 'https', 'mailto']}),
}),
],
}),
],
},
}),
defineArrayMember({
type: 'image',
fields: [defineField({name: 'alt', type: 'string', title: 'Alternative text'})],
}),
defineArrayMember({type: 'code'}),
defineArrayMember({type: 'callout'}),
],
}),
],
})Register both types and the plugin:
import {callout} from './callout'
import {guide} from './guide'
export const schemaTypes = [guide, callout]import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision'
import {codeInput} from '@sanity/code-input'
import {schemaTypes} from './schemaTypes'
export default defineConfig({
name: 'default',
title: 'Acme Docs',
projectId: 'abc123',
dataset: 'production',
plugins: [structureTool(), visionTool(), codeInput()],
schema: {types: schemaTypes},
})Replace abc123 with your project ID. Each choice in the schema matches what Blume can render. Headings start at h2 because the title is already the page heading. The decorators are the four marks Blume carries over. Links accept site paths like /authentication, so editors can point at pages developers own. Images get an alt field, which Blume uses as the alt text. Run npx sanity schemas validate to check the schema.
Import sample content
Three guides give you something to build against: two published, and one whose _id starts with drafts., which makes it a draft that has never been published.
{"_id":"guide-first-message","_type":"guide","title":"Send your first message","slug":{"_type":"slug","current":"send-your-first-message"},"summary":"Install the SDK and send a transactional email.","content":[{"_type":"block","_key":"b1","style":"normal","markDefs":[{"_type":"link","_key":"l1","href":"/authentication"}],"children":[{"_type":"span","_key":"s1","text":"Every request needs an API key. See ","marks":[]},{"_type":"span","_key":"s2","text":"Authentication","marks":["l1"]},{"_type":"span","_key":"s3","text":" to create one.","marks":[]}]},{"_type":"block","_key":"b2","style":"h2","markDefs":[],"children":[{"_type":"span","_key":"s4","text":"Install the SDK","marks":[]}]},{"_type":"code","_key":"b3","language":"sh","code":"npm install @acme/messages"},{"_type":"callout","_key":"b4","tone":"warning","text":"Keep the key out of client code. Anyone with it can send {messages} as you."}]}
{"_id":"guide-templates","_type":"guide","title":"Use message templates","slug":{"_type":"slug","current":"use-message-templates"},"summary":"Write a message once and fill in names and links per recipient.","content":[{"_type":"block","_key":"b1","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"s1","text":"A template holds the subject and the body.","marks":[]}]},{"_type":"callout","_key":"b2","tone":"tip","text":"Preview a template in the dashboard before you send it."}]}
{"_id":"drafts.guide-webhooks","_type":"guide","title":"Receive delivery webhooks","slug":{"_type":"slug","current":"receive-delivery-webhooks"},"summary":"Get a request on your server when a message is delivered.","content":[{"_type":"block","_key":"b1","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"s1","text":"This guide is still a draft.","marks":[]}]}]}From the Studio folder, signed in with npx sanity login:
npx sanity datasets import guides.ndjson --dataset productionWrite the GROQ query
Blume runs one query and turns each document it returns into a page:
*[_type == "guide" && defined(slug.current)]{
_id, _updatedAt, title, summary, slug, content
}The filter skips guides without a slug, which Blume would otherwise serve at a path built from their _id. The projection fetches only the fields Blume maps. Try the query in the Studio's Vision tab, or against the Query API. For a public dataset, no token is needed:
curl -G "https://abc123.apicdn.sanity.io/v2024-01-01/data/query/production" \
--data-urlencode 'query=*[_type == "guide" && defined(slug.current)]{title, "slug": slug.current}' \
--data-urlencode 'perspective=published'The result array lists the two published guides. The draft isn't there: unauthenticated requests never see drafts. For a private dataset, add -H "Authorization: Bearer $SANITY_TOKEN" with the token from the preview section.
Add the Sanity source
In a new folder, run npx blume init, and when it asks where your content lives, pick both filesystem and sanity. That keeps a docs/ folder for your developers' Markdown and adds @sanity/client, the SDK the source loads, to your dependencies. In an existing Blume project, install it yourself:
npm install @sanity/client@8Point the source at your dataset:
import { defineConfig } from "blume";
import { filesystem, sanity } from "blume/sources";
export default defineConfig({
title: "Acme Docs",
content: {
sources: [
filesystem({ root: "docs" }),
sanity({
prefix: "guides",
projectId: "abc123",
dataset: "production",
query: `*[_type == "guide" && defined(slug.current)]{
_id, _updatedAt, title, summary, slug, content
}`,
fields: { description: "summary", body: "content" },
}),
],
},
});fields maps your schema onto the page. Each value is a dot path into what the query returns, and you only list the ones that differ from the defaults:
| Page | Default path | In this schema |
|---|---|---|
| Title | title | title |
| Description | description | summary |
| URL, under the prefix | slug.current | slug.current |
| Body, as Portable Text | body | content |
| Last modified | _updatedAt | _updatedAt |
Other fields never reach the site. The sample guide links to a page your developers own, so add it:
---
title: Authentication
description: Create an API key and send it with every request.
---
Send your key as a bearer token in the `Authorization` header.Run npx blume dev. The two published guides appear at /guides/send-your-first-message and /guides/use-message-templates, in a sidebar group of their own, sorted by title since the source maps no order field. The draft isn't there, and neither are the install command or the warning in the first guide.
Map the custom blocks
That gap is the edge of what Blume converts for you. It handles standard Portable Text blocks: paragraphs, headings, quotes, bulleted and numbered lists (nested too), bold, italic, inline code, strikethrough, and link annotations. It also handles images, which link to Sanity's CDN without their crop or hotspot. Every other block type, including code and callout, becomes a comment that renders nothing, with no warning. Other annotations keep their text and lose the link.
You map the rest with serializers, one function per block type. They're set on sanitySource, the engine behind sanity(), which you pass to custom(). Replace the config:
import { defineConfig } from "blume";
import { custom, filesystem } from "blume/sources";
import { sanitySource } from "blume/sources/sanity.ts";
const TONES = ["note", "tip", "warning", "danger"];
// A string field on a custom block, or "" when it's missing.
const field = (block: Record<string, unknown>, name: string) => {
const value = block[name];
return typeof value === "string" ? value : "";
};
// 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}`;
};
// Backslash-escape every ASCII punctuation character, so an editor's
// { or < renders as written instead of being read as MDX.
const escapeText = (text: string) =>
text.replace(/[\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]/g, "\\$&");
export default defineConfig({
title: "Acme Docs",
content: {
sources: [
filesystem({ root: "docs" }),
custom(
sanitySource({
name: "guides",
prefix: "guides",
projectId: "abc123",
dataset: "production",
query: `*[_type == "guide" && defined(slug.current)]{
_id, _updatedAt, title, summary, slug, content
}`,
fields: { description: "summary", body: "content" },
serializers: {
// @sanity/code-input stores { code, language }.
code: (block) => fence(field(block, "code"), field(block, "language")),
// The callout type from the Studio schema: { tone, text }.
callout: (block) => {
const tone = field(block, "tone");
const directive = TONES.includes(tone) ? tone : "note";
return `:::${directive}\n${escapeText(field(block, "text"))}\n:::`;
},
},
})
),
],
},
});sanitySource is the engine the custom sources page documents. A serializer returns MDX, and setting any serializer writes the source's pages as MDX, so a :::warning directive renders as a callout. Blume escapes the text it converts itself, but a serializer's output goes into the page as written. An editor's { or <b> would break the page, so pass their text through escapeText, which backslash-escapes every ASCII punctuation character. fence writes a fence longer than any run of backticks inside the code. The first guide now becomes:
Every request needs an API key. See [Authentication](/authentication) to create one.
## Install the SDK
```sh
npm install @acme/messages
```
:::warning
Keep the key out of client code\. Anyone with it can send \{messages\} as you\.
:::custom() gives up some of sanity()'s checks. It declares no package or secret, so Blume no longer checks for @sanity/client or SANITY_TOKEN, and the dev server's cached copy doesn't notice config changes. After you edit a serializer or the query, run npx blume sync.
Preview drafts
Drafts are private in Sanity, so reading them takes a token. From the Studio folder, create one with read-only access and put it in the docs project's .env.local, which Blume loads for you:
npx sanity tokens add "Blume docs" --role=viewerecho "SANITY_TOKEN=paste-your-token-here" >> .env.local
echo ".env.local" >> .gitignoreRun npx blume dev --preview. Receive delivery webhooks appears, and a published guide with unpublished edits shows the draft instead. Without the flag, even the dev server shows only published content. The dev server serves a snapshot, so after an edit in Sanity, run npx blume sync --preview. Keep the flag: a plain blume sync regenerates the site from published content. To re-fetch on its own, add pollInterval: 30 (seconds) to sanitySource. Preview and published content are cached separately, so a production build never falls back to drafts.
Builds follow the same flag:
blume build | blume build --preview | |
|---|---|---|
| Sanity content | Published documents only | Drafts in place of published versions, and unpublished drafts |
| Sanity endpoint | The API CDN | The live API |
| Token | Only for a private dataset | Always |
Markdown with draft: true | Left out | Included |
Deploy with protected previews
Blume reads Sanity while the site builds. 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 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
SANITY_TOKENfor the Preview environment. Add it to Production too only if your dataset is private. - Under Deployment Protection, turn on Vercel Authentication with Standard Protection, which protects every deployment except your production domains.
Blume reads the token while the site builds, 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.
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 sanity.io/manage, open your project, go to API, and create a webhook:
- URL: the deploy hook URL
- Dataset:
production - Trigger on: create, update, and delete
- Filter:
_type == "guide" - HTTP method: POST
Sanity's webhooks ignore drafts by default, so publishing, unpublishing, or deleting a guide starts a build, and saving a draft doesn't. 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
A code block or callout is missing from the page
No serializer matches that block's _type, so Blume replaced it with an unsupported Portable Text block comment, which renders nothing. Add a serializer keyed by that exact type name, then run npx blume sync if the dev server is running.
A Sanity page fails to build with an MDX error
An error like Could not parse expression with acorn means a serializer returned an editor's text unescaped, and a { or < in it read as code. Wrap the text in escapeText.
Drafts don't show up with --preview
Unauthenticated requests never see drafts, so check that SANITY_TOKEN is set where the build runs and belongs to the same project. In dev, run npx blume sync --preview to replace the cached copy.
A preview build warns that previewDrafts was renamed
The Sanity client prints this when Blume asks for the previewDrafts perspective, which Sanity has renamed to drafts. Sanity still accepts both, so drafts load as usual.
No Sanity pages, and a warning that SANITY_TOKEN is not set
sanity() raises BLUME_MISSING_SECRET whenever the variable is unset. A public dataset's production build doesn't need it, so there the warning is safe to ignore. A private dataset does: Sanity answers a query without a token with an empty result rather than an error, so the build succeeds with no Sanity pages. Set the token, and redeploy, since a changed variable only applies to new builds.
The build can't load the source
BLUME_SOURCE_FETCH_FAILED carries the reason: Sanity's own error for a wrong project ID or dataset, or a message that the source needs @sanity/client when it isn't installed. Blume fetches before it checks your dependencies, so on a fresh build the fetch error is what you see. When a snapshot exists, Blume serves it with a BLUME_SOURCE_OFFLINE warning instead, but a fresh build machine has none, so there the build stops and your last deployment stays live.
Two pages claim the same URL
BLUME_DUPLICATE_ROUTE means a file in docs/guides/ and a Sanity guide resolve to the same path. Keep Sanity's prefix out of the docs folder, or give the source another prefix.
Next step
Connect your dataset
Pick filesystem and sanity when init asks where your content lives, then point the source at your project ID and query.
npx blume initA step here not working for you? Report a broken step.