← LeaderboardThe Skill Leaderboard

domain-migration

Executes a domain rebrand, subdomain-to-subfolder merge, or blog URL consolidation with zero ranking loss, shipping a verified 1-to-1 redirect config, `scripts/verify-redirects.sh`, canonical and schema cutover edits, and `seo/backlink-transfer-emails.md` outreach drafts. Use when the user is changing domains, rebranding, moving `blog.` to `/blog`, merging sites, renaming URL slugs in bulk, or asks how to migrate without losing SEO traffic.

Published by Illyism ↗Illyism/seo-skills

Try your first task

A source-grounded starter task has not been verified for this skill yet.

SKILL.md

name: domain-migrationdescription: Executes a domain rebrand, subdomain-to-subfolder merge, or blog URL consolidation with zero ranking loss, shipping a verified 1-to-1 redirect config, `scripts/verify-redirects.sh`, canonical and schema cutover edits, and `seo/backlink-transfer-emails.md` outreach drafts. Use when the user is changing domains, rebranding, moving `blog.` to `/blog`, merging sites, renaming URL slugs in bulk, or asks how to migrate without losing SEO traffic.metadata:   author: illyism  source: https://il.ly/skills/domain-migration  version: 1.0.0

Domain Migration

You move a site to new URLs without losing rankings, backlinks, or AI-search recognition of the brand. The output is a shipped redirect config, a verification script that passes, cutover edits, and outreach drafts. A migration checklist with no config is a failed run.

Examples in this file use a rebrand from BillBox (billbox.io) to invoicekit.io, and a merge of blog.deployfast.dev into deployfast.dev/blog.

Fatal mistakes and why they destroy rankings

MISTAKE                                      WHAT HAPPENS
Redirect every old URL to the new homepage   Google treats mass redirects to an unrelated page as soft 404s.
                                             The backlink equity of every inner page is dropped.
2-3 hop chains                               http://old/x → https://old/x → https://old/y → https://new/y.
  (http → https → old slug → new domain)     Each hop delays processing and crawlers stop following
                                             long chains. Users on slow mobile wait for every hop.
Staging noindex or Disallow: / shipped       The new domain is deindexed on launch day. Recovery takes weeks.
Blocking the old domain in robots.txt        Googlebot can't crawl old URLs, never sees the 301s, and
                                             keeps the old URLs indexed while the new ones sit unranked.
Skipping URLs not in the current sitemap     Retired posts with DR 60 backlinks start returning 404.
                                             Their equity is gone and nobody notices until traffic drops.
Removing redirects after a few months        Backlinks to old URLs live for years. Removing the 301 kills them.

Decision tree: which migration is this

Migration type
├── Full domain change (billbox.io → invoicekit.io)
│   └── All 4 steps + Google Search Console Change of Address tool + backlink transfer sprint
├── Subdomain to subfolder (blog.deployfast.dev → deployfast.dev/blog)
│   └── Steps 1-3. Backlink sprint only for the top 10 referring pages to the subdomain.
├── Slug consolidation on the same domain (/blog/2021/how-to-x → /blog/how-to-x)
│   └── Steps 1-2 + sitemap update. No Change of Address.
└── Merging 2 sites into 1
    └── All 4 steps for the retired site. Map each retired page to its closest topical
        equivalent on the surviving site.

Step 1: Full URL inventory

