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.

  • nameseo-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.

~/.claude/skills/seo-site-migration/SKILL.md

--- name: seo-site-migration description: Plans and runs website migrations without losing organic traffic, covering domain moves, replatforming, URL restructures, HTTPS and… license: MIT ---

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

TypeRiskTypical recovery if done wellExtra steps
HTTP to HTTPSLowDays to 2 weeksHSTS after redirects are stable, update canonicals and sitemaps
Same domain, new design, same URLsLow-medium2-4 weeksContent and internal link parity check matters most
Replatform (CMS change), same URLsMedium2-6 weeksTemplate output diff: titles, canonicals, schema, pagination, faceting
URL restructure on same domainMedium-high1-3 monthsFull redirect map, internal links rewritten to new URLs
Domain changeHigh1-4 monthsChange of Address tool, both properties verified
Subdomain to subfolder (or reverse)Medium-high1-3 monthsTreat as a URL restructure across hosts
Merging two sitesHighest3-6 months, sometimes a permanent partial lossMap 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:

  1. Crawl output (all 200, 301, and canonicalized URLs)
  2. XML sitemaps, current and historical
  3. Search Console pages with at least 1 click or impression in 16 months
  4. GA4 landing pages with any organic or referral sessions
  5. Backlink tool target URLs
  6. Server log URLs requested by Googlebot or real users
  7. 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 situationTarget
Same content exists on the new siteThe equivalent new URL, 301 or 308
Content merged into a broader pageThe merged page, ideally to the matching section
Product discontinued, replacement existsThe replacement product
Product discontinued, no replacementIts category if genuinely relevant, else 410 with a helpful page
Content intentionally removed, no equivalent410 (or 404); do not force a redirect
Old redirect already pointing elsewhereUpdate 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/x to https://new.com/y in 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_status CSV; 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 stray noindex.
  • 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 alt text, 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

  1. Confirm with the user, then deploy the new site and redirect rules together.
  2. Remove staging authentication and verify robots.txt and meta robots on production.
  3. Run check_redirects.py against production for the full map.
  4. 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.
  5. 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.
  6. Update the highest-value backlinks you control (social profiles, partner sites, directories, your own other properties) to the new URLs.
  7. Update Business Profiles, ad destination URLs, email templates, and app deep links.
  8. 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 weeksReadAction
Clicks within 10-20 percent of baseline, trending backNormal turbulenceKeep monitoring
One template lost most traffic, others fineParity failure on that templateDiff old vs new for that template, fix fast
Sitewide drop over 30 percent, indexing of new URLs stalledBlocking, canonical, or redirect failureProbe immediately; this is an incident
Old URLs still indexed after 4+ weeksRedirects not crawled or inconsistentCheck 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/y duplicate 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 162 lines · 6.1 KB
#!/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.

esc