· Ben · process · 7 min

Migrate a headless CMS to MDX without breaking a URL

Export is the easy half. How to move a headless CMS blog to MDX with a verified redirect map, 308s that survive Google, and a diff you can review line by line.

You have 340 posts in a database CMS, a Search Console property that says a decent chunk of them rank, and a growing suspicion that the editor is the reason nobody has published since March. So you want to migrate the headless CMS to MDX and put the posts in the repo where the rest of the product lives.

The content will move fine. Rich text is annoying, not hard. The thing you can actually break is the URLs, and Google already has an opinion about every one of them.

Here's the order I'd do it in, and the part everyone skips.

The migration is a redirect map, not an export script

What you ship at the end of this is not a folder of .mdx files. It's a two-column table: every URL Google currently knows about, and the URL it resolves to after cutover. No blank cells.

Write that file first, before you touch the export. It's what you review and what you test against. It's also the only thing that tells you when you're done. Everything else in the migration is generating the right-hand column.

The landing pages that promise to move your blog "in seconds" and keep your rankings never show you this table, because the table is where the work is. Exporting entries is one command. Deciding what /blog/2019/03/postgres-tuning becomes, and proving it still resolves, is a week.

Inventory before you export

Pull your URL list from two places, and neither of them is the CMS.

The first is your live sitemap. The second is Search Console's Pages report, exported. The CMS knows about entries that are currently published. Google knows about the ones you forgot: the slug you renamed in 2022 that still has three backlinks, the ?preview= route that got indexed because someone shared it in Slack, the tag page that outranks the post it links to.

Diff those two lists against the CMS entry list. Every URL that appears in Google's set but not the CMS's set is a decision you're about to make by accident unless you make it on purpose.

While you're in there, check for chains. If /posts/foo already 301s to /blog/foo, and you're about to add /blog/foo/blog/2024/foo, you now have a two-hop chain that you'll be maintaining forever. Flatten chains into the map. Every old URL points directly at its final destination, one hop.

Rich text doesn't round-trip, and pretending otherwise costs you a week

Contentful rich text and Sanity's Portable Text are ASTs. They are not Markdown with different punctuation. Converting them means writing a serializer, and the default serializer covers paragraphs, headings, lists and links, which is roughly 80% of your body content and none of the interesting parts.

On Sanity, the current path is @portabletext/markdown. The older @sanity/block-content-to-markdown is deprecated, and Sanity's own docs mark the Portable Text to Markdown route as unmaintained as of December 2025. Their course on it walks through writing custom serializers for code blocks, images and callouts, which tells you exactly what the defaults don't handle. Budget for that.

Contentful is friendlier. contentful-export (or contentful space export through the CLI) dumps the content model, entries and assets to a single JSON file, and from there you're writing a script against a documented tree.

WordPress is the odd one out because it hands you rendered HTML. /wp-json/wp/v2/posts paginates and returns content.rendered, and the standard move is to run that through Turndown. One setting to change before you run it on 340 files: Turndown escapes Markdown special characters with backslashes by default. Leave it on and you get a repo full of \_ and \* in the middle of prose, and you will find them one at a time for the next month.

The components you're going to lose

Accordions. CTA blocks. Two-column layouts. The pricing table someone built as a custom block type in 2023.

None of these have a Markdown equivalent. For each one you have exactly two options: write an MDX component now and serialize the block into it, or drop the content. There is no third option where it converts cleanly.

Make that call per component, write it down, and put it in the PR description. The worst version of this migration is the one where a marketing person finds the missing CTA block six weeks after cutover and nobody remembers whether it was a decision or a bug.

Frontmatter is a schema whether you write one or not

Your CMS enforced a content model. The moment the posts become files, that enforcement is gone unless you rebuild it.

Infer the field names, date format and slug style from what the site already renders, not from what the CMS called things internally. If your templates read description and your dates render as Aug 19, 2026, that's your contract. Match it, then validate it.

Astro's content collections do this with a Zod schema at build time. A post with a missing description or a date in the wrong format fails the build with a MarkdownContentSchemaValidationError. That is the feature, not the annoyance. The CMS let you publish a post with an empty meta description for two years. The build won't let you merge one.

308 is not 301, and your host has a limit

In Next.js, redirects() in next.config.js takes source, destination and permanent. permanent: true emits a 308, not a 301. permanent: false emits 307.

Both 301 and 308 are permanent and both pass ranking signals, so for Google this is a non-event. The reason to know it before cutover rather than after: 308 preserves the request method, and some older link checkers, monitoring probes and internal tools in your stack care about that. Find out on a staging deploy, not from a broken uptime alert.

