CMS migrations: the script is the deliverable
Somewhere in every CMS migration there is a meeting where someone says "the content team can just copy things over." That meeting is where the budget dies. We have migrated newspaper archives, museum collections, university course catalogues and a decade of podcast transcripts, and not once has manual copy-paste been the honest answer. The honest answer is a script — a real, versioned, tested piece of software that moves every record from the old system to the new one, and can do it again tomorrow.
This is the position we hold from the first scoping call: the migration script is the deliverable. The new CMS is furniture. The script is the move. If you treat the script as a throwaway you'll write it twice, badly, at 11pm, during the cutover window, with the client watching. If you treat it as the product, the cutover becomes the boring part — which is exactly what a cutover should be. (Our broader field manual lives in the headless CMS migration runbook; this piece is about the engineering inside it.)
Inventory before export
Before you write a line of migration code, you need to know what "everything" means. Old CMSs lie. The admin says 4,100 articles; the database says 4,347, of which 212 are orphaned revisions, 39 are test pages titled "asdf", and 17 contain embedded spreadsheets from 2009 that render as raw XML.
Our first artifact is always an inventory report, generated by crawling the old system's API or database directly:
- Every content type, with live/draft/archived counts
- Field-level fill rates — which fields are populated on 98% of records and which on 4%
- Media referenced but missing, media present but unreferenced
- The top 50 pages by traffic (from analytics) annotated with their content type, so the team understands what readers actually touch
- Every URL pattern in the sitemap, deduplicated and grouped
The fill-rate column is where scope gets honest. If author_bio is filled on 4% of records, nobody migrates author_bio as a first-class field — it becomes a footnote appended to the body, or it dies. Inventory turns "migrate everything" from a sentiment into a list.
The shape of a good script
A migration script we trust has four properties, and we will delay a launch to get them.
It is idempotent. Run it twice and the second run is a no-op. We key every target record on the source system's ID, stored in a legacy_id field in the new CMS. The script upserts, never blindly inserts. This one property converts cutover from a cliff edge into a dial, because re-running the whole migration after a fix is free.
It is resumable. Fifteen thousand records will hit a timeout, a rate limit or a malformed blob somewhere around record 11,000. The script writes a checkpoint file after every batch and can resume from it. We also log every per-record failure to a JSONL file with the source ID, the error and a stack trace — never to stdout, where it scrolls into oblivion.
It has a dry-run mode. migrate --dry-run exercises every transform, every media rewrite and every validation rule, then reports what would change: creates, updates, skips, failures, checksum totals. Dry-run output goes to the client as a spreadsheet. It is the single most reassuring artifact in the whole engagement, because it converts a scary opaque process into a table they can read.
It is deterministic. Same source, same output, byte for byte. No Date.now() stamped into content, no random slugs, no ordering dependent on API pagination order (sort by ID explicitly). Determinism is what makes the next section possible.
Checksums, not vibes
"Did everything come across?" is the question that ruins relationships if your answer is "looks like it." Our answer is a reconciliation report, produced by the script itself:
For each content type, we compute counts (expected vs. actual), and then a content checksum: for every record, hash a canonical serialisation of the fields we promised to migrate. Sum the set of hashes on both sides. When the multisets match, the content matched — not approximately, exactly. When they don't, the report lists the source IDs that differ, and those records get eyeballed by a human.
Two hard-won notes. Canonicalise before you hash: sort object keys, normalise line endings, trim trailing whitespace, or you'll spend a day chasing phantom diffs caused by \r\n. And checksum media as well as text — we hash the source media URL against the rewritten URL in every body field. A body that still references oldcms.example.com/uploads/… after migration is the classic silent failure, and it only surfaces when the old hosting contract lapses six months later. Ask us how we know.
The reconciliation report is also the acceptance document. "Record counts and content checksums match across all 14 content types; 6 known exceptions listed in Appendix B" is a sentence a client can sign. "We clicked around and it seemed fine" is not.
Redirects are a deliverable, not a fire blanket
Every migration is also a URL migration, even when the domain doesn't change, because slug schemes, date prefixes and category paths almost always shift. The redirect map deserves the same rigor as the content:
- Generate it from data. The inventory crawl gave us every live URL; the migration script knows the new slug it computed for each record. The redirect map is a join of two artifacts we already have — generated, not handwritten.
- Test it mechanically. In staging, request every source URL in the map and assert a 301 to the expected destination. Thousands of assertions, run in CI. We treat a redirect test failure the same as a broken build.
- Decide the 410s on purpose. Some pages should die — the "asdf" pages, the decade-old campaign microsites. The choice to return
410 Goneis an editorial decision; we put a candidate kill list in front of the client, flag the ones with backlinks or traffic, and let them sign off. Deleting by accident is a content loss event; deleting on purpose is gardening. - Keep the map forever. The redirect table ships as deployable configuration, in version control, alongside the site. We have clients whose 2019 redirect maps are still serving in 2026, quietly catching ancient backlinks. Long-lived redirects are cheap and they compound; our caching and invalidation notes cover how we serve them at the edge so they never touch origin.
Search traffic is where bad URL work shows up first, so we brief the SEO side early — the redirect map and the technical SEO launch checklist are two halves of the same launch.
The content freeze question
Editors don't stop working while you migrate, which means the source is a moving target. Two honest options:
A short freeze. Freeze editorial changes for 48–72 hours around cutover, run the final migration, go. This works for small sites and calm publishing schedules. Publish the freeze dates in the plan, in writing, with a named process for emergencies ("if something must ship during the freeze, it ships in the old CMS and we re-run the delta").
A delta loop. For busy publications we run the script on a schedule — nightly, then hourly near launch — because idempotence makes re-runs safe. The final cutover run is the same script, one more time, with a small delta. This is the pattern we used migrating the 60,000-item Postcards from the Museums archive: curators kept cataloguing through the entire build, and the cutover delta took eleven minutes.
What never works is the informal freeze — "we'll just ask everyone to be careful." Six people will be careful. One will not, and their edit will be the one that mattered.
Cutover week
If the script is the deliverable, cutover week is a formality, and formality is the point. Our run of show: dry-run report circulated Monday; final migration run Tuesday with reconciliation report attached; redirect tests Wednesday; DNS or deployment flip Thursday, with the old system left running read-only for a fortnight; decommission decision in writing at the end of the month. Every step has a named owner and a rollback note, even though we have never once rolled back — the rollback plan exists so nobody panics, and calm teams don't make the mistakes that need one.
The deeper reason to invest here: the script outlives the project. Clients re-platform again, restore from disasters, seed staging environments and spin up regional editions — all from the same machinery. We hand over the script, the reconciliation tooling and a README written for someone who has never met us. That's what done looks like on a content-heavy website build.
Key takeaways
- Treat the migration script as the product: versioned, reviewed, tested, and handed over with documentation.
- Make it idempotent (key on legacy IDs), resumable (checkpoints), dry-runnable and deterministic. These four properties turn cutover into a non-event.
- Prove completeness with content checksums on both text and media references — never with spot checks.
- The redirect map is generated from the same data the script already has, tested in CI, and kept in version control forever.
- Pick a freeze strategy explicitly — short freeze or scheduled delta loop — and write it into the plan where editors can see it.
FAQ
How long should a CMS migration take?
For a mid-size publication (5,000–50,000 records), we budget four to eight weeks: a week of inventory and mapping, two to four weeks building and iterating the script against real data, one to two weeks of client verification, and a quiet cutover. The single biggest variable is the quality of the source data, which is why inventory comes first.
Do we need to migrate drafts and revisions?
Almost never — but decide on the record. We typically migrate published content and the current draft state, archive everything else to cold storage (a JSON export per record), and note the retrieval process in the handover. Revision history is beloved in theory and consulted approximately never; keep it accessible, not live.
What about content that's hard-coded or scattered outside the CMS?
The inventory crawl has to cover it: template partials, landing pages living in code, PDFs linked from navigation. We classify each as "migrate," "rebuild" or "retire" and add it to the same manifest. Scattered content is usually 5–15% of the total and 100% of the surprises.
Should we redesign during the migration?
It happens constantly and it can work — but sequence it. Migrate content faithfully into the new model first, with checksums proving parity, then redesign against a stable dataset. Redesigning while migrating means every checksum failure becomes a two-hour argument about whether it was the migration or the redesign. One variable at a time.
The headless CMS migration runbook
A field-tested runbook for migrating to a headless CMS: content inventory, model mapping, redirect strategy, preview parity, and the cutover weekend checklist.
The TypeScript strictness ratchet: stricter every sprint, never a big bang
Strict mode on a legacy codebase is a migration disguised as a flag. The ratchet approach: incremental flags, scoped configs, codemods and type-debt metrics.
End-to-end type safety from CMS to component
How we generate TypeScript types from headless CMS schemas, validate content at the boundary, and write content models that never cause the 6pm rename incident.