Skip to content
All projects
CMS2022

Migrating a decade of pages into a real content model

Turning years of free-form HTML into validated structured fields, without losing a single URL — and rehearsing the whole migration against production data before cutover.

Role
Migration lead · B2B publisher
Stack
WordPressPHPNode.jsPostgreSQLContent modelling
  • Every legacy URL preserved or redirected, verified before cutover
  • Free-form HTML converted into typed, validated fields
  • Migration re-runnable, so cutover was rehearsed rather than attempted

The problem

Ten years of pages, written by dozens of people, stored as HTML in a single body field. Some had been pasted from Word. Some contained inline styles from a design system retired twice ago. A few had <font> tags.

The site needed a redesign, and the redesign kept stalling on the same discovery: you cannot restyle content when the content is the styling. Every attempt produced a template that worked for the pages someone checked and broke on the ones they hadn't.

Reading the content before designing the model

The instinct is to design the target schema first. I've watched that fail — the schema reflects what the new design needs, then meets a decade of content that does not fit, and the migration turns into a manual rewrite.

So the first work was analysis, not modelling. A script walked every page and counted what was actually in it: which tags appeared, how often, in what combinations. That produced an inventory rather than an impression.

Making the migration repeatable

The rule that shaped everything: the migration is a program you run many times, not an operation you perform once.

// Idempotent by construction: keyed on the legacy id, so a re-run updates
// rather than duplicating. This is what makes rehearsal possible — the
// migration can be run against a production copy as often as needed.
async function migratePage(legacy) {
  const parsed = parseBody(legacy.post_content);
 
  const issues = validate(parsed);
  if (issues.length) {
    // Never silently drop content. Anything that fails validation is
    // reported for a human decision, not skipped.
    await reportForReview(legacy.ID, issues);
    return;
  }
 
  await db.upsert("pages", { legacyId: legacy.ID, ...parsed });
}

Each run produced a report: how many pages converted cleanly, how many needed review, and what specifically failed. Successive runs pushed the review pile down as parsing improved. By cutover the number was small enough to handle by hand, and — more importantly — known in advance rather than discovered on the night.

URLs are the part you cannot undo

Everything else in a migration is recoverable. Broken links and lost search rankings are not, or not quickly.

Every legacy URL was enumerated up front and mapped: preserved, or redirected to a specific replacement. Not a catch-all rule to the homepage, which search engines treat as a soft 404 and which strands anyone following an old link.

The map was tested as part of each rehearsal — crawl the old URL list against the new site, assert every one returns 200 or a 301 to something that returns 200. That check ran until it was clean, and it was the gate on cutover.

What the model actually fixed

Content became data. A heading is a heading field, not markup that happens to look like one. Which meant the redesign that had stalled twice became a template change, and the one after it will be too.

The validation rules that came with the schema — length limits, required fields, references instead of pasted URLs — also meant the pile of legacy oddities could not start accumulating again from day one.

Outcome

Every URL accounted for, a decade of unstructured pages converted into typed fields, and a cutover that had been rehearsed against real data enough times that the live run was uneventful.

The part worth taking to any migration: make it re-runnable early. The difference between a migration you can rehearse and one you can only attempt is the difference between a routine evening and an incident.