The most dangerous moment in a Drupal-to-headless migration is the one where everything looks fine. The content exported. The article titles are all there. Then an editor opens a launch-ready page and half the images are missing, the "related articles" block is pointing at a press release from 2019, and one product's category is now, somehow, a staff bio. Nothing errored. Nothing turned red. The references just quietly stopped meaning what they used to mean. That is the trap, and it lives in taxonomy, media, and entity references specifically because Drupal stitches your content together with numbers instead of names.
Drupal is a house where everything is labeled by drawer, not by name
Picture a house where nobody wrote what anything is. Instead, every object has a little tag: see drawer 47. The cookbook says "pairs with drawer 112." The remote says "belongs to shelf 9." It works perfectly, because the drawers never move.
That is a Drupal site. A node doesn't store the word "Espresso" in its category field. It stores target_id: 4471, and somewhere in the taxonomy tables, term 4471 happens to be Espresso (today). An article's hero image isn't a file path. It's a media entity ID, which points to a file entity ID, which points to the actual bytes on disk. Every relationship in the system is a drawer number.
Now you move house. The new place (Contentful, Sanity, Strapi, whatever you picked) has its own numbering. It assigns its own IDs on import, in whatever order the importer happened to run. Drawer 47 is now drawer 900. And every tag in your entire house still says "see drawer 47."
You didn't lose the content. You lost the addresses.
The three places the addresses come from
Reference breakage in Drupal isn't one problem. It's three, and they fail in different ways, which is exactly why teams patch one and think they're done.
| Reference type | How Drupal stores it | How it breaks headless |
|---|---|---|
| Entity reference fields (category, related content, author) | Serial target_id (nid, tid, mid) | New system reassigns IDs; the number now points at something else |
| Inline embeds in body text | <drupal-media data-entity-uuid="..."> tokens | UUID is stable, but the target CMS has no idea what that token means |
| Link fields to internal content | internal:/node/123 URIs | Node 123 doesn't exist as a path anymore |
Here's the small mercy hiding in that table. Drupal 8 and later stamp every entity with a UUID that never changes, even as the auto-increment IDs shuffle around. Inline media embeds already use that UUID (data-entity-uuid), which is why they are the most recoverable part of the whole mess, if you build your import to key on UUID instead of ID. The entity reference fields, meanwhile, store the throwaway serial number. They are the least recoverable, and they are usually the ones nobody checks.
Taxonomy is where it gets personal
Taxonomy feels safe because it's small. A few hundred terms. How bad could it be?
Bad, because taxonomy is load-bearing. Terms drive your category pages, your faceted filters, your "more like this" logic, and increasingly the structured signals that tell an answer engine what a page is about. When term 4471 stops being Espresso, you don't just lose a label. You lose a hub page, its internal links, and the topical clustering that machines were using to understand the section. And nobody notices, because the article still renders. It just renders under the wrong roof.
Worse, Drupal taxonomy is often a swamp before you touch it. Duplicate terms ("espresso," "Espresso," "Espresso "), orphaned terms attached to nothing, terms with a parent that was deleted years ago. Migrate that faithfully and you've paid to move broken furniture into a nicer house. This is the part where an inventory that flags duplicate and orphaned terms before cutover earns its keep. It's the whole reason we built the crawl the way we did.
What actually saves you
One rule, and it survives contact with every platform I've seen: migrate on stable keys, verify on both ends.
Build an explicit mapping table. Old UUID (or old ID) on the left, new system ID on the right, written down before you import a single relationship. Import your leaf content and your taxonomy first, capture the new IDs the target CMS hands back, and only then resolve references against that map. Never let two importers assign IDs independently and hope they line up. They won't.
Then count. Before migration, count how many nodes reference each term, how many media entities each article embeds, how many internal links resolve. After migration, count again. If article 500 had four category terms and now has two, you have a broken reference, not a rendering quirk. The numbers are the only honest witness here, and they cost almost nothing to collect. Our own pre-migration inventory exists to produce exactly those before-and-after counts, because "it looks fine in the editor" is how sites lose a section without anyone signing off on it.
And do the boring reconciliation for internal links. Every internal:/node/123 needs to become a real URL or a real reference in the new model. A link field pointing at a node that no longer resolves is a 404 waiting for the first crawler.
So what would I actually do?
I'd treat the reference layer as the migration, and the content as the easy part. Most teams do it backwards: they obsess over getting the body text across cleanly and treat taxonomy and media as plumbing to sort out later. Later is go-live, and go-live is a terrible time to discover that every category page is empty.
Map on UUID. Import taxonomy and media first, capture the new IDs, resolve everything against a written map, and count both sides. It is not glamorous and it will not make the demo look better. But it is the difference between a house where the cookbook still knows which drawer the coffee lives in, and a house where every tag confidently points you at the sock drawer.
Move the addresses, or don't bother moving in.
