Keeping Redirects Working After an SSG Migration

A migration that changes URLs without redirects loses two things at once: the readers following links from elsewhere, and the search standing those links accumulated. Neither failure is visible on the day of the cutover, which is what makes it dangerous — the site looks fine, and the traffic decline shows up three weeks later as a mystery.

This guide builds the redirect map from production evidence rather than from the repository, emits it in whichever format your host wants, keeps chains flat, and gates the whole thing in CI. It is the URL-safety half of Migrating Between Static Site Generators.

Prerequisites

  • The production sitemap saved before the migration, plus edge or server logs covering at least ninety days.
  • Access to your host's redirect mechanism: a _redirects file, a netlify.toml, a Worker, or the generator's alias support.
  • The new build available at a preview URL so redirects can be tested before they go live.

Build the Map From Evidence

The repository does not know which URLs people actually use. Three sources together do:

Three sources for the redirect map Three inputs merge into one redirect map: the production sitemap contributes 1,240 URLs, ninety days of edge logs contribute 1,510 including 270 the sitemap never listed, and search console contributes 40 more with inbound links. The union is 1,550 URLs, of which 310 would have been missed by using the sitemap alone. The sitemap alone misses a fifth of the traffic Production sitemap 1,240 URLs what you publish 90 days of edge logs 1,510 URLs 270 never in the sitemap Search console 40 more with inbound links Union: 1,550 the URL contract 310 would have been lost redirects file generated, not hand-written
Logs are the source that finds URLs nobody remembers publishing: old campaign paths, renamed sections, links from a conference talk five years ago.
# Merge the three sources into one candidate list
curl -s https://example.com/sitemap.xml | grep -oE '<loc>[^<]+' | sed 's|<loc>||; s|https://example.com||' > s1.txt
# Edge logs: successful HTML responses only, path column, last 90 days
zcat logs/*.gz | awk '$9==200 && $7 !~ /\.(css|js|png|svg|woff2)$/ {print $7}' | sort -u > s2.txt
cat search-console-export.csv | cut -d, -f1 | sed 's|https://example.com||' | tail -n +2 > s3.txt
cat s1.txt s2.txt s3.txt | sed 's|?.*||' | sort -u > urls-contract.txt
wc -l urls-contract.txt

Then subtract the URLs the new build already produces; whatever remains needs a redirect:

find dist -name '*.html' | sed 's|dist||; s|/index.html|/|' | sort > urls-new.txt
comm -23 urls-contract.txt urls-new.txt > needs-redirect.txt

Generate the Redirect File

Hand-maintained redirect files rot. Generate the file from a mapping you keep as data — usually a CSV or the aliases front matter already written during content conversion:

// scripts/build-redirects.mjs → writes dist/_redirects (Netlify / Cloudflare Pages format)
import { readFile, writeFile } from 'node:fs/promises';

const rows = (await readFile('redirects.csv', 'utf8'))
  .trim().split('\n').slice(1)
  .map((line) => line.split(',').map((s) => s.trim()));

// Flatten chains: follow each destination until it is not itself a source
const map = new Map(rows);
const resolve = (to, seen = new Set()) => {
  if (!map.has(to) || seen.has(to)) return to;
  seen.add(to);
  return resolve(map.get(to), seen);
};

const out = rows
  .map(([from, to]) => `${from}  ${resolve(to)}  301`)
  .join('\n');

await writeFile('dist/_redirects', out + '\n');
console.log(`wrote ${rows.length} redirect(s)`);

The chain flattening is not optional. Two migrations two years apart produce /old/ → /interim/ → /new/ without anyone intending it, and each hop is a round trip the reader pays for.

A redirect chain flattened Before flattening, an old URL redirects to an interim URL which redirects to the current URL, costing three requests and 42 milliseconds. After flattening, both the old and interim URLs point directly at the current URL, costing two requests and 18 milliseconds. Every hop is a round trip the reader pays for Chained /old-docs/x/ /interim/x/ /guides/x/ 42 ms Flattened /old-docs/x/ /interim/x/ /guides/x/ 18 ms Chains form across migrations, not within one — which is why flattening must happen at generation time, every time
The resolver in the generator script walks each destination to its final target, so a redirect written today survives the next restructure without becoming a second hop.

Host formats differ but the content does not:

HostMechanismNotes
Netlify_redirects or netlify.tomlOrder matters; first match wins
Cloudflare Pages_redirectsStatic rules limited in count; use a Worker beyond that
Vercelvercel.json redirects arraySupports regex patterns and permanent flag
Nginx / Apachereturn 301 / RedirectMatchFull regex, no platform limits
Generator-levelHugo aliases, Astro redirects configEmits meta-refresh stubs, not HTTP 301

The last row is worth understanding. Hugo's aliases and similar features emit small HTML pages that redirect with a meta refresh. They work everywhere, including on hosts with no redirect layer, but they cost a page load and pass ranking signals less cleanly than a 301. Use them as a fallback, and prefer real status codes when you have an edge layer — see Setting Cache-Control Headers on Cloudflare Pages for the same host's configuration surface.

Gate It in CI

A redirect file that is not tested is a hopeful text file. Check every entry against the preview deployment before cutover:

#!/usr/bin/env bash
# scripts/verify-redirects.sh https://preview.example.com
BASE="$1"; fail=0
while IFS=$' \t' read -r from to code; do
  [ -z "$from" ] && continue
  actual_code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE$from")
  actual_to=$(curl -s -o /dev/null -w '%{redirect_url}' "$BASE$from")
  if [ "$actual_code" != "301" ]; then
    echo "FAIL $from → got $actual_code, expected 301"; fail=1; continue
  fi
  final_code=$(curl -s -o /dev/null -w '%{http_code}' "$actual_to")
  if [ "$final_code" != "200" ]; then
    echo "FAIL $from$actual_to returns $final_code (chain or dead target)"; fail=1
  fi
done < dist/_redirects
[ "$fail" -eq 0 ] && echo "redirects: all entries resolve to 200 in one hop"
exit "$fail"

Run this against the preview deployment, not against a local build directory — redirects are a host feature, so a local file server proves nothing about whether the rules actually apply. On a large map, run it in parallel and cache the result per commit; a thousand curl requests take under a minute with a modest concurrency limit and are worth the wall clock exactly once per deploy.

Two assertions matter: the status is 301 rather than a soft 200 or a 404, and the destination returns 200 in one hop. The second catches both broken targets and chains, which are the two failure modes that survive a casual manual check.

Measured Impact

A 1,550-URL documentation site migrated between generators, with redirects generated and verified as above. Figures are from edge logs and search console over the eight weeks around cutover:

MetricSitemap-only redirectsEvidence-based map
Old URLs covered1,2401,550
404s in week 1 after cutover4,18061
Redirect chains340
Median redirect latency (edge)42 ms18 ms
Search-console "not found" peak890 pages12 pages
Weekly 404 count after cutover A line chart over four weeks. With sitemap-only redirects, 404s spike to 4,180 in week one and decline slowly to 1,900 by week four. With an evidence-based redirect map, 404s stay at 61 in week one and fall to 12 by week four. Where the missing URLs show up 4,500 0 4,180 1,900 and falling slowly 61 12 week 1 week 2 week 3 week 4 Red: redirects built from the sitemap only · Green: redirects built from sitemap + logs + search console
The sitemap-only line decays slowly because each 404 is discovered by a different visitor arriving from a different old link — which is exactly why logs, not the repository, are the right source.

The latency row is a small but real bonus of flattening chains: 42 ms to 18 ms is one fewer round trip on every redirected request, which matters most for the readers arriving from search on mobile connections.

Pitfalls & Rollback

  • Building the map from the repository. It cannot know about URLs that were never in the sitemap, which is where a fifth of the traffic lives.
  • Leaving chains in place. Each hop is a round trip, and search engines stop following after a few. Flatten at generation time.
  • Using 302 by default. A temporary redirect keeps the old URL indexed and splits signals between two paths.
  • Redirect rules that shadow real pages. A broad pattern rule can capture a URL the new site actually serves. Order rules so specific ones win and test the whole file.
  • Deleting redirects during a later cleanup. Traffic on an old URL can be tiny and still valuable; review annually, remove only what has had no hits for a year.
  • Rollback: the redirect file is one artifact. Reverting it and redeploying restores the previous routing within a cache cycle, and keeping the old build available means you can point traffic back entirely if something structural is wrong.

Conclusion

Redirects are the cheapest insurance in a migration and the easiest thing to under-scope. Build the map from production evidence, generate the file rather than maintaining it by hand, flatten every chain, and verify each entry resolves to a 200 in one hop before the cutover rather than after. Then keep the file: it is the accumulated memory of every URL your site has ever published. The rest of the cutover process is in Migrating Between Static Site Generators.

FAQ

Should migration redirects be 301 or 302?

301 for anything permanent, which is almost every migration redirect. A 301 tells search engines to transfer the old URL's standing to the new one; a 302 says the move is temporary and keeps the old URL indexed. Use 302 only while you are still deciding.

Are meta refresh redirect stubs good enough?

They work and they are better than a 404, but they cost a full page load and pass ranking signals less reliably than an HTTP status. Use them where the host offers no redirect layer, and prefer real 301s wherever you have an edge or host configuration.

How long should I keep migration redirects?

Indefinitely for anything with inbound links. Redirects are cheap at the edge and the cost of removing one is a broken link somewhere you cannot see. Review the list annually and remove only entries with no traffic for a full year.

What is a redirect chain and why does it matter?

A chain is one redirect pointing at a URL that itself redirects. Each hop adds a round trip, and search engines stop following after a few. Always flatten chains so every old URL points directly at its final destination.

How do I find the URLs that actually need redirects?

Take the union of your production sitemap, your server or edge logs for the last ninety days, and the top pages in search console. Logs are the important one — they include URLs that no sitemap ever listed but that people still visit.