In the previous post in this series, I described how we rebuilt our page templates with Layout Builder. I ended that post by mentioning that templates are less suitable when the page hierarchy must be derived from the data itself.
To explain that part of the rebuild, it helps to look at a more basic change. In the Drupal 7 site, an imported data object and the page that presented it were the same node, whereas the rebuilt site stores them as separate entities.
Most of the information displayed on the site is owned by an external system. Drupal keeps local representations of selected objects so that it can identify them, relate content to them and use their source identifiers when retrieving further data. I will call these local representations data objects here. Pages are assembled from configurable block plugins that use these objects as the starting point for their queries.
The separation affected imports, page creation and the contexts passed to those blocks. It also addressed several problems that had accumulated around the Drupal 7 nodes.
When the data object was the page
The Drupal 7 migrations imported the main data objects directly into node types. The source identifier became a field on the node, the source name became its title and information from the source helped determine its publication state.
The same node was also the public page. It had a URL, a Panels display and the usual Drupal publishing and access behaviour. A ctools page element received the node as context and used its source identifier to retrieve the data it needed.
This was a practical solution because nodes already worked with fields, Views, aliases, permissions and Panelizer. There was no additional entity type or relationship to maintain, and the route context was also the data context.
The problems started when the source data and the public page no longer needed to say the same thing. For example, a name could change in the source system while the public site was meant to keep the previous name. The migration mapped the source name to the node title, so we added a separate, locally managed name field and display logic that could use it instead.
Publication required similar care. New data objects had to enter Drupal as unpublished nodes. On later imports, however, the migration retained the node's existing status when the source had a public version, but forced it to unpublished when no such version existed. This logic had to account for both states because the node represented both the imported object and the public page.
The edit interface exposed the same conflict: imported fields and editorial display settings lived on the same node. We therefore built a separate settings form that exposed only the locally managed fields, while other fields on the normal node form were disabled because the migration owned them.
These workarounds were manageable individually, but they all came from the same modelling problem: a single node represented both an object owned by the source system and a page owned by Drupal, although the two had different lifecycles.

Separating data objects from pages
For the rebuild, we introduced a custom content entity for data objects. It stores the stable local identity of an object from the source system, selected properties used frequently by Drupal and the relationships needed to find associated data. It is deliberately not a public page and has no useful canonical URL.
Drupal's content entity API lets this entity use field storage, references, access handling and Views integration without also making it a node. The imported representation can therefore use normal Drupal APIs without acquiring a public URL, an editorial publication state or a Layout Builder layout.
The public page remains a node, which owns its URL, title, publication state, editorial metadata and Layout Builder configuration. An entity reference connects it to the relevant data object.
When a page is first created, values such as its title can still be derived from the data object. After that, the two values can develop independently. A changed name from the source updates the data object, but it no longer overwrites the title of the public page. There is no need for another field whose only purpose is to override an imported node title.
Once the page and data object are separate, their relationship no longer has to be one-to-one. A main data-driven page usually refers to one data object. Its related pages find the same data object through their parent page, while other editorial content can refer to several data objects.
The migrations reflect the same split: in Drupal 7 they created nodes, while in the rebuilt site they create data object entities. Creating a public page is now a separate operation.
The custom entity does not have to contain all external data. In our case, the data object also acts as an anchor for later queries: Drupal stores the source identifier and uses it to retrieve current data when a page element is rendered. The source system remains responsible for the data and its schema; Drupal decides how to present it.
Building pages around data objects
Once importing a data object no longer creates a node directly, the corresponding pages have to be created separately. This provisioning step can run as part of an import, in a follow-up batch or for one data object through an editor-facing form.
In practice, our implementation provides a batch that creates missing parent pages after an import. This preserves a useful part of the Drupal 7 workflow: a set of newly imported data objects can receive their page structures without someone assembling every page individually. Editors can also create a single parent page when needed.
Whichever path is used, Drupal supplies values that can be inferred from the data object, adds the editorial information selected during creation and stores the relationship between the two entities. The new page starts unpublished.
When a parent page is saved, the provisioning service also checks that the expected child pages for that page type exist. Missing ones are created automatically, again as unpublished nodes. Each kind of child page has its own content type and Layout Builder configuration, but gets its data context through the parent page.

Provisioning and page templates solve different parts of the page-creation process. Provisioning decides which pages exist and how they relate to one another, while a page template provides a starting layout for one of those pages. The generated pages can use their content type's default Layout Builder layout, after which editors can customize them or apply a different template.
Keeping the two operations separate means that the site can provide the expected page structure without prescribing the final arrangement of every page element.
Passing page and data context to blocks
Separating the entities also changed what a page element receives as context. The current route still provides a page node, but that node is no longer the data object used to retrieve information.
To bridge that gap, blocks that need external data declare both the page node and the types of data objects they expect in their context definitions. Shared code resolves the objects associated with the page before the block retrieves data or builds its configuration form.
Sometimes a page provides only one matching data object. In other cases, it provides several and the editor can select which one a block should use. That choice is limited to the objects available through the current page. The block cannot independently select an unrelated object from the complete external data source.

The same rule applies when configuration moves between pages, as described in the previous post: editor-managed settings can be copied, but the context must come from the destination page.
Limiting the selector this way is not an access check. Data retrieval still performs its own access checks. The selector serves a narrower purpose: it lets a block choose among the current page's data objects rather than from the complete external source.
The trade-off
The most visible benefit is that an import no longer edits the public page's editorial properties. A changed source name updates the data object, while the page title stays as the editors left it. The import does not decide whether the page is published, and editors can revise its layout or metadata without changing the identity of the imported object.
Page content types no longer have to mirror the types used by the external source. Instead, they can describe the kind of page being built. One data object can support a parent page and several related pages, each with a layout suited to its purpose.
The distinction is also visible in the code: imported information is handled through data objects, while routes, publication and Layout Builder remain the responsibility of nodes. Blocks declare both as contexts when needed.
Using separate entities requires more code because the Drupal 7 version could often assume that the current node was the required data context. The rebuilt site has to resolve an entity reference, and child pages have to find the relevant data objects through their parent. Access and deletion need explicit decisions at both levels as well.
The implementation therefore includes a custom content entity, reference fields, context resolution, provisioning services and administration screens. For a site where every imported object has exactly one page and both always share the same lifecycle, that additional structure may not be worthwhile.
For this project, the additional structure was justified because the exceptions already existed. We had been adding fields, migration conditions and form alterations to let one node behave partly like imported data and partly like an editorial page. The rebuilt version still requires custom code, but its role is clearer: the import updates data objects, while the remaining integration creates pages, maintains their references and supplies the right data objects to their blocks.