Skip to content
All posts
2 min read

Moving My Blog From Sanity to MDX

Pulling my posts out of a hosted CMS, converting Portable Text to MDX, and the content that broke the build.

  • #mdx
  • #sanity
  • #nextjs

When I rebuilt this site I decided my posts should live in the repo as MDX rather than in a hosted CMS. That left one job: get five years of writing out of Sanity and into markdown files without retyping any of it. It went smoothly, apart from the two posts that broke the build. Those turned out to be the interesting part.

Getting the data out

Sanity has a query API, and if your dataset allows public reads you do not even need a token. One GROQ query gave me every post with its body.

*[_type == "post" && defined(slug.current)] | order(publishDate desc) {
  title,
  subtitle,
  "slug": slug.current,
  publishDate,
  "cover": coverImage.asset->url,
  body
}

I hit that endpoint, saved the JSON, and now I had all my content sitting in one file. The catch is that the body is not markdown.

Portable Text is not markdown

Sanity stores rich text as Portable Text, which is a tree of typed blocks and spans, not a string. A heading is an object with a style. Bold is a mark on a span. A link is an annotation referenced by key. To get MDX out of it you walk the tree and serialise each node yourself.

The core of my converter is small.

function renderBlock(block) {
  const text = block.children.map(renderSpan).join("");
 
  if (block.listItem === "bullet") return `- ${text}`;
  if (block.style === "h2") return `## ${text}`;
  if (block.style === "h3") return `### ${text}`;
  return text;
}

renderSpan handles the marks on each span: wrap it in double asterisks for bold, backticks for inline code, and look up the annotation for a link. Add a case for fenced code blocks and you have covered everything a normal blog post uses.

The posts that broke the build

Two posts failed to compile, and the error was about await in a place it should not be. That was confusing, because none of my writing uses await.

The cause was MDX being cleverer than plain markdown. MDX reads { as the start of a JavaScript expression and < as the start of a JSX tag. A line that begins with import is treated as a real module import. Years ago I had written a couple of code snippets as plain paragraphs rather than proper code blocks, so lines like this were sitting in the body as ordinary text:

import { User } from "lucide-react";
 
<User size={20} />

MDX saw a real import and a real component, tried to run them, and fell over. The fix was two rules in the converter. Escape the characters MDX cares about in ordinary prose:

const escaped = text.replace(/{/g, "\\{").replace(/</g, "\\<");

And when a plain block clearly is code, because it starts with import, const or a tag, wrap it in a fence so MDX leaves it alone. That both fixed the build and made those snippets render as the code they always should have been.

What I would tell you

If you are doing the same move, budget your time for the body, not the export. Pulling the data is a single query. Turning structured rich text into good markdown is where the work is, and your own old content will surprise you. Mine certainly did.