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:

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 -> translations

The 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:

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:

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:

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:

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

Takeaways

← All posts