Sanity programmatic publishing writes blog documents to the Content Lake through the Sanity API. You turn markdown into Portable Text blocks. You attach hero images. You save posts as Drafts for Studio review. No more paste work in the visual editor.
According to Ahrefs research from April 2025, 74.2% of new webpages contain AI-generated content. Teams draft in markdown. Sanity schemas want Portable Text on the `body` field. Sanity programmatic publishing bridges that gap with API writes.
TL;DR
- The MDP Gate stacks Markdown files, a Portable Text converter, and QA-gated Draft publish.
- `blog-md-to-portable-text.mjs` emits blocks, tables, links, and `youtubeVideo` custom types.
- `publish-from-files.mjs` enriches, runs hard QA gates, uploads heroes, then calls `createOrReplace`.
- Draft-first publish keeps editors in control. Studio sets `Done` and `publishedAt` to ship.
- Vendor bulk tools skip teardown. This page documents Metaflow's production pipeline.
What sanity programmatic publishing means in 2026
Programmatic publishing in a Sanity context means your pipeline assembles the post document. Not a human in Studio. You set title, slug, excerpt, SEO object, cover image reference, and a `body` array of Portable Text blocks. You authenticate with `SANITY_API_TOKEN`, call the client, and write.
Studio paste vs API createOrReplace
Manual workflow: copy markdown into Studio, fix formatting, upload a hero, set categories, save as Draft. Works for one post. Breaks at ten posts per month when every draft originates in chat or a files folder.
Programmatic workflow: a Node script loads `brief.json` plus `draft.md`, converts body markdown, runs QA, optionally generates a hero via image API, then upserts `post-{slug}` with `status: 'Draft'`. The content engineer reviews in Studio. Same outcome, zero paste labor.
Why Draft-first beats live publish
Metaflow's `publishBlogPost` sets `status: 'Draft'` and leaves `publishedAt` null. Automated pipelines should not bypass editorial review. Programmatic speed belongs in assembly, not in going live unseen. A Draft landing in Studio is the contract: machines prepare, humans approve.
Where programmatic fits content engineering
If your team already runs an information gain content framework and scores briefs before drafting, sanity programmatic publishing is the Systems layer. The script enforces gates and writes only when QA passes. Without it, you have great markdown files and a Studio queue nobody wants to populate.
| Searcher need | Where we answer it |
|---|---|
| Define programmatic Sanity publish | Opening + this section |
| Markdown to Portable Text path | MDP Gate + converter teardown |
| Image upload via API | Hero asset section |
| Draft vs live publish | Draft flow section |
| Pick a publish path | MDP vs alternatives table |
SERP consensus: what ranking pages already say
The live SERP for sanity programmatic blog publishing clusters into three camps. Each is useful. Each is incomplete.
Portable Text serialization guides
Sanity's presenting Portable Text guide is the main read path. It lists `@portabletext/react` and `@portabletext/block-tools`. It covers join syntax for internal links. It is strong on rendering. It says little about bulk publish from markdown files.
Vendor integration pages
Tools like SEOmatic advertise Sanity integrations with Portable Text output and drip publishing. Marketing pages describe scale. They do not show QA hard gates, custom block types, or a Draft-first editorial contract. You learn that programmatic SEO exists. You do not learn how to wire your own converter.
The markdown escape hatch
A popular DEV post recommends `sanity-plugin-markdown` to avoid Portable Text entirely. Fair for teams whose front-end renders markdown strings. Wrong trade for schemas that already define `table`, `youtubeVideo`, or other custom Portable Text types. You lose structured blocks when you store raw markdown.
What the SERP misses: a named framework connecting markdown ingress, custom Portable Text emission, image asset upload, and pre-publish QA before `createOrReplace`.
The MDP Gate: Metaflow's three-stage publish stack
The MDP Gate is Metaflow's original three-layer stack for sanity programmatic publishing. Each layer maps to a script in `apps/web/scripts/`.
M-layer: Markdown authoring
Input: hand-authored or chat-authored `draft.md` plus structured `brief.json` (persona, PAA, intent gaps, IG score). No LLM inside the publish script. Cursor drafts in chat; the script handles mechanics. This matches the Claude skills for blog content writing pattern: separate authoring from publish.
P-layer: Portable Text conversion
`markdownToPortableText()` in `blog-md-to-portable-text.mjs` reads the draft line by line. It builds paragraph blocks. It finds markdown tables. It maps headings to h2, h3, and h4 styles. It parses links and bold text. It turns `::youtube` lines into `youtubeVideo` blocks. The result is a block array ready for Sanity.
D-layer: Draft publish with QA
`publish-from-files.mjs` orchestrates enrich, QA, optional hero, then `publishBlogPost`. Hard gates block `--apply` unless brief IG is at least 7, tables are at least 2, PAA is answered, persona and JTBD align, and link minimums are met. Pass `--force` only when you want a broken Draft for manual Studio repair.
| MDP layer | Canonical script | Input | Output |
|---|---|---|---|
| M: Markdown | Chat + draft.md | Brief + prose | Publish-ready markdown |
| P: Portable Text | blog-md-to-portable-text.mjs | Markdown string | body block array |
| D: Draft | publish-from-files.mjs | Brief + enriched MD | Sanity Draft document |
Portable Text conversion: what blog-md-to-portable-text.mjs actually emits
The converter is narrow by design. It covers what Metaflow blog posts use today. Sanity programmatic publishing does not need every Portable Text feature on day one.
Block styles and inline marks
Headings become blocks with `style: 'h2'`, `'h3'`, or `'h4'`. Paragraphs use `style: 'normal'`. Inline parsing handles `anchor` as `link` markDefs, `bold` as `strong`, and `italic` as `em`. Each span gets a random `_key`. Portable Text requires stable keys for Studio patches.
Custom blocks: table and youtubeVideo
Tables trigger when a pipe-delimited row is followed by a separator row matching `|----|`. Rows map to `_type: 'table'` with `cells` arrays. The first row sets `isHeader: true`. YouTube embeds use the `::youtube` directive, not raw iframes. The front-end `@portabletext/react` component map renders `youtubeVideo` consistently with AEO-oriented post structure.
What the converter deliberately skips
Placeholder FAQ stubs are stripped. HTML tables are unsupported; use markdown pipe syntax. Code fences are not emitted yet. Add a `code` block handler when your schema requires it. For HTML ingress, Sanity documents `@portabletext/block-tools` `htmlToBlocks` as the inverse path. MDP chooses markdown-as-source because editorial drafts already live in `.md` files.
Image upload and hero assets in the publish path
Hero images are cover assets, not inline Portable Text images. Metaflow separates them so chat-authored posts get a consistent 16:9 spectral graphite hero without editors uploading in Studio.
generateHeroImage and OpenRouter
With `--with-images` and `OPENROUTER_API_KEY` set, `publish-from-files.mjs` calls `generateHeroImage(title)` from `blog-images.mjs`. Gemini returns a URL. The script saves a `hero-image.json` artifact for audit.
uploadImageFromUrl to Sanity assets
`uploadImageFromUrl` fetches the image buffer and calls `client.assets.upload('image', buffer, { filename })`. Sanity returns `asset._id` as a reference target, not a hotlinked URL.
coverImageRef on the post document
`coverImageRef(assetId, alt)` builds `{ _type: 'image', asset: { _ref }, alt }` on the post. `publishBlogPost` also mirrors it into `seo.ogImage`. Inline body images would need a separate `image` block type in the converter. Hero upload covers the batch-blog case where one cover suffices.
The Draft flow: QA hard gates before createOrReplace
`publish-from-files.mjs` is four stages. Understanding them prevents surprise QA failures when you run sanity programmatic publishing at volume.
Dry-run vs --apply
Default invocation is dry-run. Enrich writes `enriched.md`. QA prints metrics. No Sanity write happens. Add `--apply` to call `createBlogSanityClient()` and upsert. Add `--with-images` for hero generation. Missing `SANITY_API_TOKEN` fails at apply by design.
Enrich: internal links and YouTube injection
Enrich runs `injectInternalLinks` from `blog-internal-links.mjs` and `injectYouTubeEmbed` from `brief.asset_plan.youtube`. It appends FAQ sections only if the draft lacks them. Real answers must exist in the draft. Placeholders fail QA.
When --force is acceptable
`--force` publishes despite QA failure so editors can inspect a broken Draft in Studio. Use sparingly. The content engineering framework treats force-publish as a systems failure signal, not a normal ship path.
QA checks include word count, H2 count vs outline, tables, bullets, humanizer cadence, opening stat, internal links, external links, meta lengths, primary keyword density, and full PAA coverage.
MDP vs alternatives: which Sanity publish path to pick
Not every team needs the MDP Gate. Pick based on who authors, what your schema requires, and whether editorial QA is a hard rule. Sanity programmatic publishing still works without MDP if you accept manual Studio work.
Studio manual entry
Best when volume is low and writers already work in Studio. Portable Text fidelity is perfect because the editor produces blocks natively. Does not scale when drafts originate outside Sanity.
Markdown plugin storage
`sanity-plugin-markdown` stores markdown strings. Front-end renders with `react-markdown`. Fast to adopt. You sacrifice custom Portable Text types unless you bolt on separate fields.
Vendor bulk integrations
SEOmatic-class tools connect via Content Lake API and claim Portable Text output. Good for teams buying scale. You inherit their QA model and their template logic, not yours.
| Publish path | Portable Text fidelity | Editorial QA gate | Custom blocks | Best for |
|---|---|---|---|---|
| Studio paste | High | Manual | Full | Low volume, Studio-native authors |
| Markdown plugin | Low (string storage) | Manual | Limited | Markdown-first front-ends |
| Vendor bulk (SEOmatic) | Medium (vendor-defined) | Vendor-defined | Vendor-defined | Outsourced programmatic SEO |
| MDP Gate (scripted) | High (custom converter) | Automated hard gates | table, youtubeVideo | Content engineering teams |
For B2B SaaS brands publishing ten or more posts monthly, sanity programmatic publishing through the MDP Gate preserves Portable Text structure. It enforces the same gates as every other Metaflow batch post. It lands Drafts editors can approve without reformatting.
Install path: place `brief.json` and `draft.md` under `apps/web/scripts/blog-publish-plan/briefs/`, dry-run QA, fix issues, then run `publish-from-files.mjs` with `--slug`, `--apply`, and `--with-images`. Review the Draft in Studio. Set status to Done and `publishedAt` when ready to ship. Explore more pipelines in the Metaflow learning center.
Frequently Asked Questions
How do you publish to Sanity programmatically?
Authenticate with a write-enabled API token. Instantiate `@sanity/client`. Build a document object matching your schema. Call `create`, `createOrReplace`, or `patch`. Metaflow's `publishBlogPost` uses `createOrReplace` on `post-{slug}` so re-runs update the same Draft. The body field must be a Portable Text array unless your schema uses a markdown type. Sanity programmatic publishing through MDP automates that assembly.
What is Portable Text in Sanity?
Portable Text is JSON for rich text. It is an ordered list of blocks and custom objects. Each text block has child spans and link marks. It also has a style: normal, h2, or h3. The Portable Text specification works with any CMS. Sanity stores it in the Content Lake. GROQ returns it as written. Your site renders it with `@portabletext/react`.
Can you use Markdown with Sanity CMS?
Yes, three ways. Store markdown strings via `sanity-plugin-markdown` and render client-side. Paste markdown into Studio's block editor and let Studio convert to Portable Text. Or keep markdown as pipeline source and convert before API write, as MDP does. The third path preserves custom block types your schema already defines and is the core of sanity programmatic publishing.
How do you upload images to Sanity via API?
Use `client.assets.upload('image', buffer, { filename })` with your auth client. You get back an asset `_id`. Put that ref on image fields. Metaflow pulls hero URLs into a buffer first. See `uploadImageFromUrl` in `blog-images.mjs`. Sanity's Assets API lists rate limits and file types.
What is the Sanity Content Lake API?
The Content Lake is Sanity's hosted document store exposed via HTTP APIs and the JavaScript client. GROQ queries read documents. Mutations write them. Real-time listeners propagate changes to Studio and connected front-ends. Sanity programmatic publishing is authenticated mutations against that store. Same surface Studio uses, without the UI.
How do you create draft documents in Sanity?
Set a `status` field to `Draft` on create and leave `publishedAt` null. Your Next.js front-end should filter unpublished posts. Metaflow sets `status: 'Draft'` in `publishBlogPost` and logs a reminder to flip status in Studio. MDP uses an explicit status field for clarity in batch pipelines.
For broader context, see our roundup of claude skills marketing, and explore Claude skills for SEO, and common Claude Code content mistakes for related setup guidance.
