Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Composing a page

Your suite becomes a page when a customer binds widgets into your layout’s slots and points a page at it. Nothing in your manifest does that on its own — a person does it in the dashboard, in four clicks, and every one of those clicks is shaped by decisions you made in manifest.json.

This page follows that person: the widget picker, one click on a preset, a layout that opens populated, the slot editor, and finally page assignment. Then it turns around and asks what you have to design for so all of it holds up.

Every rule, cap and error string mentioned here has its normative home in Layout widgets. This page is the story and the design consequences, not the contract.

Layouts and presets exist only in the suite manifest shape and need widget-core 0.11.0+; pages needs 0.12.0+ and slots[].prefill needs 0.12.1+ (The two manifest shapes, Install and versions).

Everything below assumes the customer has already installed your plugin from the Marketplace and enabled it on the feed (Install, customers and kill switches).

1. The widget picker

Feed → Widgets → the Create New Widget dialog: a searchable grid of cards, one card per creatable thing, then a display name, then Create. An enabled plugin contributes two kinds of card:

  • One card per widget in widgets[] that is not deprecated (a v1 single-widget plugin contributes exactly one card, for plugin:{slug}).
  • One card per entry in presets[], listed ahead of that plugin’s widget cards and marked with a small preset badge.

A layout’s card differs from a content widget’s in two ways the customer can see: the glyph is a Layers icon instead of the plugin puzzle piece, and the platform appends (layout — hosts other widgets in its slots) to whatever description you wrote in the summary. Write a description that still reads well with that clause bolted on the end.

The icon on a card resolves widgets[].icon → the plugin’s root icon → the glyph, and an icon URL that fails to load falls back to the glyph as well (Keywords, assets and URLs). Card descriptions are clamped to two lines with a More link that reveals the full text, a Plugin details link to your plugin page, and — for a preset — the list of widgets it will create. Two lines is the real budget for a summary description; everything after that is one click away.

A preset card is labelled {plugin name}: {preset name}PDP Suite: PDP Starter for the reference suite — over a description the platform writes for you (The customer-facing surface). You choose the second half of that label and nothing else, so make presets[].name say what the customer gets (“PDP Starter”), not what it is built from.

Cards vanish when a widget is deprecated, when it is switched off platform-wide, or when the feed has switched it off — and a preset vanishes with any widget it references, because central strips presets whose layout or children are withheld from the manifest it serves. A preset is only ever offered when everything it needs is live (Install, customers and kill switches).

2. What one preset click creates

The customer types one name — say Stay page — picks the preset card, and clicks Create. Four things then happen, in this order.

  1. System-keyword children are ensured. Every child in your slots that is a bare system keyword rather than one of your own slugs is matched to the feed’s oldest active widget with that keyword. Missing ones are created first, with the platform’s own defaults. A keyword this platform cannot create is reported in a toast and skipped, not fatal (prefill describes the same machinery). These are shared, never duplicated per preset — if the feed already has a gallery, your preset binds that gallery.
  2. One instance of each of your own content widgets referenced by the preset, created with settings = your schema defaults + the feed’s branding + filter.feed_id, and display-named {typed name} — {widget name}.
  3. The layout, created with settings = its schema defaults plus a slots object mapping each preset slot name to the ids just created or ensured.
  4. A redirect to the new layout’s playground, so the customer lands on the slot editor with everything already bound.

A name a customer types once therefore spreads across the whole composition:

// Illustrative: the feed after one click on "PDP Starter", named "Stay page".
// Naming rule from src/components/widget/CreateWidgetDialog.tsx →
// createPluginPresetWidgets; widget names from pdp-suite 1.3.3 manifest.json.
Stay page                    plugin:pdp-suite:pdp-frame   ← the layout, slots bound
Stay page — Stay Hero        plugin:pdp-suite:stay-hero
Stay page — Stay Facts       plugin:pdp-suite:stay-facts
Stay page — Booking CTA      plugin:pdp-suite:booking-cta
<the feed's own widgets>     property-title, gallery, description, amenities,
                             calendar, booking, reviews   ← reused, or created for them

