Moving Layout Builder Sites to Drupal Canvas
Drupal Canvas, which started life as Experience Builder, is Drupal's new page builder. Editors drag components onto a page, fill in their props and nest them in slots, and see the result as they go. Under the hood it's a different model from Layout Builder: a page is a tree of components, and the components come straight from your theme.
This year I built a new shared Drupal 11 base on Canvas for a fleet of consumer brand sites, and a migration that moves existing Layout Builder sites into it. This post covers both: how the base is structured so Canvas works well with it, and how the importer turns old pages into Canvas component trees.
The starting point
The legacy sites run on Acquia Site Factory, on two separate codebases ("stacks") that grew over several years. Pages are built with Layout Builder, from a long list of custom block plugins: a hero block, a rich text block, a product carousel, an FAQ block and so on. Between the two stacks there are well over a hundred of them.
The new base is one codebase for all brands. Each site picks its brand when it's provisioned, and the theme switches colours and fonts with data attributes. It runs Drupal 11.4 and Canvas, and it moved through six Canvas minor versions, from 1.7 to 1.12, while it was being built.
Components first: an SDC theme for Canvas
Canvas can use blocks and JavaScript "code components", but its natural source is single-directory components (SDC) in the theme. Every component is a YAML file with a JSON schema for its props, plus a Twig template. Canvas reads the schema to build the editing form, so the schema is the editor's user interface.
The theme has about a hundred components, organized as atoms, molecules, organisms, layout and integrations, with Tailwind CSS v4 and Vite for the build. Shared enums (colour schemes, widths, spacing) live in one schema file and are referenced with $ref, and the theme enforces prop schemas so a component with an invalid schema fails loudly instead of quietly.
# hero.component.yml (simplified)
name: Hero
group: Brand/Organisms
props:
type: object
properties:
scheme: { $ref: json-schema-definitions://brand.theme/scheme }
width: { $ref: json-schema-definitions://brand.theme/width }
variant: { type: string, enum: [split, overlay, stacked] }
slots:
header: { title: Header }
media: { title: Media }
content: { title: Content }
actions: { title: Actions }The rule that shaped everything else: only atoms carry content props. Anything bigger takes its content through slots. A hero doesn't have a "title" prop; it has a header slot, and an editor drops a heading atom into it.
The reason is a Canvas constraint: a prop can't be an array of objects. So a list of cards isn't a "cards" prop with an array in it. It's a molecule with a slot, holding card molecules. Once you accept that, the component library becomes very regular, and every piece of content on a page is an atom that an editor can select and edit in place.
The editing side was deliberately limited to those components. Canvas also has AI agents that can build pages, templates and even new code components. We kept the page and template builders, connected to Gemini, and took the code component agent out of the assistant's tools, so the assistant can only compose pages from the existing library.
Templates for structured content
Not every page is free-form. Articles, recipes and similar content types have fields, and they should look the same on every node. Canvas handles those with content templates: a component tree per bundle and view mode (full, card, search card), where props are bound to the node's fields instead of fixed values.
Some templates also needed a free area where each article can add its own components below the fixed part. Canvas supports that "exposed slot" on its own page entity, but not yet on nodes. A small patch removed that restriction for us. It's one of a handful of patches the project carries against Canvas, each written down with the reason and the upstream issue, so every upgrade can check whether it's still needed.
The migration: pull, don't push
Drupal's Migrate API is the usual tool for moving content between sites. Here the legacy content is moved by a custom importer instead. Most of its code isn't about moving rows at all: it converts Layout Builder sections into component trees.
Instead it's two small modules:
- A source module, installed temporarily on the legacy site. It exposes read-only JSON endpoints, protected by a generated token, and does nothing else.
- An importer on the new site, which pulls from those endpoints, page by page, and builds the content locally.
GET /api/migrate/nodes?limit=500&offset=0
GET /api/migrate/files
GET /api/migrate/medias
GET /api/migrate/taxonomies
GET /api/migrate/menus
GET /api/migrate/redirects
GET /api/migrate/blocks
GET /api/migrate/layouts
...The source module flattens everything into plain JSON. Files and media are embedded in the item that references them, other references are exported by UUID, and paths are always the URL aliases visitors know. Each node comes with its Layout Builder sections, the inline blocks placed in them, and its translations.
On the new site the import runs as a sequence of steps in the Batch API, fifty items per batch request, so it survives large sites without hitting PHP time limits:
languages -> files -> medias -> static files -> taxonomies -> menus
-> redirects -> polls -> blocks -> layouts -> webforms -> nodes
-> site -> modules -> translationsThe order matters: media before the nodes that use it, menus and redirects before the site settings that point at pages. Files are downloaded ten at a time, and a file that's already there with the same size is skipped, so a re-run doesn't download gigabytes again.
From Layout Builder blocks to component trees
The heart of the importer is a converter that walks a node's sections, sorts the blocks by weight, and hands each one to a mapper. There's one mapper class per legacy block plugin, discovered automatically and arranged in folders that mirror the legacy modules, so finding the mapper for a block is a matter of following its path. A mapper reads the block's configuration and returns a nested tree of components:
// One mapper per legacy block plugin. It returns a nested tree.
return [
'component' => 'section',
'inputs' => ['spacing' => 'sm'],
'slots' => ['content' => [
['component' => 'container', 'inputs' => ['width' => 'lg'], 'slots' => ['content' => [
['component' => 'text', 'inputs' => ['content' => $converter->richText($configuration['html'] ?? '')]],
]]],
]],
];Canvas doesn't store nested arrays. It stores a flat list of items, each with its parent and the slot it sits in. So the converter flattens the tree in one place, and mappers never have to think about UUIDs or parents:
$items[] = [
'uuid' => $uuid,
'component_id' => $component, // 'text' becomes 'sdc.<theme>.text'
'parent_uuid' => $parent,
'slot' => $slot,
'inputs' => $inputs,
'label' => $label,
];A few details there each came from a real failure:
- Enum props get their default value filled in. Otherwise Canvas stores an empty string, and the page fails at render time.
- Media and links are stored in the full shape Canvas expects, not as a bare ID or URL. The short forms rendered fine, then crashed the translation screen.
- Rich text is cleaned on the way in. Inline style blocks are removed, a stray
h1inside body text becomes anh2, and old helper classes are mapped to the new theme's.
Content templates use the same mappers. The difference is that a node field arrives as a placeholder, and the converter turns it into a Canvas expression that binds the prop to the field:
// In a content template, node fields aren't values but expressions:
['sourceType' => 'entity-field', 'expression' => $expression]That reuse paid off. One mapper per block serves both free-form pages and templates, so a block only has to be understood once.
Never drop anything silently
A migration like this will meet blocks it can't convert. What matters is that someone finds out. Every block ends in exactly one of three states, and the import report shows all of them:
- Converted by its mapper.
- Skipped on purpose, logged as info with the reason. Some legacy blocks have no place in the new design.
- Not converted, logged as a warning. A raw HTML embed block, for example, is never converted automatically; the report shows a snippet of its markup and a person decides.
The same goes for per-node changes on templated content. If a legacy article added its own blocks to its layout, those that have a home (FAQ blocks become FAQ content) are moved, and the rest are listed as "rebuild by hand in Canvas" instead of disappearing.
Safe to run again
The importer will run many times against the same site: during development, after fixing a mapper, before the final switch. So every step is idempotent:
- Every imported entity keeps its legacy UUID, so a second run updates instead of duplicating.
- Entities the importer creates itself, like product variants or wrapper media, get deterministic UUIDs derived from their source, so they're stable across runs too.
- Nodes that reference something not imported yet are deferred and imported again at the end, instead of failing.
- URLs keep their legacy alias, menu links point at those aliases, and redirects are matched by their source, so the site's URLs survive the move.
One full test import of a single site brought in about 4,700 files, 2,400 media items, 700 nodes, 124 menus and 356 redirects, and a sweep afterwards checked every alias.
The result: three minutes per site
Migrating a site now takes about three minutes from start to finish, on both stacks. How much of the site comes across automatically depends on the stack:
- The stack we manage: about 95%.
- The second stack: about 80%.
Whatever isn't migrated automatically is what the report lists: blocks skipped on purpose, embeds a person has to look at, and per-node additions that need rebuilding by hand in Canvas.
That changes how the work is planned. A migration that takes three minutes and can run again safely isn't a big-bang event. You import, read the report, fix a mapper, and import again, as many times as you need. The manual work is a short, known list instead of a whole site.
What I learned about Canvas along the way
- Component config is generated, and it's versioned. Canvas creates a config entity per component, with a hash of its schema. Change a prop and the version changes, and stored trees and templates need to follow. We stopped exporting that config and let it be generated per site.
- Turning a prop into a slot is a migration. Every page that uses the component has to be rebuilt. Imported pages can simply be re-imported. Pages editors built by hand can't, so get the prop-or-slot decision right early.
- Canvas saves differently from the node form. Modules that hook into the node edit form's submit button, like sitemap settings, never run when an editor saves in Canvas. Hook into the entity instead.
- Don't keep layout config only in the database. Templates and regions built in the UI exist nowhere else. We lost work once before we treated them as something the migration builds.
- Translation is still maturing. At the time of writing, a page has one component tree for all its languages, and media can't differ per language. Check the open issues against your multilingual requirements before you promise anything.
- Track Canvas closely. It moves fast. Six minor versions in four months, each bringing fixes we wanted, and occasionally a patch we had to re-apply.
Takeaways
- Design the component library for Canvas from the start: small content atoms, everything else built from slots.
- For a migration from Layout Builder, the converter is the real work. Keep the transport boring: a read-only JSON source and a batched importer.
- One mapper per legacy block, returning a nested tree, with flattening done in one place.
- Make every run idempotent, and make every unconverted block visible in a report.