In a Drupal-to-headless migration, the content bodies almost always survive the trip. What breaks is everything that pointed at something else: taxonomy terms, media entities, and entity references. Drupal holds those together with internal integer IDs (term ID 42, media ID 918, node ID 3007), and the moment you export to a headless API those numbers stop meaning anything. If you don't rebuild the relationships explicitly, you land with all your boxes and no labels telling you which shelf they came off.
Let me tell you how I learned this the hard way.
The number that came back as a number
Picture the QA pass on a migration that everybody swore was done. The articles were in the new system, the images rendered, the client was already drafting the launch email. Then someone opened a random article and its category read 127. Not "Compliance." Not "Product News." Just 127. A bare integer, sitting there like a coat-check ticket for a coat that got left in the old building.
That is the whole problem in one screenshot. Drupal is a warehouse where every item has a barcode, and the barcode is what ties the box to its shelf. It's a genuinely good system (this is not a knock on Drupal, which is quietly excellent at data integrity). But the barcodes are private. They mean something only inside that one warehouse. Drive the trucks to a headless API and the barcodes are just ink.
Taxonomy: the terms move, the meaning doesn't
Taxonomy is where most teams get their first surprise. In Drupal, a term is its own entity with its own ID, and a node points to it by that ID. Export the node and you get the ID, not the human-readable name and definitely not the term's own fields, its parent, or its position in the hierarchy.
So you have to migrate the vocabulary as a first-class thing, before the content that references it, and keep a lookup table mapping every old term ID to its new home. Headless systems handle this differently: Contentful wants terms as their own entries you link to, Sanity leans on references or arrays of strings, Strapi has its own relation model. None of them will invent your hierarchy for you. Parent-child relationships, term-specific fields, and multi-level nesting all have to be rebuilt on purpose. Miss the ordering and you get orphaned references pointing at terms that don't exist yet, which is how you end up with 127.
Media: the file is the easy part
Since Drupal 8, an image isn't a file, it's a media entity that wraps a file and carries alt text, captions, focal points, credits, and usage data. Teams cheerfully copy the files across, watch the images render, and declare victory. Then the alt text is gone, the accessibility audit fails, and the focal-point crops are all centred on someone's left ear.
The file was never the hard part. The metadata around it was. And media gets referenced from two directions at once: as an entity reference field on a node, and as an inline embed inside body HTML (those <drupal-media> tags with a UUID buried in the markup). Migrate the entity references and forget the inline embeds and half your images vanish from inside the article text while the featured image sits there looking fine. You have to parse the body HTML, find every embed, and rewrite it to whatever your new platform uses.
Where the links actually snap
Here's the honest map of what travels cleanly versus what needs a rebuild and a lookup table.
| What you're moving | Travels as-is? | The trap |
|---|---|---|
| Body text and plain fields | Mostly yes | Inline media embeds and internal links hidden in the HTML |
| Taxonomy terms | No | Hierarchy, term fields, and old ID references all break |
| Media assets | File yes, entity no | Alt text, captions, focal points, credits dropped silently |
| Entity references (node to node) | No | Old node IDs mean nothing in the new system |
| Menu links and path aliases | No | URL structure changes, redirects needed for every moved path |
| Paragraphs / nested components | Rarely | Deeply nested reference trees flatten or orphan |
Notice how many rows say "no." That's not a Drupal failure. It's the cost of leaving a system whose whole personality is relationships between typed entities, for one that stores flatter documents behind an API. The relationships were doing real work. You just couldn't see them until you tried to leave.
Bring the ledger, not just the boxes
The fix is unglamorous and it works: inventory every reference before you touch anything. Every term ID and where it's used, every media entity and its metadata, every node-to-node reference, every inline embed. Build the old-ID-to-new-ID map and migrate in dependency order, terms and media first, then the content that points at them, then verify that nothing resolves to a bare integer.
This is the boring part that people skip because it doesn't render. It's also the part that determines whether launch week is calm or a series of "why is the category a number" tickets. Crawling the source site to surface those broken and orphaned references ahead of cutover is exactly the sort of thing our pre-migration inventory is built to catch, because a spreadsheet nobody updated is not a ledger.
So what would I actually do?
I'd treat the reference graph, not the article text, as the real migration. Map the taxonomy and media metadata first, freeze the old ID lookup table like it's payroll, and refuse to let anyone call the migration "done" until a crawl of the new site finds zero references resolving to raw integers. The body copy will be fine. It's always fine. The relationships are what you're really moving, and Drupal spent years being meticulous about them so that you could spend one week being careless and undo the lot.
Bring the barcode ledger. Otherwise you've just driven a very expensive truck full of unlabeled boxes to a nicer warehouse.