Three consequences for how you build:

  • Your widgets are created unconfigured. Every own-widget child arrives with schema defaults only. If a widget renders nothing, or renders an error, until someone fills in a field, the preset produces a broken page on click one. Give every field a sensible .default() (Config schema and UI schema).
  • Name your widgets so the {typed name} — {widget name} form reads well. Stay page — Booking CTA is a good row in a widget table; Stay page — Widget 2 is not.
  • A child slug that matches one of your own widget slugs is always your widget. The platform only treats a preset child as a system keyword when it is not one of your declared slugs, so naming a widget gallery shadows the system gallery inside your own presets.

If something fails part-way through, the dashboard best-effort deletes the own-widget children it created in step 2 and surfaces the original error. Note what it does not roll back: system widgets ensured or created in step 1 stay on the feed, because they are shared with everything else there. If a referenced widget stopped being available between the page load and the click, the customer sees Preset widget "{slug}" is not available right now. and nothing is created.

Not supported yet. Preset slot keys are never checked against your layout’s declared slot names. A typo creates real widgets, binds them, and renders nothing — there is no error anywhere. Diff your preset keys against slots[].name by hand before you submit (Presets).

3. prefill: a layout that opens populated

A customer who picks your layout card directly gets a different courtesy. If any slot declares prefill (0.12.1+), the dashboard collects the union of those keywords in declared order, creates the ones the feed has no active widget for, and binds the feed’s oldest active widget per keyword into the slots that asked for it — all before the layout row is written.

So pdp-frame opens with the customer’s existing gallery in hero, their existing booking widget in sidebar, and so on, instead of four empty regions. A slot that resolves to nothing is simply left out of the stored bindings.

What that means for you:

  • prefill is a first-run convenience, not a contract. It runs once, at creation, is never re-applied, and the renderer never reads it. A customer who clears a slot has cleared it.
  • It only takes system keywords. Your own widgets cannot be prefilled — that is exactly what a preset is for. The rule and its publish errors are in slots[].prefill.
  • Prefer real keywords over aspirational ones. Nothing validates them against the platform’s actual widget list, so a typo silently prefills nothing.

4. The slot editor

The playground for a layout is the normal config form with one extra panel at the top, Slot Configuration, injected by the dashboard from your manifest’s slots[] — one labelled row group per slot, each a list of widget pickers with Add Widget and remove buttons. Edits autosave like any other setting.

Two behaviours worth knowing before you design your slots:

  • accepts filters the candidate list, and only that. A widget is offered when accepts names its category or its exact keyword; the layout’s own keyword is always excluded, and so is every other layout. It is a hint to the customer, checked nowhere else (slots[].accepts).
  • An empty row is not a binding. Nothing resolves it, and both render paths drop ids that do not match a live widget on the feed (settings.slots).

The customer can bind anything they like, in any order, including nothing at all. Your component sees the result as renderedSlots and must survive all of it.

5. Page assignment

A layout that only ever renders as an embed is a component. A layout that takes over a server-rendered page is what most authors are actually after, and that is a separate, explicit step the customer takes at Feed → Page URLsPage layouts.

Four rows — Property page, Listings page, Checkout page, Confirmation page — each a picker of Automatic plus every layout on the feed that may serve that page. Your layout appears in a row when its plugin is approved and enabled on the feed, the widget is not kill-switched or deprecated, and the widget summary’s pages names that page. A plugin option is labelled with the customer’s own name for that layout instance followed by your plugin’s name, and carries a puzzle-piece marker once selected — so it is always visible that a third party renders the page. Leaving a row on Automatic means the feed’s own system layout for that page.

The picker also warns when Automatic is ambiguous — more than one system layout instance of that kind on the feed, with the oldest silently winning — which is a good reason to give a customer an explicit choice. Switching a layout purges the page cache, so the change is live in seconds rather than at the page TTL (Publishing, versions and rollback).

Three things about an assigned plugin layout that change how you design it:

  • Stored bindings only. Unlike a system layout, nothing is backfilled from the platform’s default slot map. A slot the customer never bound does not appear at all.
  • The property page renders your slot vocabulary; the other three do not. Listings, checkout and confirmation are fixed to a single content slot, and they 404 rather than degrade when it is empty. Read What the renderer does with an assignment before you put anything but pdp in pages.
  • Anything the renderer cannot honour degrades to Automatic, once, with a logged reason — a deleted or paused layout, a plugin no longer live on the feed, a widget whose summary does not declare the page. The customer’s page keeps working and your layout quietly stops being used, so “my layout vanished” is nearly always one of those reasons (Degrading to Automatic).

