Your guides are moving from Docusaurus to Starlight, and the new build writes every page to a different path. The old paths are in search results, in support tickets, and in a blog post you did not write. After the switch each one answers 404, and the reader who followed a link to a page lands on an error page instead.
What you get
You will end up with a redirect map built from your sitemap and logs, a crawl that proves every old URL lands in one hop, and an anchor audit. This is for you if you own the build and the host.
Short answer
Build the list of old URLs from the sitemap and the edge log, never from memory, and generate
the redirect map from one table of page moves. Spell every destination exactly as the new
sitemap does, trailing slash included, or the host adds a second redirect of its own. Crawl
every old path against the new build before the switch, and audit the anchors separately,
because the fragment after # never reaches the server.
You will need
A sitemap for the current site, ninety days of edge or server logs with the request path and its status, the page list of the new build, and Node 22 or later. Verified 2026-10-21 against Node 22.22.0 and github-slugger 2.0.0. The sitemap protocol lists what a site publishes, not what is requested,1 which is why the log is the second source. Google’s site move guidance asks for a mapping of old URLs to new ones, redirects with no chains, and the redirects kept for at least a year.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
Astro redirects config | A Starlight build on a host with no redirect file of its own, or an adapter that writes the host’s file for you | A static build writes one HTML page per rule with a meta refresh, so a real 301 exists only when an adapter translates the config | The host reads a redirect file, which answers faster and is a real 301 |
Cloudflare Pages _redirects | The new site is on Pages and the map fits in 2,000 static and 100 dynamic rules | One file per site, exact-match sources with a single splat, and a rule order that decides which rule wins without telling you | More than 2,100 rules, or a match on a header or a cookie |
Docusaurus plugin-client-redirects | You are moving between Docusaurus sites, or into one, and the host offers no redirects | An extra HTML page per old path that redirects with JavaScript, which the plugin’s own page says to avoid when the host can do it | The host has a redirect file, or crawlers matter more than readers |
| In-page canonical tags | Both sites stay up for a while and you want search to prefer the new URL | A reader still lands on the old page, and Google treats the tag as a strong signal rather than an instruction | The old site is going away, which is the case on this page |
| mkdocs-redirects | A Material for MkDocs build, with old pages named by their Markdown path | An HTML stub per old page, with a meta refresh and a script, written into the build, so the host answers 200 and the redirect happens in the browser | The host has a redirect file |
nginx return 301 | You run the server, and the map can live in a map block regenerated with the site | A server to operate, and a config reload on every change to the map | A static host, where the redirect file is the only control you have |
Edge rules win on speed and on what a crawler sees. The _redirects file answers before any
asset is looked up, and costs you a 2,100-rule ceiling and an order you have to get right. The
build-time stubs from Docusaurus and mkdocs-redirects run on any host and cost a page per old
path that answers 200 before it redirects. A canonical tag costs the least to add, but it
leaves the reader on the old page.
Inventory the paths from the sitemap and the log
The sitemap lists the pages you meant to publish, and the log lists the paths people still
request. Four kinds of entry appear only in the log. A trailing-slash variant of a listed page,
an .html suffix the host already redirects, a retired version, and a deleted page that a
popular post still links to. inventory.mjs merges the two and keeps the hit count, so the map
can be checked busiest page first.
export function inventory({ sitemap, log }) {
const byPath = new Map()
for (const path of sitemapPaths(sitemap)) {
byPath.set(path, { path, inSitemap: true, inLog: false, status: null, hits: 0 })
}
for (const row of logRows(log)) {
const entry = byPath.get(row.path) ?? { path: row.path, inSitemap: false, inLog: false, status: null, hits: 0 }
entry.inLog = true
entry.status = row.status
entry.hits += row.hits
byPath.set(row.path, entry)
}
return [...byPath.values()].sort((a, b) => b.hits - a.hits || a.path.localeCompare(b.path))
}
node inventory.mjs
path source status hits
/docs/auth/api-keys sitemap+log 200 18422
/docs/quickstart sitemap+log 200 9310
/docs/intro sitemap+log 200 7745
/docs/meters/record-a-reading sitemap+log 200 4120
/docs/invoices/webhooks sitemap+log 200 3908
/docs/getting-started log only 404 1208 answers 404 today and is still linked
/docs/auth/oauth sitemap+log 200 1177
/docs/auth/api-keys/ log only 200 412 a trailing-slash variant of a sitemap URL
/docs/reference/errors sitemap+log 200 390
/docs/meters/list-meters sitemap+log 200 366
/docs/invoices/overview sitemap+log 200 241
/docs/1.2/auth/api-keys log only 404 96 answers 404 today and is still linked
/docs/intro.html log only 301 61 an .html suffix the host already redirects
/docs/1.4/auth/api-keys sitemap+log 200 44
/docs/1.4/meters/list-meters sitemap+log 200 12
15 old paths: 11 from the sitemap, 4 only in the log
Four of the fifteen paths are in the log alone, and the deleted page among them took 1,208 requests in ninety days, more than six sitemap pages. Script-based analytics sees a visit only when the page runs its script, so count missing pages from the edge or server log instead.
Generate the map from one table of moves
Write down where each old page goes, once, as a page rather than a URL. The variants come from
rules: strip the version prefix, drop the .html suffix, remove the trailing slash, and look
the result up. A page renamed in the table is renamed for every URL that ever pointed at it.
export const MOVES = {
'/docs/intro': '/guides/introduction/',
'/docs/quickstart': '/guides/quickstart/',
'/docs/getting-started': '/guides/quickstart/',
'/docs/auth/api-keys': '/guides/authentication/api-keys/',
'/docs/auth/oauth': '/guides/authentication/oauth/',
'/docs/meters/list-meters': '/guides/meters/list-meters/',
'/docs/meters/record-a-reading': '/guides/meters/record-a-reading/',
'/docs/invoices/overview': '/guides/invoices/overview/',
'/docs/invoices/webhooks': '/guides/invoices/webhooks/',
'/docs/reference/errors': '/reference/errors/',
}
// Versions the new site does not carry. Their pages go to the current page.
export const RETIRED_VERSIONS = ['1.2', '1.4']
// The old page an old URL names, with the version, the suffix and the slash
// removed: /docs/1.4/auth/api-keys/ and /docs/auth/api-keys.html are both
// /docs/auth/api-keys.
export function canonicalOldPath(path) {
let p = path.replace(/\.html$/, '').replace(/\/$/, '')
for (const v of RETIRED_VERSIONS) p = p.replace(`/docs/${v}/`, '/docs/')
return p || '/'
}
export const destinationFor = (oldPath) => MOVES[canonicalOldPath(oldPath)]
The destinations end in a slash because the new sitemap does. Starlight writes
/guides/quickstart/index.html
by default and so does MkDocs with
use_directory_urls,
while Docusaurus wrote the same file and
left the slash off the URL.
Cloudflare Pages adds the slash itself
when the file is there, with a 308 of its own, so a destination without it costs every
reader a second hop.
node map.mjs
mirror: 2 static and 2 dynamic rules (limits 2000 and 100)
every old path reaches a page in one hop
restructure: 15 static and 0 dynamic rules (limits 2000 and 100)
every old path reaches a page in one hop
wrote _redirects and redirects.nginx.conf, 15 rules each
The two lines are the trade-off in numbers. A mirror move keeps the old paths under the new
tool and needs four rules, two splats for the retired versions and two static ones. The host’s
slash handling does the rest. The restructure needs a rule per old URL, fifteen here.
Every future rename lengthens that file for as long as the old links exist, which Google puts
at a year or more. The same pairs come out as an nginx map block for a server you run.
/docs/auth/api-keys /guides/authentication/api-keys/ 301
/docs/quickstart /guides/quickstart/ 301
/docs/intro /guides/introduction/ 301
Crawl the old paths against the new build
The crawl asks the new site for every old path and follows what it answers, the way a browser
would, counting the hops. Before the deployment exists, server.mjs stands in for it. The
_redirects rules come first, because Cloudflare evaluates the file before it looks for an
asset; then the built pages, then the host’s handling of a slash or an .html suffix, then a 404.
const m = matchRule(path, rules)
if (m) {
res.writeHead(m.rule.code, { location: m.to })
return res.end()
}
if (pageSet.has(path)) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
return res.end(renderPage(path, pages[path]))
}
const fixed = hostNormalize(path, pageSet)
if (fixed) {
res.writeHead(308, { location: fixed })
return res.end()
}
res.writeHead(404, { 'content-type': 'text/plain' })
res.end('not found\n')
node crawl.mjs
/docs/1.2/auth/api-keys 301 /guides/authentication/api-keys/ => 200
/docs/intro.html 301 /guides/introduction/ => 200
/docs/1.4/auth/api-keys 301 /guides/authentication/api-keys/ => 200
/docs/1.4/meters/list-meters 301 /guides/meters/list-meters/ => 200
_redirects: 15 old paths, 15 reach a page (15 in one hop, 0 chained), 0 end in 404
The same script takes --base https://preview.example and crawls a deployment instead. It
exits 1 when a path ends in a 404 or takes a second hop, so a CI job can hold the switch.
Audit the anchors the redirect cannot carry
A redirect moves a path. The fragment after # is never sent, because
RFC 3986 separates it from the URI
before the request is made, so no server rule can rewrite it. The browser does carry it across a
redirect, by a rule in RFC 9110,2
and then looks for a heading with that id on the new page. Astro, and so Starlight,
derives heading ids with
github-slugger, and Docusaurus
wraps the same package.
MkDocs uses Python-Markdown’s
toc extension, which collapses the hyphens
an ampersand leaves behind and numbers a repeated heading _1 rather than -1.
export function pythonMarkdownSlug(text, separator = '-') {
const ascii = text.normalize('NFKD').replace(/[^\x00-\x7f]/g, '')
const cleaned = ascii.replace(/[^\w\s-]/g, '').trim().toLowerCase()
return cleaned.replace(new RegExp(`[${separator}\\s]+`, 'g'), separator)
}
export function pythonMarkdownIds(headings) {
const seen = new Set()
return headings.map((h) => {
let id = pythonMarkdownSlug(h)
while (seen.has(id) || !id) {
const m = /^(.*)_(\d+)$/.exec(id)
id = m ? `${m[1]}_${Number(m[2]) + 1}` : `${id}_1`
}
seen.add(id)
return id
})
}
node anchors.mjs
anchor lands on Starlight MkDocs
/docs/auth/api-keys#rotate-a-key /guides/authentication/api-keys/ missing missing
/docs/auth/api-keys#scopes--permissions /guides/authentication/api-keys/ ok missing (scopes-permissions)
/docs/1.4/auth/api-keys#create-a-key /guides/authentication/api-keys/ ok ok
/docs/meters/record-a-reading#example-1 /guides/meters/record-a-reading/ ok missing (example_1)
/docs/invoices/webhooks#retry-schedule /guides/invoices/webhooks/ ok ok
/docs/getting-started#install-the-cli /guides/quickstart/ ok ok
6 inbound anchors: Starlight keeps 5, MkDocs keeps 3
Starlight loses one anchor, because Rotate a key became Rotating a key, and MkDocs loses two
more on text that did not change.3 The fix is the same in every toolchain: keep the heading
text, or pin the id. Docusaurus takes
{#rotate-a-key} after a
heading and ships a write-heading-ids command that adds one to every heading before you move.
Python-Markdown’s attr_list
extension takes {: #rotate-a-key }. Astro needs a rehype plugin for an explicit id.
Check it worked
node --test migrate.test.mjs
1..14
# tests 14
# suites 0
# pass 14
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 756.462745
Fourteen tests pin the shapes. The fifth feeds the first draft through the validator and asserts
eleven shadowed rules and eleven chains, so a validator that stops catching either goes red. The crawl test asserts oneHop: 15 against the stand-in, and
the committed _redirects has to match what the generator writes. Another runs map.mjs --check
and crawl.mjs on the first draft and expects exit status 1 from both.
When it goes wrong
Every path answers 404 or takes two hops. The first draft of this page’s map put a catch-all splat on line one and spelled its destinations without the slash.
node map.mjs --check drafts/first-draft_redirects
static rule for /docs/1.4/meters/list-meters is shadowed by /docs/* above it
/docs/intro -> /guides/introduction chains: the page is at /guides/introduction/
/docs/quickstart -> /guides/quickstart chains: the page is at /guides/quickstart/
Cloudflare applies the top-most matching rule, so the splat answered every request and sent
/docs/auth/api-keys to /guides/auth/api-keys, which is not a page. Put static rules first,
or leave the catch-all out. The chains are the slash: the host sends /guides/quickstart on to
/guides/quickstart/ with a redirect of its own, so the reader makes two round trips.
A reader arrives at the top of the right page instead of the section they were sent to. The fragment went with them, and the heading id changed.
node pitfall.mjs
link followed: /docs/auth/api-keys#rotate-a-key
server received: /docs/auth/api-keys
server answered: 301 /guides/authentication/api-keys/
browser lands on: /guides/authentication/api-keys/#rotate-a-key
ids on that page: create-a-key scopes--permissions rotating-a-key revoke-a-key
#rotate-a-key: not on the page, so the reader lands at the top
The server saw a path with no fragment, so no rule could have mapped rotate-a-key to
rotating-a-key. Only the audit sees the loss, and only an explicit id or the old
heading text prevents it.
The crawl passes against the stand-in but fails against the deployment. Each static host
treats /x, /x/, and /x.html differently, and the
trailing slash guide tabulates them, and
GitHub Pages answers 404 where Cloudflare Pages redirects. Crawl a preview deployment with --base
before you trust the stand-in.
When not to do this
Do not restructure in the same release as the toolchain move unless you will own the longer table. The mirror map here is four rules, and the host does the rest. The restructure is fifteen rules that stay in the file for as long as anyone links to the old paths, and every future rename lengthens it.
Do not add a catch-all splat to the guides index to bring the 404 count to zero. A reader who followed a link to a specific page lands on an index with no idea why. You also lose the 404 that would have told you a path still needs a rule.
Canonical tags are not a migration. They tell search which of two live URLs to prefer, and Google calls the tag a strong signal rather than an instruction. Once the old site stops serving, the tag goes with it.
Do not trust the sitemap alone, or the log alone. The sitemap misses the paths nobody meant to publish. The log misses the pages nobody visited this quarter, whose links sit in a book or a slide deck you will never see.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0 and github-slugger 2.0.0. Every output block is what
the command preceding it printed, run in the page’s code directory against an in-process stand-in
server. The host’s handling of a trailing slash and an .html suffix is modeled on
Cloudflare’s documentation and the source of its Pages asset server, not measured against a
deployment. The MkDocs ids come from a port
of Python-Markdown’s slug function, checked against a run of Python-Markdown 3.11.1 rather than
against an MkDocs build. No Docusaurus, Starlight, or MkDocs site was built.
Footnotes
-
The protocol allows a file 50,000 URLs and 50 MB uncompressed, and its one date field,
lastmod, records when a page last changed. No field records that a URL has stopped answering, so a sitemap can be valid in every field while naming pages that have been gone for a year. ↩︎ Back to text -
The browser’s habit of carrying the fragment across a redirect entered HTTP in 2014, in RFC 7231. Fifteen years earlier, RFC 2616 had described
Locationas a single absolute URI and left fragments unmentioned. RFC 9110 kept the rule in 2022 and changed one term in it: the request target became the target URI. ↩︎ Back to text -
GitHub documents its anchor rules: letters made lowercase, spaces replaced by hyphens, other punctuation removed, and a repeated heading given a hyphen and a counter. Removing the ampersand from Scopes & permissions leaves two spaces, so the id is
scopes--permissions, and github-slugger reproduces the double hyphen on purpose. Python-Markdown collapses the run to one hyphen and counts repeats with an underscore, which is the whole of the disagreement. ↩︎ Back to text