Migrating from ReadMe to Mintlify
This guide is part of Mintlify Setup & Migration within Developer Portal Frameworks & UI Setup. It walks through a concrete platform migration end to end, with the emphasis on the part that determines whether it goes well: preserving the URLs people and search engines already use. Content conversion is mechanical; link preservation is not, and it is where migrations quietly fail.
Problem & Context
Both platforms store prose as Markdown, which makes migration look like a copy. It is not, because everything around the prose is platform-specific: the callout syntax, the component set, the front-matter fields, the navigation tree, and — most consequentially — the URL structure.
The URL problem is the one that causes lasting damage. A portal that has been live for two years has accumulated inbound links from blog posts, Stack Overflow answers, support tickets, internal wikis, and search-engine indexes. None of those update when you migrate. If /docs/authentication becomes /authentication and nothing bridges the two, every one of those links breaks at once, search rankings reset, and the support load goes up for months.
The content conversion is a scripted find-and-replace with a review pass. The redirect map is derived work that must be complete, and the difference between a migration people remember as smooth and one they remember as painful is almost entirely whether that map was built from data or from memory.
Step-by-Step Solution
1. Inventory the URLs that matter
Three sources, unioned. Each catches something the others miss:
# a. Everything the old site claimed to publish
curl -s https://old-docs.example.com/sitemap.xml \
| grep -oP '(?<=<loc>)[^<]+' | sed 's|https://old-docs.example.com||' | sort -u > urls-sitemap.txt
# b. Everything people actually visited (export from analytics, 12 months)
cut -f1 analytics-pages.tsv | sort -u > urls-visited.txt
# c. Everything other sites link to (export from Search Console)
cut -f1 search-console-links.tsv | sort -u > urls-linked.txt
sort -u urls-sitemap.txt urls-visited.txt urls-linked.txt > legacy-urls.txt
wc -l < legacy-urls.txt
214
That number is your redirect budget. It is almost always larger than anyone’s estimate.
2. Export the content and convert the dialect
Pull the Markdown out, then script the syntax differences:
mkdir -p mintlify/
# Rename to .mdx and normalise front matter keys.
find export/ -name '*.md' | while read -r f; do
out="mintlify/${f#export/}"; out="${out%.md}.mdx"
mkdir -p "$(dirname "$out")"
sed -E \
-e 's/^excerpt:/description:/' \
-e 's/^hidden: true/---REMOVE---/' \
"$f" > "$out"
done
# Convert callouts: ReadMe block syntax -> Mintlify components.
find mintlify -name '*.mdx' -print0 | xargs -0 perl -0pi -e '
s/\[block:callout\]\s*\{[^}]*"type":\s*"warning"[^}]*"body":\s*"([^"]*)"[^}]*\}\s*\[\/block\]/<Warning>$1<\/Warning>/gs;
s/\[block:callout\]\s*\{[^}]*"type":\s*"info"[^}]*"body":\s*"([^"]*)"[^}]*\}\s*\[\/block\]/<Info>$1<\/Info>/gs;
'
Then find what the script missed — leftover platform syntax is the most common post-migration defect:
grep -rn '\[block:' mintlify/ | head
An empty result means the conversion is complete. Anything listed needs a manual pass.
3. Rebuild the navigation in docs.json
Navigation does not transfer. Every page needs an entry, and the entry path must match the file path without its extension:
{
"name": "Acme API",
"navigation": [
{
"group": "Getting started",
"pages": ["introduction", "authentication", "quickstart"]
},
{
"group": "API reference",
"openapi": "openapi/api.yaml"
},
{
"group": "Guides",
"pages": ["guides/pagination", "guides/webhooks", "guides/errors"]
}
]
}
Catch mismatches before they become 404s:
# Every navigation entry must have a file, and every file an entry.
node -e '
const fs = require("fs"), path = require("path");
const nav = JSON.parse(fs.readFileSync("docs.json","utf8")).navigation;
const entries = new Set(nav.flatMap(g => g.pages ?? []));
const files = [];
(function walk(d){for(const e of fs.readdirSync(d,{withFileTypes:true})){
const p = path.join(d,e.name);
if (e.isDirectory()) walk(p);
else if (e.name.endsWith(".mdx")) files.push(p.replace(/^mintlify\//,"").replace(/\.mdx$/,""));
}})("mintlify");
const orphanFiles = files.filter(f => !entries.has(f));
const missingFiles = [...entries].filter(e => !files.includes(e));
orphanFiles.forEach(f => console.log("ORPHAN " + f + " (file with no nav entry)"));
missingFiles.forEach(e => console.log("MISSING " + e + " (nav entry with no file)"));
'
4. Write the redirect map from the inventory
Only changed paths need entries:
{
"redirects": [
{ "source": "/docs/authentication", "destination": "/authentication" },
{ "source": "/docs/quickstart", "destination": "/quickstart" },
{ "source": "/reference/create-invoice", "destination": "/api-reference/invoices/create" }
]
}
Generate the skeleton rather than typing it:
while read -r old; do
new=$(echo "$old" | sed -E 's|^/docs/||; s|^/reference/|/api-reference/|')
[ "$old" != "$new" ] && printf ' { "source": "%s", "destination": "%s" },\n' "$old" "$new"
done < legacy-urls.txt
5. Verify before and after the DNS switch
Run this against the parallel host first, then again immediately after cutover:
#!/usr/bin/env bash
# verify-redirects.sh — every legacy URL must 301 and land on a live page.
set -uo pipefail
HOST="${HOST:?set HOST, e.g. https://docs-next.example.com}"
fail=0
while read -r old; do
first=$(curl -s -o /dev/null -w '%{http_code}' "${HOST}${old}")
final=$(curl -sL -o /dev/null -w '%{http_code}' "${HOST}${old}")
if [ "$first" != "301" ] && [ "$first" != "308" ] && [ "$first" != "200" ]; then
echo "BROKEN ${old} first=${first}" >&2; fail=1
elif [ "$final" != "200" ]; then
echo "DEAD ${old} redirects to a ${final}" >&2; fail=1
fi
done < legacy-urls.txt
[ "$fail" -eq 0 ] && echo "All $(wc -l < legacy-urls.txt) legacy URLs resolve."
exit "$fail"
All 214 legacy URLs resolve.
Complete Working Example
The cutover runbook, ordered so that everything before the DNS change is reversible:
#!/usr/bin/env bash
# cutover.sh — run each phase deliberately; nothing before phase 4 is irreversible.
set -euo pipefail
NEXT_HOST="https://docs-next.example.com"
OLD_HOST="https://old-docs.example.com"
phase1_parallel() {
echo '== Phase 1: parallel run =='
npx mint dev --port 3000 >/dev/null 2>&1 & # local smoke test first
sleep 5 && curl -sf http://127.0.0.1:3000/ >/dev/null && echo 'local build serves'
kill %1
echo "Deploy to ${NEXT_HOST} and continue when it is live."
}
phase2_verify() {
echo '== Phase 2: verify on a real host =='
HOST="${NEXT_HOST}" ./verify-redirects.sh
npx lychee --no-progress "${NEXT_HOST}/sitemap.xml"
echo 'Content, links and redirects verified on the parallel host.'
}
phase3_lower_ttl() {
echo '== Phase 3: lower DNS TTL to 60s, at least 24h before cutover =='
echo 'Do this now and wait a day — it is what makes phase 4 reversible in a minute.'
}
phase4_switch() {
echo '== Phase 4: DNS switch (the only risky step) =='
read -r -p 'Type SWITCH to point docs.example.com at Mintlify: ' ok
[ "$ok" = SWITCH ] || { echo 'aborted'; exit 1; }
echo 'Switched. Re-run verification against the production hostname now.'
}
phase5_hold() {
echo '== Phase 5: keep the old origin serving 301s =='
echo "Do NOT decommission ${OLD_HOST} until analytics show legacy traffic near zero."
echo 'Typically one full search-engine recrawl cycle.'
}
"${1:-phase1_parallel}"
The ordering is the whole point. Every check happens on a real hostname while the old site is still authoritative, so a problem found in phase 2 costs nothing. Only phase 4 changes what the public sees, and the TTL work in phase 3 is what turns a bad cutover from an outage into a one-minute rollback.
Gotchas & Edge Cases
Redirect chains from the old platform. If the old site already redirected some paths, do not stack a new hop on top. Resolve each legacy URL to its final destination and map it there directly — a three-hop chain is slow, leaks link value, and breaks entirely when one intermediate host is retired.
Hidden and draft pages export too. ReadMe’s hidden pages come out of an export looking like ordinary content, and publishing them is an embarrassing way to leak an unreleased feature. Filter on the hidden flag during conversion rather than reviewing 200 files by eye.
Anchors are part of the URL. A link to /docs/authentication#refresh-tokens only works if that heading still generates the same id. Platform slug rules differ, so verify a sample of anchored URLs specifically — the redirect check above follows the path and will happily pass a URL whose fragment now points nowhere.
Images referenced by absolute old-host URLs. Prose that embeds https://old-docs.example.com/images/flow.png keeps loading from the old origin, and breaks the day you decommission it. Grep for the old hostname across all converted content and rewrite those to relative paths before cutover.
Analytics resets look like a traffic collapse. A new property with no history shows a cliff on migration day that is measurement, not reality. Keep both properties running through the transition so you can tell a genuine drop from an artefact.
Estimating a migration is mostly estimating two tasks. Everything else is closer to a rounding error:
FAQ
What actually transfers in a ReadMe to Mintlify migration?
The OpenAPI document and plain Markdown prose transfer almost unchanged. Navigation, callout syntax, custom components, and redirects do not — those are rebuilt, and they are where the schedule goes.
Do I have to keep the old URL structure?
No, but every path you change needs a redirect entry, and the number of them scales with the size of the portal. Changing structure and platform at once doubles the risk; if the old structure is workable, keep it through the migration and restructure as a separate, later change.
How long should the old origin stay alive?
Until analytics show inbound traffic to legacy URLs has dropped to near zero — typically one full search-engine recrawl cycle. A 301 only helps while something is still requesting the old path, and decommissioning early permanently loses those links.
Related
- Mintlify Setup & Migration — the parent guide, including
docs.jsonconfiguration in full. - Mintlify vs Docusaurus for API teams — deciding whether this migration is the right one.
- Broken link checking in docs CI — keeping links healthy after the move.
- Developer Portal Frameworks & UI Setup — the parent overview.