6. How the property reaches your widgets

One PDP layout instance serves every property page on the feed, so the property cannot come from the layout’s stored settings. It is injected, and every widget in every slot receives it the same way: as options.filter.property.

  • On a server-rendered property page, the page itself supplies the filter — feed id, property slug, and platform: null — and it is merged over every widget’s resolved settings, the layout’s included, after the customer’s own values. A stored filter.property on a child never wins on a PDP.
  • On a CSR embed, the layout resolves a property (its own Property setting, or a per-embed data-hr-options override on the layout’s host element) and pushes it onto every slot child as it mounts them. The layout verifies the slug once, so a bad property fails in one place instead of 404ing in every slot at the same time. The exact payload and the console lines it can produce belong to Runtime, mount and the DOM contract.

Both paths land in the same place, so a slot child reads one thing:

// pdp-suite 1.3.3 (the reference suite) — src/widgets/stay-hero/widget.tsx
// The same three-line helper appears verbatim in stay-facts and booking-cta.
/** The property slug the platform injects when a parent layout resolves one. */
function injectedProperty(options?: Record<string, unknown>): string | null {
  const filter = (options as { filter?: { property?: unknown } } | undefined)?.filter;
  return typeof filter?.property === "string" && filter.property ? filter.property : null;
}

Because your config extends widgetSchema, the base filter object is part of your schema, so parseWidgetConfig(configZod, props.options) keeps the injected value too and cfg.filter?.property reads the same slug (Settings and options). The raw helper above is what the reference suite ships: it is deliberately independent of the parse, so a widget still knows which property it is on even while its own config is being edited.

Design for injection, not configuration: treat filter.property as the identity of the page and fall back to your own setting only when nothing was injected. A widget that demands the customer pick a property in its own config cannot be dropped into a PDP layout at all.

7. Design for composition

Everything above adds up to five habits.

Fail alone. Each slot child is its own island — its own render, its own data, its own error boundary. A content widget that throws costs one region of the page. Your layout component throwing during a server render costs the whole page, so the shell must be the most defensive code in your suite: parse, default every slot, and never assume a key exists (Failure semantics).

Read well when empty. The customer decides what goes into each slot, and “nothing” is a legitimate answer — especially on an assigned page, where unbound slots are never backfilled. A grid that collapses gracefully with two of four regions filled is worth more than one that only looks right in your screenshot. Do not paint a background on the shell and do not reserve space for children that may not exist (Layout CSS, Styling and theming).

Mirror the system slot vocabulary. Naming your property-page slots hero, main, sidebar and bottom is what lets the platform’s own widgets — and the customer’s existing ones — drop straight in, and it is what makes prefill and page assignment feel native rather than parallel.

Put interactivity in the children. On a server-rendered page the layout shell is static light DOM forever: no hydration, no React tree, no event handlers. A tab strip or a sticky booking bar belongs in a content widget bound into a slot, not in the shell (SSR).

Compose one level. Layouts cannot nest, on either render path, and there is no manifest field that opts in. Everything your layout arranges is content (Layouts cannot nest).

Known gaps on this path

Not supported yet.

  • Preset slot keys are never validated against your layout’s declared slot names.
  • prefill keywords are shape-validated only, never checked against the real system-widget keyword list.
  • accepts: ["layout"] can never match — the slot picker excludes layouts before your filter runs.
  • A plugin layout on a server-rendered page can never be interactive.
  • The renderer keeps server-rendering an assigned layout you later mark deprecated, while the dashboard flags that same assignment as an error. Deprecating a layout does not take it off a page.
  • A layout previews on its own — npm run dev:ssr -- --widget {layout} (widget-core 0.12.2+) — but its composed server render does not. The harness passes no renderedSlots, so the shell renders with every slot empty; only the platform composes the bound children (Previewing SSR locally).

Where to go next