Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

Discoverability

Move documentation URLs without breaking old links

Map every old URL to its new page, serve real HTTP redirects from your host, check each one on the live site, and keep a way back if the move goes wrong.

By 10 min read

To move documentation URLs without breaking old links, answer every old URL with a permanent HTTP redirect (301 or 308) to the one new page that replaces it, then check each one on the live site. In Blume, redirects are a list in blume.config.ts, and your host decides whether they go out as real HTTP redirects. A move to a new domain also needs a rule on the old domain, which lives in your host's settings, not in Blume.

You end up with a list of every URL your site serves, a map from each moved URL to its new home, redirects in your config, a script that checks the map against the public host, and a way back. Redirects keep links and bookmarks working, but they don't guarantee your search visibility stays the same: search engines recrawl on their own schedule, and rankings can shift while they do.

If all you want is a tidier sidebar, you may not need new URLs. Numeric prefixes reorder pages, a folder name in parentheses groups pages without a URL segment, and a slug in frontmatter pins a page's route wherever its file lives. See Group folders. Coming from another framework? Start from its migration guide, like Migrate from Docusaurus.

Redirects, redirect pages, and host rules

Three different things can answer an old URL:

  • An HTTP redirect is a 3xx status code with a Location header. Browsers, crawlers, curl, and link checkers all follow it.
  • A redirect page is what a static build writes at each old path: an HTML page that answers 200, with a <meta http-equiv="refresh"> tag, a noindex, and a canonical link to the new URL. Browsers move on, and Google treats an instant meta refresh as permanent, but anything that reads status codes sees a working page.
  • A host rule is a redirect set in your host's settings. Blume's redirects match paths, never domains, so a domain move needs one.

Which of the first two an old URL gets depends on how you deploy:

Your setupWhat an old URL gets
A server build: vercel(), netlify(), cloudflare(), or node()An HTTP redirect with your status, answered at request time
A static build that names Netlify or Cloudflare, like netlify({ output: "static" })An HTTP redirect, from the _redirects file the build writes
A static build in a Git-connected Vercel projectThe redirect page, until you copy the redirects from dist/vercel.json into the vercel.json at your project root
A static build on GitHub Pages, or any host that reads none of these filesThe redirect page for an exact redirect, and a 404 for every path a pattern covers

A static build that names no host writes _redirects, vercel.json, and blume-redirects.json, a plain list to turn into nginx or Apache rules. On Netlify, name the host anyway: otherwise the rules aren't forced and Netlify serves the redirect page first. The redirects docs cover each host.

List the URLs you have today

Before you change anything, save every URL the live site serves. Blume writes a sitemap.xml whenever it knows the site URL, so pull the paths out of it:

curl -s https://docs.acme.example/sitemap.xml \
  | grep -o '<loc>[^<]*' \
  | sed 's#<loc>https://docs.acme.example##' \
  | sort -u > old-urls.txt

Replace docs.acme.example with your domain in both places. The sitemap leaves out draft, hidden, and noindex pages, so add any of those people still reach, plus paths your analytics show traffic on. Commit the file.

Map each old URL to its new home

Make the moves on a branch: rename folders, git mv files, merge pages. Then run npx blume dev and print every old URL that no longer answers 200:

while read -r path; do
  code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:4321$path")
  [ "$code" = "200" ] || echo "$path"
done < old-urls.txt

Each path it prints needs a row in url-map.csv, with the old path and the path of the page that replaces it:

old,new
/quickstart,/getting-started/quickstart
/api-keys,/getting-started/authentication
/guides,/tutorials
/guides/send-email,/tutorials/send-email
/guides/sms-templates,/tutorials/sms-templates
  • Map one to one. Send each old URL to the page that covers the same thing. Google's site-move guidance warns against redirecting many old URLs to one irrelevant page, like the homepage.
  • Merged pages point at the merge. A page that's gone with no replacement can 404 rather than land somewhere unrelated.
  • Every locale counts. On a translated site, each copy of a page (/de/quickstart) needs its own row.
  • List real paths. Write out every URL, even ones a pattern will cover. The map is what you test.

Add the redirects

Turn the map into rules in blume.config.ts: an exact redirect for a single page, and a pattern for a folder that moved as a whole.

import { defineConfig } from "blume";

