The short answer
A migration keeps its search traffic when every URL that had value before the move either still returns 200 or redirects in one permanent hop to the page that replaces it — and when the new site is not accidentally telling search engines to prefer staging, or to go away. Everything in this checklist serves those two sentences: an inventory so no URL is forgotten, a map so every one has a decided destination, a redirect check that compares destinations rather than status codes, and a staging discipline that keeps noindex and canonicals pointing the right way on each host.
Claude Code does the parts that are reading and comparison: building the inventory from crawl exports and sitemaps, drafting the map, diffing metadata between old and new, writing the redirect rules from the map, and running the read-only check. You make the mapping decisions and you press go. Nothing here asks the agent to change DNS, and nothing here trusts a redirect because it returned 301.
Four kinds of move, and which checks each needs
"Migration" covers four different jobs. Naming yours first removes half the checklist.
-
Redesign, same URLs. A new theme or front end; every URL stays. Risk: metadata and structured data silently change, internal links drop, a staging
noindexships. No URL map needed; the metadata diff and the staging checks are the whole job. -
URL structure change. Same platform, new paths (
/services/physio.htmlbecomes/services/physiotherapy). Risk: unmapped URLs, chains through old redirects. The map and the redirect check are the job. -
Platform change. WordPress to Shopify, a static generator to a CMS. Usually a URL change as well, plus everything from the redesign case, plus platform behaviours you did not choose — Shopify's fixed
/products/and/collections/prefixes, its generated sitemap that you must not replace, its redirect table. - Domain change. All of the above plus DNS, email records, certificates on both hosts, and Search Console's Change of Address, which Google applies only to domain and subdomain moves.
Google's own documentation separates a move with URL changes from one without, and the checks differ accordingly: for a hosting move with the same URLs, the advice is to remove any temporary crawl blocks and lower the DNS TTL "at least a week in advance"; for a URL change, it is a mapping, permanent redirects, and monitoring both sites.
Prerequisites
- Read access to the current site's crawl (any crawler export), its sitemap, Search Console and analytics, and its existing redirects — all exported to files the agent can read.
- A staging copy of the new site you can fetch from outside, protected by auth or
noindex. - Claude Code in a repository that holds the exports, the map and the generated redirect rules. Plan mode (
claude --permission-mode plan) for every step that reads before it writes. - Python 3.8 or later for the redirect check in the download.
- Thirty days of the old hosting after cutover. A migration with no way back is a launch, not a migration.
Inventory and baselines
The inventory is the union of every place a URL could be recorded: the crawl, the sitemap, Search Console's Pages report, analytics landing pages over twelve months, the existing redirect table, and the pages with the most linking domains. Reconciling these is exactly the kind of tedious comparison the agent is good at:
Read-only. Build the URL inventory for the migration from the exports in ./exports (crawl.csv, sitemap.xml, gsc-pages.csv, analytics-landing.csv, redirects-existing.csv). Do not fetch any URL. Output inventory.csv with one row per unique URL (normalised: scheme, host, trailing slash, no fragment) and columns: url, in_crawl, in_sitemap, in_gsc, sessions_12m, existing_redirect_target, status_from_crawl, title_from_crawl, canonical_from_crawl. Then list: URLs in the sitemap but not the crawl, URLs with sessions but a non-200 status, and URLs whose canonical points elsewhere. Do not decide dispositions.
The baseline is the crawl's own metadata — title, description, canonical, robots directive, H1, structured data types — saved off-host with a date, plus Search Console and performance exports. It exists so that "the new site is worse" can be a comparison rather than a feeling. The Search Console guide covers which exports to take and why twelve months; the SEO overview covers what each piece of metadata is for, which decides what a difference in the diff is worth.
The URL map, with a worked example
One row per inventory URL, one disposition each: KEEP (same URL, must 200), MOVE (301 to one new URL), MERGE (301 to the page that absorbed its content), REMOVE (410, or a 301 to the closest relevant page — never the homepage). A row still marked REVIEW when the build starts is a URL that will be forgotten.
The example below is the fixture used in this site's Technical SEO Lab: a fictional clinic moving from a .html site to a CMS on a new domain.
| source_url | target_url | expected_status | identity | notes |
|---|---|---|---|---|
| old.example/physio | new.example/services/physiotherapy | 301 | Physiotherapy | MOVE — short link on business cards |
| old.example/services/physio.html | new.example/services/physiotherapy | 301 | Physiotherapy | MOVE — platform dropped .html |
| old.example/about-us | new.example/about | 301 | About | MOVE |
| old.example/blog/2019/opening-hours | new.example/contact | 301 | Contact | MERGE — folded into contact |
| old.example/christmas-2019 | — | 410 | — | REMOVE — no equivalent |
| new.example/ | — | 200 | Harbourline | KEEP — must not be noindexed |
Two columns do the work most maps leave out. expected_status is what the source URL must return after cutover, so a KEEP row is a check that the page still exists and a REMOVE row is a check that it returns a deliberate 410 rather than a soft 404. identity is a word from the target page's title, so a redirect that lands on the right URL but the wrong content — a homepage dump behind a rewrite rule — still fails.
The agent drafts the map from the inventory and the new site's page list; you decide the rows it marks REVIEW. It is also the right tool for the second pass — "for every MOVE row, confirm the target exists on staging and its H1 is about the same thing as the source's title" — because that is a hundred comparisons nobody wants to do by hand and nobody should skip.
Redirects: chains, loops, dumps and honest removals
Google's guidance is specific: use server-side permanent redirects (301 or 308), keep them "for as long as possible, generally at least 1 year", keep chains short, and "don't redirect many old URLs to one irrelevant single URL destination, such as the home page of the new site" — which "might be treated as a soft 404 error". Each of those has a failure pattern worth naming.
-
Chains. The old site already redirected
/old-servicesto/services-2; the new map redirects/services-2to the new page. Now the first URL takes two hops. The fix is to merge the existing redirects into the map so every source goes straight to the final destination — and to generate rules from the map, not add to the old table. -
Loops. Usually a rule written for the old host applied on the new one:
/ato/band/bto/a. Any check that follows redirects without a hop limit hangs on these. -
The homepage dump. A catch-all rule that sends anything unmatched to
/. It hides every unmapped URL behind a 301 and converts them all into soft 404s. Unmatched URLs should 404 (or 410) loudly, so the log shows you what the map missed. - Irrelevant destinations. A MERGE to a page that does not contain the merged content. The identity column catches the worst of it; a person reading the map catches the rest.
- Honest removal. A page with no equivalent gets a 410, or a 301 to the closest genuinely related page. "We are removing it" is a legitimate disposition; disguising it as a redirect to the homepage is not.
Where the rules live depends on the platform. Shopify has a redirect table that takes a CSV import; Cloudflare and most static hosts take a rules file; WordPress usually means the server configuration or a redirects plugin. Generating the rules from the map is a mechanical job for the agent; the Shopify SEO guide covers that platform's fixed URL prefixes, which decide several MOVE targets for you.
Staging: noindex here, canonicals there
Two settings point in opposite directions on the two hosts, and getting either backwards is the most common way a migration loses a month of indexing.
Staging must not be indexed: a noindex robots meta or an X-Robots-Tag header, or HTTP auth in front of it. Production must not carry that block. Google's list for both kinds of move ends with the same line — remove the noindex and robots.txt blocks "that were only needed for the migration" — because so many launches ship them.
Canonicals go the other way. On staging, the canonical of every page should already point at the production URL, because a canonical is a statement about where the preferred copy lives, and staging is never it. A template that builds the canonical from a "site origin" setting will happily emit https://staging.example/… on the day you launch if the setting was never changed. That is the Technical SEO Lab's first fault, and it is invisible in the template: the template is correct and the setting is wrong. The audit guide's rule applies — check rendered responses, not source.
Read-only. Fetch each URL in targets.txt on the STAGING host (rewrite the production host to staging before requesting; do not follow redirects). For each, report: status; robots meta and X-Robots-Tag; canonical href; whether the canonical's host is the production host; title; H1; count of internal links whose host is staging or the old host. Then compare title, description and canonical path against baseline.csv and list every difference. Output a table and a list of pages where staging is not noindexed OR the canonical is not on the production host — both are launch blockers.
A bounded redirect check, tested on fixtures
The download includes check_redirects.py, a standard-library Python script that reads the map, requests each source URL without following redirects, walks the chain one hop at a time, and reports whether it ended where the map says, in how many hops, with what status, and whether the final page's title contains the identity string. It is bounded on purpose: a row limit (500 by default), a timeout per request, a maximum hop count, and a hard stop on the first malformed row so a broken map cannot produce a half-checked report that looks complete. It never writes anything but the report.
A --host-rewrite old=new option lets you test a map written for production against staging or local copies: every request goes to the rewritten host and destinations are compared after the same rewrite, so the map itself never changes.
It was tested against two local fixture servers — an "old" site that redirects and a "new" site that serves pages — with every failure pattern above planted in the map. The test lives in this site's repository as scripts/test-redirect-checker.py; the summary from that run:
Rows checked: 10 · passed: 3 · failed: 7 | row | source | result | chain | problem | |-----|-----------------|--------|------------------------------------------|------------------------------------------------------| | 2 | /physio | PASS | /physio (301) → /services/physiotherapy (200) | | | 3 | /team | FAIL | /team (301) → / (200) | landed on /, expected /team | | 4 | /old-services | FAIL | … (301) → /services-2 (301) → /services/ (200) | 2 hops, expected exactly 1 | | 5 | /loop-a | FAIL | /loop-a (301) → /loop-b (301) → … | redirect loop | | 6 | /temp | FAIL | /temp (302) → /about (200) | source returned 302, expected 301 | | 7 | /gone | PASS | /gone (410) | | | 8 | /news | FAIL | /news (301) → /about (200) | page identity 'About Harbourline Physio' does not contain 'News' | | 9 | /about | PASS | /about (200) | | | 10 | /missing | FAIL | /missing (404) | page returned 404 | | 11 | /slow | FAIL | /slow (None) | request failed: timed out |
Row 3 is the one to notice: a single 301 ending in a 200, which every status-only check passes, failing because the destination is the homepage rather than the mapped page. Row 8 is the other: right URL, wrong page. The three passing rows are the correct move, the honest 410, and the page that stayed. A malformed row — a relative source, an unknown status — stops the run with a distinct exit code before any request is made.
Launch and the first month
On the day: for a domain change, the DNS zone recorded (including MX, SPF, DKIM and DMARC — a migration that breaks email is remembered longer than one that loses rankings) and the TTL already lowered. Then, in order: confirm production is not noindexed; confirm canonicals self-reference on the production host; run the redirect check against the live origin; submit the new sitemap in Search Console (and the Change of Address, for a domain move); verify forms, tracking and the key pages from outside, on a phone.
Then the redirects stay. A year is Google's floor; on a site that has been linked to for a decade there is no reason ever to remove them. The old hosting stays for at least thirty days as the rollback, and its access log is read weekly for URLs that still receive requests and were not mapped.
Monitoring at one day, one week and one month: Search Console's Pages report for 404s and redirect errors, the platform's 404 log, the Performance report against the baseline for the key pages, and the redirect check re-run — because redirect tables get "cleaned up" by people who did not read the map.
What Search Console can and cannot tell you
Search Console is the monitoring layer, not the verification layer. Its documentation is precise about the limits. The URL Inspection tool's default view "is not a live test. The results shown are from most recently indexed version of a page", and its live test "follows any redirects… but does not indicate that it has followed a redirect, nor will it display the final URL that was tested". There is a per-property daily limit on live inspections. So it cannot check a redirect's destination, and it cannot check five hundred of them.
What it does well: the Pages report shows which URLs Google found and how it classified them (indexed, not found, redirect, excluded by noindex); the Sitemaps report shows whether the new sitemap was read; the Performance report, compared with the baseline you saved, shows whether clicks moved with the URLs. The Change of Address tool applies to domain and subdomain moves only — not to path changes and not to a move from HTTP to HTTPS.
Verification — did every source URL end up where the map says — is the redirect check run against the live origin. Search Console then tells you, over weeks, whether Google agreed.
How you know it worked
- The inventory count is recorded, and every row in the map has a disposition other than REVIEW.
- The redirect check reports zero failures on the live origin, and the report file is saved with the date.
- Production returns no
noindex, and canonicals on the key pages self-reference on the production host — read from the response, not the template. - The new sitemap is reported as read in Search Console; the Pages report's "Not found" list at one week contains nothing that was in the inventory.
- The old host's access log at one week shows no requests for unmapped URLs that mattered — or the ones it shows have been added to the map and the check re-run.
- Forms delivered a marked submission from the new site; tracking fired once on the key page.
Download the checklist and sample map
The kit contains the checklist with an evidence column for every line, the sample URL map in the format the checker reads, and check_redirects.py with its usage in the README. No account, no email address.
Next action
Name the kind of move you are making and generate the verification checklist above for it — the map starter it produces is the CSV the checker reads. Then take the Technical SEO Lab: it plants a staging canonical and a homepage-dump redirect in a fixture and asks you to find both from the rendered responses, which is the skill this whole guide depends on. Save both to My Projects so the map, the checklist and the check results stay together.