Composing Navigation in a Headless CMS
How a decoupled Drupal 11 + Next.js platform gets navigation that maintains itself — curated where it should be, automatic where it can be, correct everywhere — and why we turned the result into an open-source module.
The problem hiding inside "just add it to the menu"
Navigation is the most-used feature of almost any site and one of the least-examined pieces of its architecture. In a traditional Drupal site, menus are hand-curated: an editor places every link by hand, and the theme renders whatever the menu tree contains. That model quietly breaks down on a content-heavy, decoupled platform, and it breaks down in four distinct ways at once.
We hit all four while doing something that should have been trivial — publishing a single new page and adding it to the main menu.
- No hybrid behavior. Publishing a new offering never surfaces it in the navigation. Every link is placed by hand, so the menu is perpetually a manual mirror of the content — and perpetually out of date.
- Broken link URLs. A menu link created through the content form pointed at an editorial route —
/node/107/latest. There is no path alias for that route, so the decoupled front end resolved it verbatim and returned a 404. The link looked fine in Drupal and was dead on the site. - Stale navigation. Publishing or unpublishing a node did not invalidate the navigation cache. And when menu changes were revalidated, the signal went to a single front end while content changes fanned out to all of them — so a multi-environment deployment could serve a stale menu indefinitely.
- Not reusable. The one dynamic-menu mechanism we already had was a bootstrap script with hardcoded vocabulary names, generated at runtime and never committed as configuration. It solved one site's problem and could not travel.
Each of these is a small bug. Together they describe a missing capability: hybrid navigation that composes itself correctly for a headless architecture. This paper is how we built it, and the principles that made it reusable.
Principle: the backend owns the whole tree
The first and most consequential decision is where navigation is composed. There are two options in a decoupled system: compose the tree in the front end (fetch content, decide the structure in React), or compose it in the CMS and let the front end render one finished tree.
We chose backend composition, without hesitation, for one reason: navigation is a cross-cutting concern that more than one consumer needs. A headless CMS may feed a Next.js site today, a native app tomorrow, and a JSON:API integration after that. If the logic that decides "what goes under Platforms" lives in the React app, every other consumer has to re-implement it — and they will each get it subtly wrong. If it lives in the CMS, every consumer asks the same question and gets the same answer.
So the CMS owns the entire menu tree, including the automatically generated parts, and exposes it through exactly one query. The front end fetches menu(name: MAIN) and renders what it gets. There is no navigation-specific code in the front end and no navigation-specific schema for the API layer to learn — because the generated items are ordinary menu links, indistinguishable from hand-placed ones to anything downstream.
The hybrid model: curate the top, automate the children
The insight that makes navigation both editorial and automatic is that these are different jobs at different levels of the tree. Editors have strong opinions about the top level — the handful of sections that define the site's information architecture. They have far less appetite for hand-maintaining the children — the long, churning lists of individual pages beneath each section.
So we let editors curate the top level exactly as they always have, and we let each curated item declare where its children come from. A parent menu link can be marked "dynamic," and its children are then generated and continuously reconciled from published content. An editor picks the source per item, choosing from:
- A taxonomy term — every published node whose reference field points at a given term (for example, every Solution whose type is "Platform").
- A content type — every published node of a bundle.
- A hand-picked list — an explicit, ordered set of nodes, for the cases where curation is the point.
When content is published, updated, retyped, or unpublished, the affected parents reconcile automatically: child links are created, retitled, reordered, and pruned to match the current set of published content. The reconciliation is a diff — it computes the desired set of children, compares it against the links it already owns, and makes only the necessary changes. Running it twice in a row is a no-op. This "diff before write" discipline is what lets the same operation run safely on every content save and as a nightly self-healing sweep.
Crucially, the module only ever touches links it created. Hand-placed links under a dynamic parent are left exactly where the editor put them. The automatic and the curated coexist in one tree.
The invariant that makes it headless-safe
The broken /node/107/latest link was not an accident of one editor's workflow; it was a symptom of a missing invariant. In a decoupled system, a menu link's stored URL is resolved by the API layer into whatever the front end will navigate to. If the stored value is an editorial or internal route, the front end faithfully renders a URL that has no page behind it.
The fix is a single rule, enforced everywhere links are written:
A generated link always stores a canonical entity reference — entity:node/<id> — never an alias, an internal path, or an editorial route.
A canonical entity reference resolves, at query time and per language, to the node's real published path alias. It is stable across alias changes, immune to the /latest class of bug, and correct in every language automatically. The module writes only canonical references for the links it manages, and it ships a normalizer — available as a Drush command and as a deploy-time update — that rewrites any pre-existing editorial-route links (the /node/107/latest problem) to their canonical form. Adopting the module cleans up the mess that predates it.
This is the kind of invariant worth naming out loud, because it is invisible until it is violated. It is the difference between navigation that happens to work and navigation that cannot break.
Keeping the tree fresh, everywhere
A composed tree is only as good as its freshness. Two revalidation problems had to be solved together.
First, menu changes must invalidate the navigation cache. Because the generated children are real menu links, every create, update, reorder, and delete the reconciler performs already fires the entity hooks that bust the shared menu cache tag — the automatic sync and the cache invalidation are the same event, not two things to keep in step.
Second, the invalidation must reach every front end. Our original code busted the menu tag on a single endpoint while content changes fanned out to all configured front ends — an asymmetry that would leave secondary environments serving a stale menu. We closed it: menu revalidation now loops the same front-end registry that content revalidation uses, and coalesces identical busts within a request so that a bulk publish or a full reconcile triggers at most one invalidation per front end rather than a storm.
The result is a navigation cache that is aggressive (a long time-to-live, so warm reads never pay a round-trip) and always correct (any menu, alias, or content change that could move a link invalidates every language variant on every front end at once).
Accessibility is a front-end responsibility — and a solved one
Because the backend hands the front end one coherent tree, the accessible behavior lives entirely in the presentation layer, where it belongs. That made it possible to get navigation accessibility genuinely right, and it surfaced a common mistake worth calling out.
The intuitive choice for a nav with dropdowns is the ARIA menubar role. It is the wrong choice for site navigation. The menubar role models a desktop application menu bar: it captures the arrow keys, pulls items out of the normal tab order, and makes the Tab key skip the entire navigation — none of which a person browsing a website expects. Worse, declaring the role without implementing its full keyboard model is actively harmful, because a screen reader announces behavior that is not there.
The correct pattern for site navigation is Disclosure Navigation: a real <nav> and list of links, where a parent with children is a button that discloses its submenu. Tab moves through items the way it moves through any page; Enter or Space toggles a submenu; Escape closes it and returns focus to the button; arrow keys move within an open submenu. The current page is marked with aria-current="page", and all motion is suppressed under prefers-reduced-motion. It is more usable, more robust, and conformant with WCAG 2.1 AA — and it is the pattern the platform ships.
Ready for more than one language
The same canonical-reference invariant that fixes broken URLs also makes the navigation language-aware for free. Menu links are translatable content entities; their titles can carry per-language labels, while the shared entity:node/<id> reference resolves to the correct alias in whatever language the menu is requested. The API exposes an optional language argument, and the front end caches the resolved tree per language under one shared cache tag, so a single invalidation refreshes every translation at once. A monolingual site pays nothing for this; a multilingual site gets a correct translated menu with no additional plumbing.
From one site's fix to a platform capability
The last of our four problems was that our existing mechanism could not travel. We treated that as the most important one. A capability that solves a recurring problem across clients is not a bug fix; it is a product in disguise, and the discipline of building it as one is what turns bespoke work into a portfolio.
So the engine is not webcms code. It is a standalone, open-source Drupal module — Menu Autopilot — with no hardcoded vocabulary names, no client specifics, and no assumptions about the front end beyond the one it cannot avoid: that clean URLs matter. Everything site-specific stays in configuration; the reusable core ships as a module any Drupal site can install. It is, deliberately, to navigation what Pathauto is to URL aliases: the automatic, configuration-driven default that a site turns on and stops thinking about.
Principles worth keeping
The specific module matters less than the principles the work surfaced, which apply to any headless content platform:
- Compose shared concerns in the backend. If more than one consumer needs the answer, the CMS should own the question. Navigation, like search facets and breadcrumbs, is a cross-cutting concern, not a front-end detail.
- Name your invariants. "Links always store canonical references" is invisible until it is violated. Writing it down — and enforcing it in code — is what makes a system that cannot break rather than one that happens to work.
- Make correctness and freshness the same event. When the mechanism that changes data is also the mechanism that invalidates its cache, the two cannot drift.
- Pick the right accessibility pattern, not the intuitive one. For site navigation, that is Disclosure Navigation, not menubar.
- Build the bespoke thing as if it were a product. Parameterize instead of hardcoding, keep client specifics in configuration, and the seam for extraction is already there when the opportunity is.
Navigation that maintains itself is a small feature with a large surface area. Getting it right required treating it as an architecture problem rather than a menu problem — and the payoff is a site whose navigation is correct by construction, an accessible experience that meets the bar without heroics, and a reusable capability that outlives the engagement that produced it.