SEO Migrations skill for Codex
Move the site, keep the rankings. Codex treats a migration as six phases: inventory URLs from crawls, Search Console, analytics, backlinks and logs, save a baseline, build a one-to-one redirect map, check it offline for chains, loops, duplicates and unmapped URLs, implement it at the right layer, and compare staging against the old site before launch. Production deploys wait for your go-ahead, and monitoring runs against the saved baseline for weeks.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- seo-migrations Installs to ~/.agents/skills/seo-migrations/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install seo-migrations The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project seo-migrations Installs to .agents/skills/seo-migrations/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/seo-migrations.zipunzip -o seo-migrations.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) seo-migrations PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $seo-migrations, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Plans and executes website migrations without losing organic traffic - domain moves, HTTPS or www changes, URL restructures, CMS or framework replatforms (e.g. WordPress to Next.js) and site merges - with a full URL inventory, a one-to-one 301 redirect map checked for chains and loops, preserved metadata, canonicals, hreflang and structured data, staging crawls, a launch-day checklist, Search Console Change of Address and post-launch monitoring. Use when asked to migrate a site, change URLs, move domains, build a redirect map, or recover rankings after a botched migration.
Use it when
- A domain, URL structure, CMS or framework change is planned
- You need a redirect map built and validated before launch
- A staging site must be compared against production for SEO parity
- Organic traffic fell after a migration and you need to diagnose it
Use something else when
- You want a general crawl and indexation audit of a site that is not moving: use technical-seo-audit
- You are only migrating a Next.js codebase from Pages Router to App Router with URLs unchanged: use nextjs-pages-to-app-migration
How to check the result
The redirect checker exits clean against the full inventory, and after launch every map row returns a single 301 or 308 to its intended target.
The mistake it prevents
Redirecting old URLs while robots.txt still blocks them, or dumping removed pages onto the homepage where Google treats them as soft 404s.
A migration is done well when every URL that earned traffic, links or rankings has a single-hop 301 to its closest equivalent, the new site exposes the same or better content, metadata and internal links, launch is checked by crawling rather than by clicking around, and traffic is compared against a saved baseline.
Classify the migration
| Type | Risk | Key extra steps |
|---|---|---|
| HTTP to HTTPS, or www/non-www | Low | Sitewide rule, HSTS after stable, update canonicals and sitemaps |
| Domain change | High | Change of Address tool in Search Console, keep old domain registered and redirecting for years |
| URL structure change on same domain | Medium-high | Full redirect map, internal link rewrite |
| Replatform (CMS or framework) with same URLs | Medium | Parity crawl: titles, content, canonicals, rendering, status codes |
| Replatform plus URL changes | High | Everything above; avoid combining with a redesign if you can |
| Merging sites or sections | High | Content consolidation decisions per URL, not blanket redirects |
Prefer to change one thing at a time. URL change, redesign, content pruning and domain move in one release makes any traffic loss impossible to diagnose.
Phase 1: inventory and baseline (before any code)
Build the list of URLs that matter from several sources and merge them:
- [ ] Full crawl of the current site (Screaming Frog, Sitebulb, or a crawler script)
- [ ] XML sitemaps (all of them, including image/news)
- [ ] Search Console: Pages and Performance exports (16 months of queries and pages)
- [ ] Analytics: landing pages with organic sessions over 12+ months
- [ ] Backlinks: Ahrefs/Semrush/Majestic export of linked URLs
- [ ] Server logs: URLs Googlebot actually requests
- [ ] Non-HTML assets with traffic or links: PDFs, images, feeds
Save the baseline: organic clicks and impressions per page, rankings for the top 100-500 queries, index coverage counts, Core Web Vitals, crawl stats. Store as dated CSVs in the migration folder; post-launch comparison depends on them.
Phase 2: redirect map
Map every inventory URL to its closest equivalent on the new site.
| Old URL situation | Action |
|---|---|
| Same content, new URL | 301 (or 308) to the new URL |
| Content merged into another page | 301 to the merged page |
| Content removed, close substitute exists | 301 to the substitute (a category, not the homepage) |
| Content removed, no substitute, no links or traffic | 404 or 410 |
| Content removed but has valuable backlinks | Keep or recreate a relevant page, or 301 to the most relevant one |
| Parameter variants, tracking URLs | Handle by rule, not per row |
Rules:
- One hop. Old URL redirects straight to the final URL; flatten chains from previous migrations too.
- Permanent status: 301 or 308. Not 302, not meta refresh, not JavaScript redirects.
- Never mass-redirect to the homepage; Google treats that as a soft 404.
- Absolute target URLs, final protocol and host, consistent trailing-slash policy.
Check the map before it is implemented (standard library, offline):
python3 scripts/redirect_map_check.py redirects.csv --inventory old_urls.txt --flatten redirects.flat.csv
It reports duplicates, self-redirects, chains, loops, relative targets, many-to-one collapses and inventory URLs with no mapping, and writes a chain-free map. Fix until it exits 0.
Phase 3: implement redirects
Implement at the fastest layer that can hold the whole map: CDN/edge or web server first, application last.
# nginx: exact-path map loaded once, O(1) lookups
map $uri $redirect_to {
include /etc/nginx/redirects.map; # lines like: /old-path https://www.example.com/new-path;
}
server {
if ($redirect_to) { return 301 $redirect_to; }
}
// next.config.js - fine for tens to hundreds of rules
module.exports = {
async redirects() {
return [
{ source: "/blog/:slug", destination: "/articles/:slug", permanent: true }, // 308
{ source: "/pricing-old", destination: "/pricing", permanent: true },
];
},
};
# Netlify _redirects
/blog/* /articles/:splat 301
/pricing-old /pricing 301
For thousands of one-off rules, use the platform’s bulk mechanism (Cloudflare Bulk
Redirects, a web-server map, or middleware backed by a lookup table) instead of a huge
framework config. Read AGENTS.md and the hosting config to find the right layer.
Phase 4: parity on staging
The new site must carry over, for every mapped URL:
<title>, meta description, H1 and main content (or deliberate improvements).- Self-referencing canonical on the new URL; no canonicals pointing at old URLs.
- hreflang clusters rewritten to new URLs (
hreflang-international-seo). - Structured data, Open Graph tags, image alt text.
- Internal links pointing directly at new URLs (not relying on redirects).
- Server-rendered content for pages that were server-rendered before; client-only rendering of previously static content is a common hidden loss.
- XML sitemap listing only new, indexable, 200-status URLs.
Crawl staging (behind auth or noindex, never blocked only by obscurity) and compare
against the old crawl by URL pair: status, title, H1, canonical, word count, indexability.
Remember to remove staging noindex, auth and Disallow: / at launch; leaving them on
production is the classic migration disaster.
Phase 5: launch day
Deploying to production is the user’s call: walk through this list, then wait for an explicit go-ahead.
- [ ] Redirects live; spot-check 50 random map rows plus the top 100 traffic URLs
- [ ] robots.txt allows crawling; no stray noindex in HTML or X-Robots-Tag headers
- [ ] New XML sitemap submitted in Search Console
- [ ] Domain moves: both properties verified; Change of Address submitted from the old property
- [ ] Analytics and tag manager firing on the new site; conversions recording
- [ ] Old robots.txt does NOT block old URLs (Google must crawl them to see redirects)
Verify redirects from the map (needs network):
while IFS=, read -r old new; do
got=$(curl -s -o /dev/null -w "%{http_code} %{redirect_url}" "$old")
[ "$got" = "301 $new" ] || [ "$got" = "308 $new" ] || echo "MISMATCH $old -> $got (want $new)"
done < <(tail -n +2 redirects.flat.csv)
Phase 6: monitor (8-12 weeks)
| When | Check |
|---|---|
| Daily, week 1 | 404s and 5xx in server logs and Search Console; redirect mismatches; organic sessions vs baseline |
| Weekly | GSC Pages report: indexed new URLs rising, old URLs moving to “Page with redirect” |
| Weekly | Rankings for top queries vs baseline; crawl stats |
| Weeks 4-12 | Page-level clicks compared old URL (baseline) vs new URL |
Some fluctuation for several weeks is normal. A sustained drop on specific page groups points to a mapping, content or rendering problem on those pages; fix there first.
Keep redirects in place for at least a year, and for domain moves keep the old domain registered and redirecting indefinitely; links pointing at it never stop arriving.
Gotchas
- Redirecting while the old robots.txt disallows the old paths: Google never sees the 301s.
- Trailing slash and case mismatches turning one redirect into a chain.
- Query-string URLs with traffic (
?id=123) ignored by a path-only map. - Pagination, faceted and tag pages dropped without decisions, losing long-tail traffic.
- Images and PDFs moved to a new CDN path without redirects.
- Hreflang and canonicals still referencing old URLs months later.
Output template
## Migration plan: <old> -> <new> (<type>), launch <date>
**Baseline saved:** <files, date range>
**Inventory:** N URLs (crawl n1, GSC n2, backlinks n3, logs n4) -> M unique
**Redirect map:** M rows, checker clean, implemented at <layer>
**Parity crawl:** differences found and fixed
**Launch checklist:** status per item (awaiting go-ahead)
**Monitoring:** owner, cadence, alert thresholds What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "SEO Migrations"
short_description: "Move the site, keep the rankings"
default_prompt: "$seo-migrations We're moving from WordPress to Next.js with new URLs. Build the redirect map and a launch checklist" scripts/redirect_map_check.py
#!/usr/bin/env python3
"""Check an SEO redirect map before it ships. Standard library only, offline.
Input: CSV with a header row containing the old and new URL columns (default
"old" and "new"; override with --old-col / --new-col).
Checks:
- duplicate source URLs (same old URL mapped twice, possibly to different targets)
- self-redirects (old == new after normalisation)
- chains (a target that is itself redirected) and loops
- relative or schemeless targets
- many sources collapsed onto one target (often the homepage: soft-404 risk)
- optional --inventory: URLs from the old-site crawl/GSC/backlinks that have no mapping
- optional --flatten FILE: writes a chain-free map (each source -> final target)
Usage:
python3 redirect_map_check.py redirects.csv
python3 redirect_map_check.py redirects.csv --inventory old_urls.txt --flatten flat.csv
python3 redirect_map_check.py map.csv --old-col source --new-col destination --collapse-threshold 20
Exit codes: 0 clean, 1 problems found, 2 bad input.
"""
from __future__ import annotations
import argparse
import csv
import sys
from collections import Counter, defaultdict
from urllib.parse import urlsplit, urlunsplit
def norm(url: str, keep_query: bool) -> str:
"""Normalise for comparison: lowercase scheme/host, strip default ports and fragments."""
url = url.strip()
p = urlsplit(url)
host = (p.hostname or "").lower()
if p.port and not ((p.scheme == "http" and p.port == 80) or (p.scheme == "https" and p.port == 443)):
host = f"{host}:{p.port}"
path = p.path or "/"
return urlunsplit((p.scheme.lower(), host, path, p.query if keep_query else "", ""))
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("map_csv")
ap.add_argument("--old-col", default="old")
ap.add_argument("--new-col", default="new")
ap.add_argument("--inventory", help="text file of old URLs (one per line) that must be mapped")
ap.add_argument("--flatten", help="write a chain-free map to this CSV")
ap.add_argument("--collapse-threshold", type=int, default=10,
help="warn when this many sources point at one target")
ap.add_argument("--ignore-query", action="store_true", help="drop query strings when comparing")
a = ap.parse_args()
keep_q = not a.ignore_query
try:
with open(a.map_csv, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
if not reader.fieldnames or a.old_col not in reader.fieldnames or a.new_col not in reader.fieldnames:
print(f"error: need columns '{a.old_col}' and '{a.new_col}', found {reader.fieldnames}", file=sys.stderr)
return 2
rows = [(r[a.old_col].strip(), r[a.new_col].strip()) for r in reader if r[a.old_col].strip()]
except OSError as err:
print(f"error: {err}", file=sys.stderr)
return 2
problems = 0
mapping: dict[str, str] = {}
targets_seen: dict[str, set[str]] = defaultdict(set)
for old, new in rows:
if not new:
print(f"EMPTY TARGET {old}")
problems += 1
continue
if not urlsplit(new).scheme or not urlsplit(new).netloc:
print(f"RELATIVE TARGET {old} -> {new} (use absolute https URLs)")
problems += 1
o, n = norm(old, keep_q), norm(new, keep_q)
targets_seen[o].add(n)
if o == n:
print(f"SELF REDIRECT {old}")
problems += 1
continue
mapping[o] = n
for o, ts in targets_seen.items():
if len(ts) > 1:
print(f"DUPLICATE SOURCE {o} -> {sorted(ts)}")
problems += 1
final: dict[str, str] = {}
for o in mapping:
path, cur = [o], mapping[o]
while cur in mapping:
if cur in path:
print(f"LOOP {' -> '.join(path + [cur])}")
problems += 1
break
path.append(cur)
cur = mapping[cur]
else:
if len(path) > 1:
print(f"CHAIN ({len(path)} hops) {' -> '.join(path + [cur])}")
problems += 1
final[o] = cur
counts = Counter(final.values())
for target, c in counts.most_common():
if c < a.collapse_threshold:
break
print(f"COLLAPSE {c} sources -> {target} (check these are true equivalents, not a soft-404 dump)")
problems += 1
if a.inventory:
try:
with open(a.inventory, encoding="utf-8") as f:
inv = [line.strip() for line in f if line.strip() and not line.startswith("#")]
except OSError as err:
print(f"error: {err}", file=sys.stderr)
return 2
missing = [u for u in inv if norm(u, keep_q) not in mapping]
for u in missing:
print(f"UNMAPPED {u}")
problems += len(missing)
print(f"inventory: {len(inv) - len(missing)}/{len(inv)} mapped")
if a.flatten:
with open(a.flatten, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["old", "new"])
for o, n in final.items():
w.writerow([o, n])
print(f"wrote chain-free map with {len(final)} rows to {a.flatten}")
print(f"\n{len(rows)} rows checked: {'clean' if not problems else f'{problems} problem(s)'}")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.