export default defineConfig({
  deployment: { site: "https://docs.acme.example" },
  redirects: [
    { from: "/quickstart", to: "/getting-started/quickstart" },
    { from: "/api-keys", to: "/getting-started/authentication" },
    { from: "/guides/:slug*", to: "/tutorials/:slug*" },
  ],
});

status defaults to 301 and also takes 302, 307, and 308. Google treats both 301 and 308 as permanent. /guides/:slug* covers /guides itself and every path under it, so three entries cover all five rows. Write both sides as if the site were mounted at the root: Blume adds deployment.base and basePath. Patterns also keep a large move under host limits: Cloudflare's _redirects takes 2,000 exact and 100 pattern rules, and a Vercel vercel.json takes 2,048 redirects.

Check the redirects before you deploy

This script reads url-map.csv and requests every old URL twice: once to read the first response, and once following redirects to see where it lands. Save it next to the map:

#!/usr/bin/env bash
# Check every row of url-map.csv against a running site.
# Usage: ./check-redirects.sh <old origin> [new origin]
set -u
old_origin="${1%/}"
new_origin="${2:-$1}"
new_origin="${new_origin%/}"
failed=0

while IFS=, read -r old new || [ -n "$old" ]; do
  new="${new%$'\r'}"
  if [ "$old" = "old" ] || [ -z "$old" ]; then
    continue
  fi
  url="$old_origin$old"
  expected="$new_origin$new"

  first=$(curl -s -o /dev/null --max-time 20 -w '%{http_code}' "$url")
  read -r code hops final < <(curl -sL -o /dev/null --max-time 20 \
    -w '%{http_code} %{num_redirects} %{url_effective}' "$url")

  case "$first" in
    301 | 308) ;;
    302 | 307) ;;
    200)
      if curl -s --max-time 20 "$url" | grep -qi 'http-equiv="refresh"'; then
        echo "META  $old is a redirect page (200), not an HTTP redirect"
      else
        echo "FAIL  $old answered 200 without redirecting"
      fi
      failed=1
      continue
      ;;
    *)
      echo "FAIL  $old answered $first"
      failed=1
      continue
      ;;
  esac

  if [ "$final" != "$expected" ]; then
    echo "WRONG $old lands on $final, expected $expected"
    failed=1
  elif [ "$code" != "200" ]; then
    echo "DEAD  $old lands on $final, which answers $code"
    failed=1
  elif [ "$first" = "302" ] || [ "$first" = "307" ]; then
    echo "TEMP  $old -> $final ($first is a temporary redirect)"
    failed=1
  elif [ "$hops" -gt 1 ]; then
    echo "CHAIN $old -> $final in $hops hops"
  else
    echo "OK    $old -> $final ($first)"
  fi
done < url-map.csv

exit "$failed"

With npx blume dev still running, point it at your machine:

chmod +x check-redirects.sh
./check-redirects.sh http://localhost:4321

Every row should print OK. WRONG and DEAD mean a rule points somewhere else or at nothing. CHAIN passes, but point the first rule straight at the last page. A status of 000 means no answer at all.

In dev, this proves where each URL lands, not its status: the dev server answers exact redirects with a 301 whatever status says. In production your host sends the status, so check it on the public site.

Next, stop the dev server, build, and run the audit's redirect and link checks:

npx blume build
npx blume audit --only redirects,links,indexability,sitemap

The audit walks every configured redirect, old ones included. It reports a redirect that lands on no page (BLUME_AUDIT_REDIRECT_BROKEN), a loop, a chain where an older redirect points at a page you've moved since, and a redirect whose source is still a real page, so it never fires.

Redirects are for links you don't control. Point the links you do control straight at the new URLs, as Google's site-move guidance asks.

  • Links. The audit lists every link through a redirect, in page bodies and navigation, as BLUME_AUDIT_LINK_TO_REDIRECT. blume validate won't, because it counts a link to a redirect's from as valid.
  • Canonicals. Each page's canonical URL comes from its route and deployment.site, so moved pages update on their own. Only a hand-written seo.canonical can still name an old URL, and the audit reports it as BLUME_AUDIT_CANONICAL_BAD_TARGET.
  • Sitemap. Blume rebuilds sitemap.xml from the pages that exist, so it never lists a redirect, unless a public/sitemap.xml of your own replaces it. Submit it in Google Search Console once the move is live.

