Collapsing a Content Model in Place
Consolidating a content model on a live site is mostly an exercise in what you refuse to break. This is how we collapsed twelve content types into six without downtime — and the debugging discipline that kept the migration honest when the front end went blank.
Why a content model sprawls
Content models grow the way codebases grow: one reasonable decision at a time. A site launches with a handful of content types, and then every new kind of page seems to deserve its own type. Articles and case studies and news posts. Platforms and capabilities and solutions and industries. Each addition is defensible in isolation; the sum is a model with twelve content types, many of them structurally near-identical, each carrying its own slightly-different set of fields.
The cost is paid later, and by everyone. Editors face a dozen "Create" options where three would do. Fields that mean the same thing have different machine names across types, so nothing can be queried uniformly. The decoupled front end has to special-case each type. And every new field or template change has to be made — and tested — a dozen times instead of once.
Our model had reached that point. We set out to collapse twelve content types into six: a single solution umbrella (with Platform, Capability, Offering, and Industry as a discriminator), a single resource umbrella (absorbing articles and case studies), a consolidated page, and the genuinely distinct remainder. The hard part was not the target model. The hard part was that the site was live, and everything about those twelve types — the URLs, the search rankings, the editorial history — had to survive the move.
The constraint: change the model, preserve everything else
A content-type consolidation on a live site is defined by its invariants. Before writing a line of migration code, we enumerated what could not change:
- Node identity. Every node keeps its id and UUID. Anything referencing a node by id — internal links, integrations, analytics — must keep working.
- URLs. Every published alias survives. A consolidation that changes URLs is an SEO event, not a refactor. Existing aliases persist untouched.
- Revisions and moderation. The full editorial history and each node's moderation state move with it. No node silently reverts to draft.
- SEO metadata. Per-node titles and meta descriptions carry over, even as the underlying field storage changes.
- Uptime. The migration runs as part of a normal deploy, not a maintenance window.
These invariants ruled out the obvious approach — creating new content types and copying nodes into them — because copying mints new node ids and breaks every one of the guarantees above. The move had to happen in place.
The technique: rewrite the bundle, keep the node
A Drupal node's content type — its "bundle" — is stored as data, not identity. The node id is the identity; the bundle is a column. That is the seam the whole migration turns on: if you change only the bundle-key columns, the node itself — id, UUID, revisions, moderation state, aliases, timestamps — is untouched, because none of those live in the bundle column.
So the migration rewrites the bundle in place. For each node moving from a source type into its target umbrella, it updates the bundle value in the base table, the field-data table, and the bundle column of every dedicated field table — and nothing else. The node keeps everything that makes it itself and simply answers "what type are you?" differently afterward.
Two details make this safe rather than reckless:
- Order relative to configuration import. The bundle rewrite runs during database updates, before configuration is imported. That ordering is load-bearing: when the config import later removes the now-empty source content types, it finds no nodes attached to them, so it reconciles cleanly instead of cascade-deleting content. Reverse the order and the same import deletes the very nodes you were migrating.
- Discriminator fields, applied after. Folding four types into one
solutionwould lose the distinction between a Platform and a Capability — so before the rewrite, each node's original type is recorded, and after configuration import creates the new discriminator field, a companion script stamps each node with its type (Platform, Capability, Offering, Industry). The information isn't lost; it moves from the bundle to a field, where it belongs.
Every step is idempotent: a node already migrated is skipped, so a re-run — on staging, on production, after a rollback — is a no-op. That property is what lets a destructive-sounding operation run confidently on live data.
When the front end went blank: a lesson in premises
With the migration validated on staging, the decoupled front end started returning blank pages. The timing was damning: it coincided with a Next.js framework upgrade, and the error pointed at a framework-level image configuration. The obvious story wrote itself — the upgrade broke rendering. That story was wrong, and chasing it would have cost days.
The discipline that saved the time was refusing to accept the obvious premise without evidence. Instead of tuning the framework, we reproduced the failure and traced it to its origin: every page render was throwing while fetching its data. The data query, not the renderer, was failing.
The root cause was a single dead field. The front-end query still selected a field that the consolidation had removed from the GraphQL schema. GraphQL rejects a query that references a field the schema does not expose — so the entire query was invalid, the data fetch threw on every render, and the page rendered blank. The "Next.js bug" was a stale field name in our own query. The fix was to delete it.
The lesson generalizes well past this incident. When a symptom appears next to a suspicious change, the correlation is a hypothesis, not a diagnosis. Reproduce the failure, trace it to where the bad value originates, and verify the premise against the actual system — in a decoupled architecture, that means introspecting the live schema and diffing your query against it rather than trusting either side's assumptions. Blaming the framework is the comfortable answer; it is rarely the correct one.
How one empty field emptied a whole listing
A subtler failure surfaced from the same consolidation. A listing page — every Solution of a given kind — would occasionally come back completely empty, even though the content plainly existed. The cause sat at the intersection of the data model and the GraphQL layer, and it is worth understanding because the failure mode is so quiet.
The new solution type carried a single-value entity reference to its type. On most nodes it was set. On one node it was empty. When the GraphQL layer resolved that one node, the empty single-value reference resolved to null — and because of how the schema composes a list, a null on one item propagated up and nulled the entire list response. One unset field on one node blanked a listing of dozens.
The instinct is to patch the front end to tolerate the null. That treats the symptom. The correct fix is at the data layer: guarantee the field is populated — a migration fallback that assigns a sensible default, and a constraint that keeps it populated going forward — so the query has nothing to choke on. Robustness at the source is worth more than defensiveness at every consumer, because there is only one source and there are many consumers.
Reconciliation as hygiene
A consolidation is also an opportunity — and an obligation — to reconcile the fields that sprawled alongside the types. Merging types surfaces the duplication that accumulated across them: two fields that both mean "summary," a field that no type actually uses anymore, form displays that drifted apart until the same paragraph type looked different on two content types.
We treated that reconciliation as part of the work, not a follow-up: duplicate summary fields collapsed to one, unused fields removed at the storage layer, form-display drift normalized so a component looks and behaves the same everywhere it appears. The point of consolidating the model was consistency; leaving the fields inconsistent would have undercut it.
Principles worth keeping
- Define the migration by its invariants. On a live site, what you refuse to break — ids, URLs, revisions, SEO, uptime — determines the technique before any code is written.
- Change data in place when identity must survive. A node's bundle is a column, not its identity. Rewrite the column and the node keeps everything else.
- Order operations around the destructive step. Running the in-place rewrite before configuration import turns a cascade-delete into a clean reconcile. Sequencing is part of correctness.
- Make every migration step idempotent. Idempotence is what lets a destructive-sounding operation run confidently on production and survive a re-run.
- Distrust the convenient diagnosis. A symptom next to a suspicious change is a hypothesis. Reproduce, trace to origin, and verify the premise against the real system before you fix anything.
- Fix robustness at the source. One unset field can null an entire response. Guarantee the data at the layer that owns it rather than defending against it at every consumer.
Consolidating a content model is unglamorous work with an outsized payoff: a simpler mental model for editors, a uniform data surface for every consumer, and a platform that is cheaper to change for years afterward. Done in place, with the invariants named up front and the debugging kept honest, it is also work you can do on a live site without anyone noticing — which is exactly the point.