A migration is done when every source record has an accounted outcome, every public URL still answers, editors can publish without WordPress, and you can roll back. A site that merely looks right can still be missing records, URLs or editorial flows, which is why the checklist is split into phases that each end in a gate with evidence.
How to work with this skill
Don't paste the whole checklist back at the user. Instead:
- Setup mode for a new migration. If the user wants to start or set up a migration,
or the project has no migration evidence yet, run the intake in
references/setup.md: at most 10 questions viaAskUserQuestion(the old site's URL or code, how much should change, URLs, body format, editorial features, plugins, hosting, audience, freeze, approver), a read-only look at the old site between rounds, and aMIGRATION-BRIEF.mdthat marks n/a items and pre-fills the decision packet. - Find out where the project is. Look for evidence before asking: an audit folder or reports (inventory, field matrix, URL manifest), a Payload config and collections, an import ledger or reconciliation report, a crawl report, deploy scripts, a cutover runbook. Map what you find to a phase below. If there's no repo yet, start at Phase 0.
- Check the current phase and the gate before it. Mark each item done (with its evidence: a file, a test name, a commit, a command output), missing, or n/a (with the reason: e.g. the site has no comments). An item without evidence is missing, however likely it seems.
- Report the gaps first, ordered by what blocks the next gate, then what's done. Offer to fix or write the missing pieces (tests, scripts, reports).
- For a fresh project, help the user copy the phases into the repo as
MIGRATION-CHECKLIST.mdor as tracker epics whose acceptance criteria are the checklist lines, so the evidence accumulates where the work happens.
Read references/pitfalls.md before the audit, conversion, import and deploy phases: it
lists concrete traps with fixes. Read references/cutover-runbook.md when planning the
cutover. Several pitfalls are tied to specific Payload/Next/nginx versions; confirm them
against current docs when the project uses different versions.
Ground rules
- Production stays read-only until cutover. Audit a restored local copy through a
query guard that only allows
SELECT/SHOW/DESCRIBE/WITH/EXPLAIN. Mistakes on a copy are free; on production they're incidents. - No silent loss. Every table, field, status and comment class gets a disposition: preserve, transform, retire with a reason, or unresolved. Unresolved blocks the gate, because "we'll decide later" usually means "we dropped it".
- Keep hosting, CMS and redesign apart. Each changes risk independently; bundling them makes failures impossible to attribute.
- The owner decides; you recommend. Collect decisions in a numbered packet (D1…Dn) with evidence, options and a recommendation, and record the answers and dates in the repo. Re-open a decision when its evidence changes.
- Test first. A failing test, then the fix, per task. Migrations regress quietly, and tests are what keep fixed traps fixed across re-imports.
- Re-verify every number that drives a decision, with a second query or method.
Phase 0: Backups and source evidence
- Full DB dump (with revisions) and a revision-free copy, both restored locally, with counts per post type × status identical to production; sha256 manifests stored. Revisions are usually most of the bytes and none of the migration.
- Note table engines: MyISAM isn't transactional, so a consistent dump needs a read lock the owner has approved, or a quiet window.
- Uploads archive with a per-file manifest (path, size, sha256), extracted and verified. Leave out backup-plugin folders on purpose, and say so.
- DB and media timestamps recorded (they're rarely captured together; expect drift).
- Plugin and theme source copied read-only; the real WordPress version taken from
db_version, not assumed. - Server access by SSH key, checked from a new terminal before the old one closes; no passwords stored in notes or the repo.
Phase 0a: Audit (what exists, not what the frontend shows)
- DB inventory: tables by owning plugin, row counts, post types × statuses, including statuses left by deactivated plugins (invisible in wp-admin).
- Active vs installed plugins: inactive plugins still own data (comments, avatars, donations, views), and each needs a decision.
- Field matrix: every meta key / ACF / Pods field → label, type, cardinality, populated count, owning plugin, target field, transform, disposition.
- Admin screen map: screenshots of every edit screen (posts, pages, custom post types, media, users, terms); each control → storage → target. Flag unmapped (a control with no data) and hidden (data with no control). Note classic vs block editor per screen.
- Authors and credits: co-author storage and order, guest authors, the fallback to
post_author; extra roles (translator, photographer) checked against body text. Public author profiles are not login users, and password hashes don't migrate. - API contracts: custom REST endpoints the current frontend uses (routes, permission callbacks, response shapes). The new content layer must reproduce them while both run.
- URL manifest: every public URL by kind: posts, pages (with hierarchy), custom
post types, categories, tags, authors, plus pagination (
/page/N/), feeds (/feed/, per-category feeds), date archives, attachment pages and search. Note slug collisions across types and existing redirects (Redirection plugin,_wp_old_slug, framework config), and check that redirect targets exist. - Media reconciliation: attachments vs files (missing, orphans, no metadata), size variants, inline URLs to the old domain or a CDN, images hot-linked from other sites.
- Bugs in the current site recorded in the tracker, so they aren't rebuilt.
- Fixtures: 10–20 real, difficult records (multiple authors, footnotes, captions, embeds, tables, long text, odd encodings, slug collisions), with a manifest of checks a machine can run on each.
- Decision packet drafted with the questions below.
Decisions the packet usually needs: hosting · author profiles vs logins · byline order and fallback · extra credit roles · comments (native, read-only archive, third party, or dropped; approved only; spam and unmoderated stay in the source archive; close on old posts?) · donations or other personal data (usually not imported) · editorial workflow (drafts, stages, scheduled publishing) · fields to retire · analytics and Search Console · body format (convert to Lexical, or keep legacy HTML) · admin URL and language.
Gate 0: field matrix, screen map and author mapping reviewed; decision packet answered.
Phase 1: Payload proof on fixtures
- Next, React and Node versions that the chosen Payload release supports, pinned exactly, with one lockfile. Upgrade the runtime in its own commit before adding Payload, so a breakage points at one change.
- Collections modelled from the field matrix, not from the old frontend's types.
- Fixtures import; every manifest check passes or is listed as lossy.
- Editor flow: edit → save draft → preview → publish → edit again → restore an earlier version. The public page shows the last published version throughout; anonymous visitors never see drafts.
- Two editors on one document: document locking behaves as expected.
- Opening and saving an imported document doesn't change its stored source.
- Author pages list exactly the fixtures credited to each profile.
- The owner has walked through the admin (screenshots or a live session).
Gate 1: the owner accepts the editor workflow; unsupported features are listed with how each will be handled.
Phase 2: Content conversion (HTML → Lexical)
- Sanitized legacy HTML kept unchanged next to the converted body, with a
conversionStatus(clean / lossy). Lossy documents go to a manual review list. - Per-document checks, legacy vs converted: headings, images, images with empty alt, footnotes, captions, embeds, links, blockquotes, list items, tables.
- Images resolved to Media docs before conversion (Payload drops plain
<img>). - Links normalized so Lexical validation accepts them; embeds and footnotes kept (see pitfalls).
- Shortcodes and plugin markup converted or reported.
Phase 3: Full import and reconciliation
- An importer that is idempotent and resumable, with a ledger per run and per record (created / updated / unchanged / kept-admin-edit / skipped / failed). A second run straight after reports 0 created and 0 updated; that's what makes the final import at cutover safe and fast.
- Nothing that wasn't published in WordPress becomes public; original dates and the site timezone kept; drafts and custom statuses mapped explicitly.
- Import side effects off: no emails, notifications, schedules or search pings.
- Public reads enforce access control (the Local API bypasses it by default).
- Reconciliation report: source vs target per type × status, 0 mismatches, including orphans (target rows with no source).
- Preflight that stops with one clear error when the source DB is unreachable.
- Run time, peak memory, DB size and media size recorded; they size the server.
Gate 3: reconciliation clean, lossy list reviewed, full URL crawl passes locally.
Phase 4: Public site parity
- Public pages read Payload only. A content-source switch makes any accidental WordPress fetch fail loudly instead of quietly serving stale data.
- URLs keep WordPress's shape, trailing slashes included; one place handles
redirects and leaves
/apiand the admin path alone. - Crawl of the whole URL manifest: N/N return 200, with latency recorded.
- Real 404s for unknown slugs and pages past the last listing page (soft 404s hurt SEO).
- Old
/wp-content/uploads/...URLs 301 to the imported files (relativeLocation); a missing file returns 404, not 500. - Feeds exist where WordPress had them, or redirect deliberately.
- Search works and covers the imported content.
- One canonical origin in config; sitemap, canonical tags, robots and OpenGraph URLs derive from it. Archive canonicals point at themselves.
-
og:imageis an absolute URL on the production host (checked in rendered HTML); favicon, share images and titles from the starter template replaced. - Every content type is reachable (custom post types need routes and listings).
- Author index and author pages from the new data; byline order and links.
- Phone width: no horizontal scroll on long articles; tap targets, contrast,
lang, main landmark, visible focus. - Comments, if kept: threaded view, a notice when closed, nothing rendered when closed and empty.
Phase 5: Admin and editorial operations
- Admin at a non-default path (the default returns 404), defined in one module.
- Admin in the editors' language: default locale, collection and field labels, options, help texts; types regenerated afterwards. Short guides for editors and admins.
- Logins only for people who edit; how author names are displayed, with an override per profile.
- Excerpts, bios, featured images and SEO fields editable.
- Media can't be deleted while documents still reference it.
- Scheduled publishing (if decided): durable jobs, cron started at boot, one runner, the editors' timezone as default, overdue jobs run as soon as possible, retries with an alert after the last failure.
- Comments (if kept): moderation queue, spam guard, per-IP rate limit with the proxy
overwriting
X-Forwarded-For. - Email configured; password-reset links use the public origin.
- Probes against a production build: no draft leaks, upload pipeline (validation, image processing, write, delete), scheduled publish end to end.
Phase 6: Hosting and staging
- Reachability measured from where the audience is (some regions block or throttle certain clouds and CDNs) before the host is chosen.
- Repeatable deploy: build on the server (native modules), health check,
automatic rollback, several releases kept,
payload migraterun in the deploy. - Process manager with memory limits; firewall open only for SSH, HTTP, HTTPS.
- Staging is access-restricted and
noindex, so search engines don't index a duplicate of the site. - Staging crawl N/N.
- Restore drill: the backup restored into a new database, the app started on it,
crawl N/N. Use
pg_dumpmatching the server's major version. - Nightly DB and media backups landing off the server, and checked.
Gate 6: staging crawl N/N, restore drill passed, off-server backups landing.
Phase 7: Cutover
Follow references/cutover-runbook.md. In short:
- Owner's written go-ahead; editors told about the freeze.
- Freeze, then check whether WordPress changed since the last snapshot; if it did, run the final import and reconcile.
- Data restored into a new database; the previous one kept for rollback.
- TLS planned for before DNS points at the new host.
- DNS changed by script (dry run, apply, old values saved, rollback) and only the records intended; mail records untouched.
- From outside, including the audience's regions: key pages 200;
http,wwwand old hostnames 301 to the canonical origin, keeping path and query. - Public crawl N/N; admin smoke test including a publish scheduled two minutes ahead.
- Rollback trigger defined, and any edits made after the freeze listed.
Phase 8: First week and decommissioning
- Daily: application errors, failed scheduled jobs, off-server backups.
- Search Console verified for the canonical host, sitemap submitted; analytics recording.
- Social preview caches refreshed for the main pages.
- WordPress kept read-only (and backed up) for an agreed period, then archived; old hosting shut down only with the owner's approval.