To keep these checks on every pull request, see Add a technical SEO check to your documentation build.

Move to a new domain

A domain move needs three more changes. First, set the new origin:

deployment: { site: "https://developers.acme.example" },

With a host adapter, pass it as an option instead: vercel({ site: "https://developers.acme.example" }). Don't leave this to detection during a move: on Vercel, Blume reads VERCEL_PROJECT_PRODUCTION_URL, the shortest production custom domain, so with both domains on the project a shorter old one can win and take every canonical with it. The build summary prints the resolved site URL.

Second, send every path on the old domain to the same path on the new one with a permanent redirect:

  • Vercel: add both domains to the project. In Project Settings, open Domains, select Edit on the old domain, choose the new one under Redirect to, and pick a permanent status, 301 or 308.
  • Cloudflare: add a redirect rule to the old domain's zone, as in Cloudflare's Redirect one domain to another. Cloudflare's _redirects doesn't support domain-level rules.
  • Netlify: give the old domain its own small site whose publish folder holds a single _redirects file with the line /* https://developers.acme.example/:splat 301!. Don't put that file in the docs site's public/ folder: there it would replace the _redirects a static build writes.

Third, verify the new domain in Google Search Console and, once the redirects are live, use the Change of Address tool from the old domain's property. It applies to domain and subdomain moves only, and you must own both properties.

The map now needs every old URL. Start from a row per URL that keeps its path, then edit the rows whose path changed too:

(echo "old,new"; sed 's/.*/&,&/' old-urls.txt) > url-map.csv

Those rows redirect only on the old domain, so test them on the public host, not in dev. Changing paths and domain in one release gives moved URLs two hops, reported as CHAIN; moving paths first keeps each URL to one.

Verify on the public host

Merge, let the production deploy finish, and run the script against the real domain. Pass the new origin as a second argument for a domain move:

./check-redirects.sh https://docs.acme.example
./check-redirects.sh https://docs.acme.example https://developers.acme.example

Test the public domain, not a preview URL: domain rules apply only on the domain itself, and a protected preview answers with a login page. A META line means your host serves redirect pages instead of HTTP redirects; find your setup in the table at the top.

blume audit --url https://docs.acme.example is worth running too. It requests every page in your build from the live host and reports 4xx, 5xx, and header problems, but it never requests the old URLs, so it can't tell you whether a redirect fires.

Keep a way back

  • Merge the move as one pull request with old-urls.txt and url-map.csv in it, so it reverts as one commit and the test stays with the repository.
  • For a domain move, keep the old setup intact until the script passes on production. Removing the domain redirect undoes the switch.
  • Leave the redirects in place for at least a year, as Google's site-move guidance advises. Old links keep arriving long after that.

Rolling back a URL move has a catch: browsers cache 301 and 308 redirects. Readers who followed one keep going to the new URL, which 404s once you move the page back. Prefer rolling forward: fix the destination, or the page at its new URL.

Troubleshooting

Old URLs answer 200 with a redirect page

Your host isn't reading a redirect file. On Netlify or Cloudflare, name the host (netlify({ output: "static" })) or switch to a server build. On a Git-connected Vercel project with a static build, copy the redirects from dist/vercel.json into the vercel.json at your project root. GitHub Pages can't send HTTP redirects at all, and pattern redirects do nothing there.

Your config redirects are missing from _redirects

A _redirects file in public/ replaces the one Blume would write, so none of your config's redirects reach it. Move its rules into blume.config.ts and delete the file.

The build stops with BLUME_REDIRECT_MATCHES_PAGE

A pattern also covers a page that still exists, and hosts disagree on which one answers. Narrow the pattern to the paths that moved.

Canonical URLs still name the old domain

Blume built with the old domain as its site URL, usually because Vercel's detection picked the shorter domain. Set site as in Move to a new domain and redeploy.

A redirect you changed still goes to the old place

Your browser cached the earlier permanent redirect. Test with the script or curl, which don't cache, or in a private window.

Old pages drop out of search results

That's expected as search engines follow the redirects and swap in the new URLs, and positions can move meanwhile. If the new URLs aren't indexed, see Troubleshoot documentation pages that are not indexed.

Next step

Audit your redirects

After a build, run the audit's redirect and link checks, then run the script above against your live host.

npx blume audit --only redirects,links
Read the audit docs

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init