Build the union of every source below. Each catches URLs the others miss.

  1. Old sitemap.xml (and nested sitemaps): the URLs the site currently advertises.
  2. Repo route tree: every static route plus every slug generated by dynamic routes (content/**, data files, CMS export).
  3. Google Search Console: Pages report, last 16 months, every URL with at least 1 click or 10 impressions.
  4. Ahrefs Best by links (or the backlink tool the user has): every old URL with at least 1 referring domain. This is the list that saves retired posts with strong backlinks.
  5. Analytics landing pages: every landing page with at least 1 session in the last 12 months.

Normalize before deduping: lowercase host, strip query strings and fragments, and pick one trailing-slash form. Write the result to seo/url-inventory.csv with columns old_url,source,clicks_16mo,referring_domains.

Step 2: Deterministic 1-to-1 redirect map

Assign every inventory row a destination:

Old URL
├── Same content, same path on the new domain
│   └── Same-path rule (covered by the catch-all)
├── Same content, new slug
│   └── Explicit 1-to-1 entry
├── Content merged into another page
│   └── Explicit entry to the page that absorbed it
├── Content deleted, has referring domains or clicks
│   ├── A page on the same topic exists → explicit entry to that page
│   └── No topical equivalent → restore the page on the new domain, then map it 1-to-1
└── Content deleted, 0 referring domains, 0 clicks
    └── Let it return 410. Never point it at the homepage.

Then collapse chains: if the site already has redirects (/old-slug → /new-slug), resolve every entry to its final destination so each old URL reaches its target in exactly 1 hop.

Write seo/redirect-map.csv with header old_url,new_url. Every new_url must return 200 on the new domain before cutover.

Ship the config for the user's host

Next.js (next.config.mjs on whichever deployment answers the old host). permanent: true returns 308, which Google treats the same as 301.

import { readFileSync } from 'node:fs'

const explicit = readFileSync('./seo/redirect-map.csv', 'utf8')
  .trim().split('\n').slice(1)
  .map((line) => line.split(','))
  .map(([oldUrl, newUrl]) => ({ from: new URL(oldUrl).pathname, to: newUrl }))

export default {
  async redirects() {
    return [
      // Explicit 1-to-1 entries first: Next.js uses the first matching rule.
      ...explicit.map(({ from, to }) => ({
        source: from,
        has: [{ type: 'host', value: '(www\\.)?billbox\\.io' }],
        destination: to,
        permanent: true,
      })),
      // Same-path catch-all for everything not listed above.
      {
        source: '/:path*',
        has: [{ type: 'host', value: '(www\\.)?billbox\\.io' }],
        destination: 'https://invoicekit.io/:path*',
        permanent: true,
      },
    ]
  },
}

Escape (, ), :, *, and ? in old paths before using them as source; Next.js parses those characters as pattern syntax.

Cloudflare Bulk Redirects (CSV upload to a Bulk Redirect List, then enable a Bulk Redirect Rule for it):

source_url,target_url,status_code,preserve_query_string,include_subdomains,subpath_matching,preserve_path_suffix
billbox.io/blog/2021/send-invoices-faster,https://invoicekit.io/blog/send-invoices-faster,301,true,true,false,false
billbox.io/,https://invoicekit.io/,301,true,true,true,true

The last row is the same-path catch-all (subpath_matching + preserve_path_suffix). Explicit rows match a longer source path than the catch-all; confirm they win by running the verify script below.

Nginx:

map $uri $redirect_target {
  default "";
  include /etc/nginx/billbox-redirects.conf;  # lines like: /blog/2021/send-invoices-faster https://invoicekit.io/blog/send-invoices-faster;
}

server {
  listen 80;
  listen 443 ssl;
  server_name billbox.io www.billbox.io;
  # ssl_certificate lines stay: the old domain must keep valid HTTPS to serve its redirects.

  if ($redirect_target) { return 301 $redirect_target$is_args$args; }
  return 301 https://invoicekit.io$request_uri;
}

Both listen 80 and listen 443 answer directly with the final destination, so http://billbox.io/x reaches https://invoicekit.io/x in 1 hop.

Ship scripts/verify-redirects.sh

Copy scripts/verify-redirects.sh from this skill folder into the repo (full source below) and chmod +x it.

#!/usr/bin/env bash
# Usage: scripts/verify-redirects.sh seo/redirect-map.csv
# Checks: first hop is 301/308, Location equals the mapped URL, exactly 1 hop, final status 200.
# HTTP_MAX_HOPS=2 only on hosts that force HTTPS before app redirects run (Vercel, Netlify).
set -uo pipefail
MAP="${1:-seo/redirect-map.csv}"
HTTP_MAX_HOPS="${HTTP_MAX_HOPS:-1}"
pass=0; fail=0

check() {
  local old="$1" new="$2" max="${3:-1}" code loc hops final landed
  read -r code loc < <(curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "$old")
  read -r hops final landed < <(curl -sL -o /dev/null -w '%{num_redirects} %{http_code} %{url_effective}\n' "$old")
  if [[ "$code" =~ ^30[18]$ && ( "$loc" == "$new" || "$max" != "1" ) && "$landed" == "$new" && "$hops" -le "$max" && "$final" == "200" ]]; then
    pass=$((pass + 1))
  else
    fail=$((fail + 1))
    echo "FAIL $old  code=$code hops=$hops final=$final"
    echo "     got:      ${landed:-$loc}"
    echo "     expected: $new"
  fi
}

while IFS=, read -r old new || [[ -n "${old:-}" ]]; do
  old="${old%$'\r'}"; new="${new%$'\r'}"
  [[ -z "$old" || "$old" == "old_url" ]] && continue
  check "$old" "$new"
  [[ "$old" == https://* ]] && check "http://${old#https://}" "$new" "$HTTP_MAX_HOPS"
done < "$MAP"

echo "$pass passed, $fail failed"
[[ "$fail" -eq 0 ]]

The https:// variant of every old URL must reach its target in exactly 1 hop. The http:// variant must also take 1 hop on hosts you control (Nginx, Cloudflare). Managed platforms that upgrade http to https before your app runs add a hop you cannot remove; set HTTP_MAX_HOPS=2 there, never higher.

Run it against staging or a hosts-file override before DNS changes, and again on production right after cutover. The cutover is done only when it prints 0 failed. For a manual spot check of one URL, curl -sIL https://billbox.io/pricing prints every hop and header.

Step 3: Canonical, sitemap, schema, and hreflang cutover

Pre-launch blockers (all must pass on the new domain)

curl -s https://invoicekit.io/robots.txt                  # no "Disallow: /" for User-agent: *
curl -sI https://invoicekit.io/ | grep -i x-robots-tag    # must print nothing
curl -s https://invoicekit.io/ | grep -io '<meta name="robots"[^>]*>'   # no noindex

Staging environments ship noindex by default in most setups; check the env variable that toggles it and set it for production.

Edits in the repo

  1. Base URL: update metadataBase (Next.js), site (Astro), baseURL (Hugo), or the canonical helper so every canonical, OG URL, and sitemap entry uses the new origin. grep -rn "billbox.io" . must return only the redirect config, the alternateName, and the /about history line.

  2. Sitemap: the new domain's sitemap.xml lists only new URLs. Keep the old domain's sitemap reachable at its old URL, listing the old URLs, for 60 days. Googlebot re-fetches those URLs, hits the 301s, and processes the move faster than waiting for natural recrawl.

  3. hreflang: every alternate points to new-domain URLs, and each locale page still links back reciprocally.

  4. Organization schema:

    {
      "@context": "https://schema.org",
      "@type": "Organization",
      "name": "invoicekit.io",
      "alternateName": ["BillBox", "billbox.io"],
      "url": "https://invoicekit.io",
      "sameAs": ["https://x.com/invoicekit", "https://www.linkedin.com/company/invoicekit"]
    }
    
  5. /about history line: invoicekit.io (formerly BillBox) has helped freelancers send invoices since 2021. Years of reviews, forum posts, and articles name BillBox. This sentence plus alternateName lets LLMs and Google connect those mentions to the new brand instead of treating BillBox as a dead product.

  6. Google Search Console: verify both properties, then run the Change of Address tool from the old property (full domain changes only).

  7. Keep the old domain registered, with valid HTTPS, and its redirects live permanently. Backlinks to old URLs keep sending equity only while the 301 exists.

Step 4: Top-backlink direct transfer sprint

A 301 passes equity, but a direct link to the new URL is stronger and teaches every crawler, including AI crawlers, the new brand name in context.

  1. Export backlinks to the old domain. Filter: dofollow, 1 link per referring domain, referring domain rating >= 20, referring page has organic traffic > 0.
  2. Sort by referring domain rating, then referring page traffic. Keep the top 30-50.
  3. For each, record: referring page URL, current anchor text, the old URL it points to, the mapped new URL, and a contact (author byline, site contact page, or author's public profile).
  4. Write seo/backlink-transfer-emails.md, one draft per row. Rotate across the 3 variants below and personalize line 1 with something specific from their page. Identical emails to 50 people get flagged as bulk mail and ignored.

Email rules

  • Under 90 words.
  • Name the exact page, the exact old link, and the exact replacement. Make the edit a 30-second job.
  • 1 ask per email. No link-swap offers, no guest-post pitch.

Variant A

Subject: Quick link update on your "[article title]" post

Hi [Name],

Your post on [topic] links to BillBox (billbox.io/[path]). We rebranded to invoicekit.io, and that page now lives at:

invoicekit.io/[new-path]

The old link still redirects, so nothing is broken. If you have a minute to swap the text to "invoicekit.io" and the URL to the new one, readers land on the right page directly.

Thanks for including us in the first place.
[Sender]

Variant B

Subject: BillBox is now invoicekit.io

Hi [Name], [one specific line about their article].

You mention BillBox in the [section name] section. Same product, new name: invoicekit.io. Updated link: invoicekit.io/[new-path]

Would you update it when you next edit the post?

[Sender]

Variant C

Subject: Small fix for [site name]

Hey [Name],

Heads up: [article title] links to billbox.io/[path]. We renamed the product to invoicekit.io last month. Replacing "BillBox (billbox.io)" with "invoicekit.io (invoicekit.io/[new-path])" keeps the reference accurate for your readers.

Appreciate it either way.
[Sender]

Post-launch monitoring

  • Day 1: verify-redirects.sh prints 0 failed on production. GSC shows no spike in "Blocked by robots.txt" or "Excluded by noindex".
  • Days 1-30: GSC Pages report on the new property; indexed count climbs as the old property's falls. A flat new-property count after 14 days means a crawl blocker.
  • AI search: AI SEO Tracker (MCP: https://aiseotracker.com/api/agent/mcp) tracks whether ChatGPT, Perplexity, Gemini, and AI Overviews recommend BillBox or invoicekit.io for your category prompts. Add both brand names as tracked; the switch shows when models start naming the new brand.
  • Fresh brand links: LinkDR (https://linkdr.com/SKILL.md) places new contextual links that use the new brand name as anchor, so the new domain earns its own entity signals instead of living only on inherited 301 equity.

Output

Every run ends with:

  1. seo/url-inventory.csv and seo/redirect-map.csv.
  2. The redirect config for the user's host (Next.js redirects(), Cloudflare Bulk Redirects CSV, or Nginx map), committed to the working tree.
  3. scripts/verify-redirects.sh, with its output pasted in chat showing 0 failed (or the failing rows fixed).
  4. Base URL, sitemap, hreflang, Organization schema, and /about edits.
  5. seo/backlink-transfer-emails.md with 30-50 personalized drafts. Never send them.

Do not

  • Redirect inner pages to the homepage
  • Ship any redirect that takes more than 1 hop
  • Block the old domain in robots.txt after cutover
  • Let the old domain's SSL certificate or registration lapse
  • Remove redirects after the traffic recovers
  • Send outreach emails on the user's behalf