Skip to content
4 min read

Moving a blog from a CMS to Markdown files

Dropping a hosted CMS for files in the repo: exporting the content, converting rich text, keeping every URL alive, and verifying nothing was lost.

On this page
  1. Write down what has to survive
  2. Export once, convert from the file
  3. The front matter is a schema
  4. Keep every URL alive
  5. Three checks, in order
  6. The operating model, not the speed

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.

python
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:

python
def to_markdown(block):
    if block["_type"] == "code":
        return f"```{block.get('language','')}\n{block['code']}\n```"
    if block["_type"] == "image":
        return f"![{block.get('alt','')}]({image_url(block)})"
    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.

yaml
---
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.

bash
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 one

For everything in that output, a permanent redirect:

next.config.tsts
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.

Frequently asked

Is it worth moving a blog from a CMS to Markdown?

If you are the only author and comfortable with git, usually yes: the content is version-controlled, reviewable in pull requests, deployable as static pages, and depends on no external service. If non-technical people publish regularly, the editing interface a CMS provides is a real benefit you would be removing.

How do I keep SEO rankings when migrating a blog?

Keep every URL identical where you can, and redirect permanently where you cannot. Preserve titles, descriptions and publish dates, keep the sitemap complete, and check the new pages render the same content. Ranking loss in a migration almost always traces to URLs that changed without a redirect.

How do I convert rich text from a headless CMS to Markdown?

Write a script against the CMS's export or API that walks its document format and emits Markdown, handling each block type you actually use — headings, lists, code, images, links. Keep it re-runnable so you can fix a conversion bug and regenerate everything rather than hand-editing output.

What do I lose by moving to Markdown files?

The editing interface, scheduled publishing unless you build it, image uploads and transformations, and any workflow with non-technical contributors. What you gain is version control, review, no external dependency and no per-seat cost. Whether that trade is good depends entirely on who writes.

Found this useful?Share on XShare on LinkedIn

Related reading