A hosted CMS makes sense when several people publish and none of them want to see a repository. When the author is one engineer, it is a subscription, an API dependency and a second place where the site's content lives.
Moving that content into the repository as Markdown is about a day of work. Keeping every URL alive while you do it is the part that needs care, and the part that bites.
Write down what has to survive
Before touching anything, decide what must be byte-identical afterwards.
Slugs are non-negotiable: every published URL either stays the same or gets a permanent redirect. Publish dates matter more than they look, because they appear in the page, the feed and the structured data, and rewriting them republishes your whole archive to anyone subscribed. Titles and descriptions are already earning impressions, and changing them mid-migration means you cannot tell a migration problem from a copy change when the graph moves. And every image reference has to resolve on the other side.
Everything else is free. Tags, author boxes, layout, related posts, all of it can be rebuilt however you like. It is the identifiers that are expensive, and only the identifiers.
Export once, convert from the file
Pull the content out through the CMS's export or its API. Most headless CMSs expose a read endpoint that needs no token for published documents, which is enough.
import requests, json
QUERY = '*[_type == "post"]{title, "slug": slug.current, publishedAt, description, tags, body}'
r = requests.get(API_URL, params={"query": QUERY}, timeout=60)
docs = r.json()["result"]
json.dump(docs, open("export.json", "w"), ensure_ascii=False, indent=2)Export to a file, then convert from that file. Re-running the conversion against a local snapshot is fast and repeatable; re-running it against an API is slow and rate-limited. And you will re-run it, because the first pass always gets code blocks or nested lists wrong.
The conversion walks the document format and emits Markdown per block type:
def to_markdown(block):
if block["_type"] == "code":
return f"```{block.get('language','')}\n{block['code']}\n```"
if block["_type"] == "image":
return f"})"
text = "".join(span["text"] for span in block.get("children", []))
style = block.get("style", "normal")
return {"h2": f"## {text}", "h3": f"### {text}", "blockquote": f"> {text}"}.get(style, text)Handle only the block types you use. A general converter for a format you control is wasted work; a specific one you can read end to end is about three screens long.
The front matter is a schema
Design it once, because changing it later means touching every file.
---
title: "Post title in sentence case"
description: "Under 160 characters, written for the search result."
date: 2026-10-29
tags: [nextjs, seo]
---Add fields when a feature needs them rather than in anticipation. Anything derivable, like reading time or word count, gets computed at build time from the content instead of stored, because a stored number drifts the moment somebody edits a paragraph and nothing tells you.
Keep every URL alive
Diff the old sitemap against the new one. Anything on the old list and not the new is either a redirect you owe or a page you have lost.
curl -s https://old.example.com/sitemap.xml | grep -oP '(?<=<loc>)[^<]+' | sort > old.txt
curl -s http://localhost:3000/sitemap.xml | grep -oP '(?<=<loc>)[^<]+' | sort > new.txt
comm -23 old.txt new.txt # on the old site, missing from the new oneFor everything in that output, a permanent redirect:
export default {
async redirects() {
return [
{ source: "/posts/:slug", destination: "/blog/:slug", permanent: true },
];
},
};permanent: true is a 308 and passes ranking signals along. A temporary redirect during a migration tells search engines to keep the old URL, which is precisely backwards from what you want.
Three checks, in order
Count first. Posts in the export against Markdown files on disk. A mismatch means the converter skipped documents, usually drafts, occasionally not.
Then read the conversion. Open the five most-trafficked posts and actually read them. Code blocks, nested lists and tables are where converters fail, and they fail silently, producing output that is valid Markdown and wrong.
Then crawl both. Fetch every URL on the new site and confirm a 200, the expected title and a self-referencing canonical. Canonicals are easy to get wrong in a move and getting them wrong is its own kind of damage.
After that, watch Search Console for a fortnight. A migration that worked produces a flat impressions line. A dip starting on the day of the move is a URL problem, and the sitemap diff above will usually name it before you have finished worrying.
The operating model, not the speed
Not speed. A CMS-backed site can be statically generated and served just as fast, and anyone telling you otherwise is selling something.
What changes is the operating model. Content lives in the repository, every edit is a reviewable diff, publishing is a commit, and there is no external service to be down, rate-limited or billed for. The whole site becomes files rendered at build time with nothing to call at request time.
One thing worth doing on the way out, which people forget: if the old CMS held form submissions or user data, delete the dataset or lock it down. A read-open dataset nobody is watching any more is a quiet liability that will outlive the migration by years.
The content moves easily. The identifiers are the work. Keep the slugs, keep the dates, redirect what you cannot keep, and diff the sitemaps before calling it done.