The second constraint is a hard number. The Next.js docs state that Vercel caps redirects at 1,024. If your map has 340 rows you're fine. If you're migrating a six-year-old marketing site with every date-path variant, you'll blow through it, and at that point the redirects belong in middleware backed by a lookup, or at the edge in front of the app. Not in the config file, where they'll silently truncate.

None of the migration guides say this out loud, so: Search Console's Change of Address tool is domain-level. A CMS-to-MDX move usually keeps the same domain, so the tool does not apply to you at all. Path-level 301s and 308s are the entire mechanism. There is no button.

Ship it as one branch a human can read

400 new files, one config change, one pull request.

That's the shape. Not a big-bang deploy at 2am. Not a content freeze, not a spreadsheet of manual copy-paste. A branch that adds the MDX, adds the redirect map, and can be reverted with one click if the crawl comes back wrong.

The review is the point. A teammate can read the diff on the ten posts that matter most, spot that the code fences lost their language hints, and leave a line comment on the exact line. The case for reviewing content the way you review code applies to everything else in the repo, and a migration is where it pays off hardest, because a migration is 400 changes made by a script that nobody watched.

What "it worked" looks like, and when you can stop watching

Three checks, all automatable, all run against the old sitemap you saved in step one.

Crawl every URL in the old list and assert it returns 308 (or 301) and that following it lands on a 200. Not a 200 anywhere. A 200 at the destination in your map. Then diff the count of URLs in the old sitemap against the count of files in the new content directory plus intentional removals. If those numbers don't reconcile, you have an orphan and you should find it before Google does.

Then wait. Two numbers get quoted for how long, and they don't agree.

180 days vs 1 year Google's Change of Address tool asks you to maintain redirects for at least 180 days; John Mueller's public advice is to keep them for at least a year.: Mueller's reasoning: it can take Google six months to a year to fully recognize that a site has moved, and its systems need to see each redirect several times to record the change. Google's own docs also advise keeping the old domain registered for at least a year, and note that after the 180-day window it stops recognizing any relationship between the old and new sites. (Change of Address tool - Search Console Help)

Both of those numbers come from the domain-move guidance, and your move is same-domain, so nothing expires on a schedule here. Take the longer one anyway. It's the only public number Google gives, and 180 days is the floor for a case with a tool behind it, not for path-level redirects nobody is tracking. Keep the redirects longer than feels necessary. They cost you nothing except a config file that's slightly ugly.

Watch the Pages report weekly for the first month. You're looking for old URLs moving to "Page with redirect" and new URLs moving to "Indexed," in roughly matching counts.

What you break on purpose

The honest version of "without breaking a URL" is: decide which ones you're breaking, on the record, before you merge.

Tag pages usually go. Most CMS tag archives are thin, near-duplicate pages that got indexed because they existed. If one of them ranks, keep it and build the equivalent route. If none do, 410 them and move on. A 410 is a clearer signal than a 301 to your homepage, which is what most people do and which Google treats as a soft 404 anyway.

Paginated archives (?page=2 and friends) go. Author pages go unless you have more than one author and readers actually use them. Preview routes go, and if they got indexed, that's a noindex you should have shipped years ago.

Date-path slugs are the one to think about. Flattening /blog/2019/03/slug to /blog/slug is worth doing, and every one of those old paths needs a row in the map. This is usually where the redirect count gets large enough to matter.

What's better on the other side, and what isn't

Posts are files. The build catches bad frontmatter before it ships, and history is git log instead of a revision dropdown. Every change to published content arrives as a diff someone can read.

What you give up is real. There's no WYSIWYG for non-technical writers, and if you have any, you need to solve that on day one rather than promising to solve it later. The git CMS options are worth a look before you assume everyone will learn frontmatter. Scheduled publishing becomes a workflow you write. Image optimization becomes your problem, and the assets currently sitting on a CDN you're about to stop paying for need to come with you, with their URLs rewritten in the same PR.

The thing you get that's hard to see from the CMS side: once the blog is a folder of MDX, the repo is the integration. Every tool that speaks git speaks to your content. That's the whole reason Contentcron works on file-based blogs and doesn't work on Contentful, Sanity, Strapi or WordPress. There's no file to open a pull request against, which is the practical difference between a git-based CMS and a headless one.

If you're staring at a CMS export button and a Search Console property you don't want to torch, write the redirect map first. Everything after that is a script and a review. And when the posts are files and publishing has quietly become the thing nobody owns, the first article is free, no card required.