SEO Site Migration skill for Claude Code
Move domains or platforms and keep your traffic. Claude treats a migration as a redirect and parity project with a benchmark, not a launch checklist. It unions URLs from crawls, sitemaps, Search Console, analytics, backlinks, and logs, maps each to its true equivalent, tests the whole map with a bundled checker before and after launch, diffs templates on staging, and sets rollback triggers before launch day.
- name
seo-site-migration - size210 lines · ≈2,800 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Plans and runs website migrations without losing organic traffic, covering domain moves, replatforming, URL restructures, HTTPS and subdomain-to-subfolder moves, and site mergers. Includes benchmarking, complete URL inventories, one-to-one redirect maps, content and signal parity checks on staging, launch-day steps, the Change of Address tool, and post-launch monitoring with rollback triggers. Use when changing domains, CMS, URL structure, or merging sites, or when traffic dropped after a migration. Try asking
- We're moving from WordPress to Webflow next month and the URL structure will change. Plan the SEO migration
- Here's our redirect map CSV. Test it against staging and tell me what's broken
- Organic traffic fell 40% two weeks after our domain change. Work out what went wrong
Use it when
- Changing domain, CMS, URL structure, or moving a subdomain into a subfolder
- Merging two websites or consolidating regional sites
- Writing or testing a redirect map before launch
- Traffic dropped after a migration and the cause is unclear
Skip it when
- Indexing problems unrelated to a migration — use technical-seo
- Merging or pruning a handful of blog posts on the same site — use content-refresh
How to check the result
check_redirects.py passes 100 percent of URLs with clicks or backlinks against production on launch day, and Search Console shows new URLs indexing while old ones move to Page with redirect.
The common mistake it prevents
Redirecting every retired URL to the home page feels safe but Google treats those redirects as soft 404s, so the old pages' link equity disappears.
Done well means every old URL that earned traffic, links, or conversions resolves in one hop to its closest equivalent, the new pages carry the same content and signals as the old ones, and organic clicks return to the pre-launch baseline within the expected window, with monitoring that would have caught a failure in days, not months.
Ground rules
- Change as little as possible at once. A domain move plus a redesign plus new URLs plus new content multiplies risk and makes any loss impossible to attribute. If the business insists on bundling, say so in writing and plan longer recovery.
- Redirects are the migration. A mapping spreadsheet nobody tested is not a redirect plan.
- Never redirect everything to the home page or a category. Google treats irrelevant redirects as soft 404s and the old URL’s equity is lost.
- Keep redirects for at least one year, and in practice indefinitely; keep the old domain registered and serving redirects forever.
- Launch, DNS changes, and redirect rule deploys are production changes: show the plan and get explicit user confirmation before each one. Never launch in the four weeks before the site’s peak season, and avoid Fridays.
Migration types and expected recovery
| Type | Risk | Typical recovery if done well | Extra steps |
|---|---|---|---|
| HTTP to HTTPS | Low | Days to 2 weeks | HSTS after redirects are stable, update canonicals and sitemaps |
| Same domain, new design, same URLs | Low-medium | 2-4 weeks | Content and internal link parity check matters most |
| Replatform (CMS change), same URLs | Medium | 2-6 weeks | Template output diff: titles, canonicals, schema, pagination, faceting |
| URL restructure on same domain | Medium-high | 1-3 months | Full redirect map, internal links rewritten to new URLs |
| Domain change | High | 1-4 months | Change of Address tool, both properties verified |
| Subdomain to subfolder (or reverse) | Medium-high | 1-3 months | Treat as a URL restructure across hosts |
| Merging two sites | Highest | 3-6 months, sometimes a permanent partial loss | Map overlapping content, consolidate duplicates deliberately |
Workflow
- [ ] 1. Scope: what changes, what stays, launch date, rollback owner
- [ ] 2. Benchmark: traffic, rankings, crawl, links, conversions
- [ ] 3. Build the complete old URL inventory from every source
- [ ] 4. Write the redirect map and redirect rules
- [ ] 5. Check content and signal parity on staging
- [ ] 6. Pre-launch test of the redirect map against staging
- [ ] 7. Launch (with confirmation) and run the launch-day checks
- [ ] 8. Monitor daily for 2 weeks, weekly to 3 months
- [ ] 9. Report and close out
2. Benchmark before anything changes
Save these, dated, because they cannot be recreated afterward:
- Search Console: 16 months of clicks and impressions by page and by query (API or BigQuery export for large sites), plus the Page indexing counts.
- GA4: organic landing pages with sessions and conversions for the last 12 months.
- Rank tracking: 50-200 head and money queries, captured for at least two weeks pre-launch.
- A full crawl of the live site (Screaming Frog, Sitebulb, or similar): URL, status, title, meta description, H1, canonical, meta robots, word count, inlinks, schema types.
- Backlinks: top linked URLs from a backlink tool or Search Console’s Links report.
- Server logs for 30 days if available; they reveal URLs the crawl never finds.
3. Inventory every old URL
A crawl alone misses orphaned pages, old campaign URLs, and PDFs that still have links. Union and dedupe:
- Crawl output (all 200, 301, and canonicalized URLs)
- XML sitemaps, current and historical
- Search Console pages with at least 1 click or impression in 16 months
- GA4 landing pages with any organic or referral sessions
- Backlink tool target URLs
- Server log URLs requested by Googlebot or real users
- Images, PDFs, and other files that rank in image search or have links
Tag each URL with clicks, referring domains, and conversions so mapping effort goes where the value is.
4. The redirect map
Map in priority order and stop treating the long tail with the same care only once the value-weighted top is perfect.
| Old URL situation | Target |
|---|---|
| Same content exists on the new site | The equivalent new URL, 301 or 308 |
| Content merged into a broader page | The merged page, ideally to the matching section |
| Product discontinued, replacement exists | The replacement product |
| Product discontinued, no replacement | Its category if genuinely relevant, else 410 with a helpful page |
| Content intentionally removed, no equivalent | 410 (or 404); do not force a redirect |
| Old redirect already pointing elsewhere | Update it to point directly at the final new URL (no chains) |
| Parameter URLs (tracking, sort) | Strip or preserve parameters by rule; never map each variant by hand |
Implementation rules:
- One hop from every old URL to its final destination, including historical redirects that pointed at URLs now changing again. Rewrite those to the new final target.
- Combine protocol, host, and path changes into a single rule (
http://www.old.com/xtohttps://new.com/yin one hop). - Use pattern rules (regex or path prefix) for systematic changes, explicit pairs for everything else, and test rule ordering: the first matching rule wins on most servers.
- Normalize trailing slashes and case in the same hop, consistent with the new canonical form.
- Status 301 or 308 for permanent moves. 302 and 307 are for temporary changes only.
- Store the map in version control as
old_url,new_url,expected_statusCSV; it is the test fixture for step 6 and the audit trail afterward.
5. Parity on staging
Staging must be blocked by HTTP authentication or an IP allowlist, never by robots.txt
Disallow or a noindex tag that can ship to production.
Crawl staging with the same tool and settings as the step-2 crawl and diff by template:
- Titles, meta descriptions, H1s: same or deliberately improved; no template placeholders.
- Body content: word count within roughly 10 percent per page unless the change was planned; missing FAQ blocks, reviews, or tab content are common silent losses.
- Canonicals: self-referencing on the new URLs, absolute, on the production host (not staging).
- Meta robots and
X-Robots-Tag: no straynoindex. - Internal links: point directly at new URLs, not at old URLs relying on redirects; nav and footer link sets match or improve on the old ones.
- Structured data: same types still present and valid.
- hreflang, pagination, faceted navigation handling, image
alttext, and image URLs. - JavaScript rendering: primary content present in server HTML (hand detailed rendering
problems to
technical-seo). - Page speed: field data cannot exist yet, but lab LCP and INP on key templates should not be worse than the old site.
6. Test the redirect map
Run the bundled checker against staging (via a hosts entry or staging host rewrite) and again against production right after launch:
python3 scripts/check_redirects.py redirect_map.csv --workers 8
python3 scripts/check_redirects.py redirect_map.csv --rewrite-host old.example.com=staging.example.com
It requests each old URL without following redirects, then follows the chain, and fails a
row when the first response is not the expected status, the Location does not match the
mapped target, the chain is longer than one hop, or the final URL is not 200. It exits 1 if
any row fails and prints a summary by failure type. Fix every failure on the top-value URLs;
the launch bar is 100 percent pass for URLs with clicks or links, at least 98 percent overall.
7. Launch day
- Confirm with the user, then deploy the new site and redirect rules together.
- Remove staging authentication and verify
robots.txtand meta robots on production. - Run
check_redirects.pyagainst production for the full map. - Submit the new XML sitemap in Search Console. Keep a sitemap of old URLs submitted for a few weeks so Google recrawls and processes the redirects sooner.
- Domain moves only: verify both properties, then use the Change of Address tool from the old property. It requires the home page and a sample of URLs to 301 correctly.
- Update the highest-value backlinks you control (social profiles, partner sites, directories, your own other properties) to the new URLs.
- Update Business Profiles, ad destination URLs, email templates, and app deep links.
- Annotate the launch date in analytics.
8. Monitor and roll back
Daily for two weeks, then weekly to three months:
- Search Console Page indexing: new URLs moving to indexed, old URLs moving to “Page with redirect”; watch for spikes in 404, soft 404, or server errors.
- Crawl stats: crawl requests should rise after launch as Google processes redirects.
- 404 log: any 404 on a URL with traffic or links gets a redirect the same day.
- Clicks by page group vs the benchmark; rankings for the tracked set.
| Observation at 2-4 weeks | Read | Action |
|---|---|---|
| Clicks within 10-20 percent of baseline, trending back | Normal turbulence | Keep monitoring |
| One template lost most traffic, others fine | Parity failure on that template | Diff old vs new for that template, fix fast |
| Sitewide drop over 30 percent, indexing of new URLs stalled | Blocking, canonical, or redirect failure | Probe immediately; this is an incident |
| Old URLs still indexed after 4+ weeks | Redirects not crawled or inconsistent | Check old sitemap is submitted, redirects are 301, no canonicals pointing back |
Rollback (restoring the old site and reversing redirects) is a last resort for a broken launch within the first days; after Google has processed most redirects it causes a second migration. Decide the rollback trigger and owner in step 1, before emotions are involved.
Gotchas
- Canonicals or hreflang on the new site pointing at old URLs create a redirect-canonical loop and stall indexing.
- Google Analytics and tag manager often break on the new platform; organic “loss” can be a tracking loss. Check Search Console clicks before panicking over GA4.
- New platform defaults: trailing-slash flips, uppercase handles,
/collections/x/products/yduplicate paths, auto-generated tag pages, and paginated archives with canonical to page 1. - Redirecting by IP or language detection on the new site can hide pages from Googlebot.
- Image URLs change silently in replatforms; image search traffic disappears unless images redirect too.
- Expiring the old domain lets someone buy it and inherit your backlinks.
Report template
## Migration report — <old> to <new>, launched YYYY-MM-DD
Type: <domain move / replatform / ...>. Old URLs inventoried: <n>. Mapped: <n> (301 <n>, 410 <n>).
Redirect test: <pass rate overall>, <pass rate for URLs with clicks or links>
| Week | Clicks vs baseline | Indexed new URLs | Old URLs still indexed | 404s with value | Notes |
|---|---|---|---|---|---|
**Parity failures found and fixed:** template, issue, date fixed
**Open risks:** ...
**Close-out criteria:** clicks within 5 percent of baseline for 4 weeks, zero valuable 404s Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/check_redirects.py
#!/usr/bin/env python3
"""Test a migration redirect map against live responses (stdlib only).
Input CSV with a header row:
old_url,new_url,expected_status
https://old.example.com/a,https://example.com/a,301
https://old.example.com/gone,,410
`expected_status` is optional (default 301; 308 is accepted wherever 301 is expected).
Leave `new_url` empty for rows expected to return 404 or 410.
Usage:
python3 check_redirects.py map.csv [--workers 8] [--timeout 15]
[--rewrite-host old.example.com=staging.example.com]
[--failures-out failures.csv]
For every row: the first response must have the expected status, its Location
must equal new_url (after normalizing scheme/host case and resolving relative
Locations), the chain must be exactly one hop, and the final URL must return 200.
Exit 1 if any row fails, 2 on bad input.
"""
from __future__ import annotations
import argparse
import csv
import sys
import urllib.error
import urllib.request
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import urljoin, urlsplit, urlunsplit
UA = "Mozilla/5.0 (compatible; migration-redirect-check/1.0)"
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs): # noqa: D401
return None
OPENER = urllib.request.build_opener(NoRedirect)
def norm(u: str) -> str:
p = urlsplit(u.strip())
return urlunsplit((p.scheme.lower(), p.netloc.lower(), p.path or "/", p.query, ""))
def fetch(url: str, timeout: float) -> tuple[int, str | None]:
req = urllib.request.Request(url, headers={"User-Agent": UA}, method="GET")
try:
with OPENER.open(req, timeout=timeout) as r:
return r.status, r.headers.get("Location")
except urllib.error.HTTPError as e:
return e.code, e.headers.get("Location")
def rewrite(url: str, pairs: list[tuple[str, str]]) -> str:
p = urlsplit(url)
for src, dst in pairs:
if p.netloc.lower() == src:
return urlunsplit((p.scheme, dst, p.path, p.query, p.fragment))
return url
def check(row: dict, a, pairs) -> tuple[dict, str | None]:
old = rewrite(row["old"], pairs)
target = rewrite(row["new"], pairs) if row["new"] else ""
exp = row["status"]
try:
status, loc = fetch(old, a.timeout)
except Exception as e: # network errors, timeouts, TLS
return row, f"error: {type(e).__name__}: {e}"
ok_status = {301, 308} if exp in (301, 308) else {exp}
if status not in ok_status:
return row, f"status {status}, expected {exp}"
if status in (404, 410):
return row, None
if not loc:
return row, f"status {status} without Location"
first = urljoin(old, loc)
if target and norm(first) != norm(target):
return row, f"wrong target: {first}"
# Follow the rest of the chain to confirm one hop and a 200 final.
url, hops = first, 1
while hops <= 10:
try:
s, l2 = fetch(url, a.timeout)
except Exception as e:
return row, f"error on final: {type(e).__name__}"
if s in (301, 302, 303, 307, 308) and l2:
url, hops = urljoin(url, l2), hops + 1
continue
if hops > 1:
return row, f"chain of {hops} hops ending {s} at {url}"
if s != 200:
return row, f"final status {s} at {url}"
return row, None
return row, "redirect loop or chain over 10 hops"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("map_csv")
ap.add_argument("--workers", type=int, default=8)
ap.add_argument("--timeout", type=float, default=15)
ap.add_argument("--rewrite-host", action="append", default=[], help="src=dst host rewrite, repeatable")
ap.add_argument("--failures-out")
a = ap.parse_args()
pairs = []
for spec in a.rewrite_host:
if "=" not in spec:
print(f"error: --rewrite-host needs src=dst, got {spec}", file=sys.stderr)
return 2
s, d = spec.split("=", 1)
pairs.append((s.lower(), d))
rows = []
try:
with open(a.map_csv, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
cols = {c.strip().lower(): c for c in reader.fieldnames or []}
if "old_url" not in cols or "new_url" not in cols:
print("error: CSV needs old_url and new_url columns", file=sys.stderr)
return 2
for r in reader:
old = (r.get(cols["old_url"]) or "").strip()
if not old:
continue
new = (r.get(cols["new_url"]) or "").strip()
raw = (r.get(cols.get("expected_status", ""), "") or "").strip() if "expected_status" in cols else ""
status = int(raw) if raw.isdigit() else (301 if new else 410)
rows.append({"old": old, "new": new, "status": status})
except OSError as e:
print(f"error: {e}", file=sys.stderr)
return 2
if not rows:
print("error: no rows in map", file=sys.stderr)
return 2
with ThreadPoolExecutor(max_workers=a.workers) as ex:
results = list(ex.map(lambda r: check(r, a, pairs), rows))
fails = [(r, why) for r, why in results if why]
kinds = Counter(why.split(":")[0].split(" ")[0] for _, why in fails)
for r, why in fails[:200]:
print(f"FAIL {r['old']} -> {r['new'] or '(gone)'}: {why}")
passed = len(rows) - len(fails)
print(f"\n{passed}/{len(rows)} passed ({passed / len(rows):.1%})")
if kinds:
print("Failures by type: " + ", ".join(f"{k}={v}" for k, v in kinds.most_common()))
if a.failures_out and fails:
with open(a.failures_out, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["old_url", "new_url", "expected_status", "reason"])
for r, why in fails:
w.writerow([r["old"], r["new"], r["status"], why])
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.