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

Add a layout widget

A layout widget renders none of its own content. It declares named slots, the customer binds other widgets into them, and the platform hands your component the already-rendered children as renderedSlots. Assigned to a server-rendered page, your layout replaces the platform’s own page layout for that feed.

This page builds pdp-frame — the layout that ships in the real pdp-suite plugin — file by file, in the order you would write them. Every rule, cap and error string it touches is stated once in Layout widgets; this page is the build order, not the contract.

Two things to settle first. Layouts are suite-only: a v1 single-widget manifest resolves every widget as content, and the shape is frozen at first publish, so scaffold or convert to a suite before you start (Build a suite, The two manifest shapes). And layouts need widget-core 0.11.0+, pages needs 0.12.0+, slots[].prefill needs 0.12.1+ — npm’s latest is older than all three (Install and versions).

If you would rather start from a working layout than from this page, npx create-hr-plugin <slug> --template suite (create-hr-plugin 0.8.0+) scaffolds one: page-frame, a four-slot layout with prefill, pages, a preset and light-DOM-safe CSS. It is the same eight steps below, already written. Read this page either way — the scaffold shows you a layout, not why each part is shaped as it is.

What you are building

pdp-frame reuses the slot vocabulary of the platform’s own property-details layout — hero, main, sidebar, bottom — so system widgets drop straight in; what it remixes is the grid, lifting the sidebar rail beside the hero. Its folder (pdp-suite 1.3.3src/widgets/pdp-frame/):

config.ts       zod config — including the mandatory `slots` object
index.tsx       client entry: registerPluginWidget + mount, both with a category
pdp-frame.css   light-DOM-safe shell CSS
ssr-entry.ts    one line — a layout needs no data hooks
widget.tsx      the component: renders `renderedSlots`

That is the same five files a content widget uses inside a suite — a layout differs in what goes into them.

Step 1 — declare the summary

Add one entry to widgets[] in the root manifest.json:

// pdp-suite 1.3.3 (the reference suite) — manifest.json, the pdp-frame summary
// (trimmed: the summary's own "description" string is elided.)
{
  "slug": "pdp-frame",
  "name": "PDP Frame",
  "category": "layout",
  "slots": [
    { "name": "hero",    "prefill": ["property-title", "gallery"] },
    { "name": "main",    "prefill": ["property-meta", "bednbath", "description", "amenities", "calendar"] },
    { "name": "sidebar", "prefill": ["booking"] },
    { "name": "bottom",  "prefill": ["reviews"] }
  ],
  "ssr": { "url": "dist/pdp-frame/pdp-frame-ssr.umd.js" },
  "assets": {
    "js": "dist/pdp-frame/pdp-frame.iife.js",
    "css": "dist/pdp-frame/pdp-frame.css",
    "fonts": ["https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap"]
  },
  "manifest": "widgets/pdp-frame.manifest.json",
  "pages": ["pdp", "misc"],
  "icon": "media/pdp-frame.svg",
  "readme": "media/widgets/pdp-frame.md"
}

What the layout-specific fields buy you:

  • category: "layout" is what routes the widget through the layout path at all. Anything that is not exactly that string silently becomes a content widget — and a layout keyword rendered as content then fails.
  • slots[] names the regions. The names are yours; mirroring the system PDP vocabulary is a choice. See slots[] — and read the content-slot rule there before you put listings, checkout or confirmation in pages, because those pages ignore your vocabulary.
  • slots[].prefill (0.12.1+) lists system widget keywords the dashboard binds when the customer creates the layout, so it opens populated. Creation-time only, never re-applied, and your own widgets cannot go here — that is what a preset is for (slots[].prefill).
  • ssr: { "url": … } is mandatory: the server composes your children, then renders your shell. "ssr": false is not caught by the publish audit — it fails at render time (SSR).
  • pages (0.12.0+) makes the layout assignable to a server-rendered page. Omit it and the layout still works as an embed and in the picker; it just never reaches the feed’s page-layout picker.
  • assets.fonts (0.12.0+) carries the Playfair Display face the slot labels use; declared fonts load document-level, because @font-face is ignored inside a shadow root (Keywords, assets and URLs). icon / readme (0.12.0+) are media/ files you author and commit.

Note the absence of expectedHeight: it reserves space for a CSR-only host div and never applies to a layout. Every non-layout field belongs to Manifest: widget summary.

Step 2 — declare the mandatory slots config field

This is the step people skip, and its failure is silent. The dashboard does not render a separate slot editor — it injects one into your own config form under a field named slots, so without that field there is nowhere to persist and the customer’s bindings are dropped on save.

// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/config.ts
// (trimmed: the shipped file also exports a `uiSchema`; a layout needs none.)
import { z } from "zod";
import { widgetSchema } from "@homerunner-next/widget-core/schema";
import { zodToManifestSchema } from "@homerunner-next/widget-core/plugin-schema";

export const configZod = widgetSchema.extend({
  showSlotLabels: z.boolean().default(false).describe("Show slot region labels"),
  stickyRail: z.boolean().default(true).describe("Keep the sidebar rail pinned"),
  // One key per manifest slot name, character for character.
  slots: z
    .object({
      hero: z.array(z.string()).default([]),
      main: z.array(z.string()).default([]),
      sidebar: z.array(z.string()).default([]),
      bottom: z.array(z.string()).default([]),
    })
    .default({})
    .describe("Widgets bound into each region"),
});

export type FrameConfig = z.infer<typeof configZod>;
export const configSchema = zodToManifestSchema(configZod);

Three things to get right, all covered in The mandatory slots config field: the keys equal the manifest slot names exactly; every slot is z.array(z.string()).default([]) with .default({}) on the object, so the field survives configZod.parse({}) at creation; and you do not write a uiSchema entry for slots, because the dashboard replaces that key with its own panel (Config schema and UI schema). Your project’s own pre-build script turns this into widgets/pdp-frame.manifest.json — see Build a suite.

Step 3 — write the component

The component reads two props: raw options, and renderedSlots. It renders regions and nothing else.

// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/widget.tsx
// Corrected: the shipped file hand-rolls its props type. Import the SDK type instead.
import React, { type ReactNode } from "react";
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import type { LayoutWidgetProps } from "@homerunner-next/widget-core/contracts";
import { configZod, type FrameConfig } from "./config";
import "./pdp-frame.css";

// `options` arrives RAW on both paths, and SSR supplies neither `widgetType`
// nor `target` — so relax the SDK type at the boundary and parse it yourself.
type FrameProps = Omit<Partial<LayoutWidgetProps<FrameConfig>>, "options"> & {
  options?: Record<string, unknown>;
};

export default function PdpFrame(props: FrameProps) {
  const cfg = parseWidgetConfig(configZod, props.options ?? {});
  const slots = props.renderedSlots ?? {};

  const region = (name: string, node: ReactNode) => (
    <section className={`pdpf-region pdpf-${name}`} data-testid={`pdpf-${name}`}>
      {cfg.showSlotLabels ? <span className="pdpf-label">{name}</span> : null}
      {node ?? (cfg.showSlotLabels ? <div className="pdpf-empty">empty “{name}” slot</div> : null)}
    </section>
  );

  return (
    <div className={`pdpf${cfg.stickyRail ? " pdpf-sticky-rail" : ""}`} data-testid="pdp-frame">
      {region("hero", slots.hero)}
      {region("main", slots.main)}
      {region("sidebar", slots.sidebar)}
      {region("bottom", slots.bottom)}
    </div>
  );
}

Why it is written that way:

  • parseWidgetConfig(configZod, props.options ?? {}) is not optional — settings reach a plugin raw on both server and client (Settings and options).
  • props.renderedSlots ?? {}, then one lookup per region. Never assume a key exists: the two render paths disagree about what an empty slot looks like (The component contract), and the region() helper’s node ?? … fallback is the defensive shape you want.
  • Nothing in here may throw. A content widget that throws costs one widget; a layout that throws during SSR costs the customer’s whole page (Failure semantics).

Step 4 — write the SSR entry

Because the summary declares ssr.url, the build runs an SSR pass for this widget, and that pass needs src/widgets/pdp-frame/ssr-entry.ts. For a layout it is one line:

// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/ssr-entry.ts
// SSR entry — the renderer hands `renderedSlots` to the default export.
export { default } from "./widget";

No getInitialData, no dehydrateState, no getStaticAssets: composition is the job, and the children fetch their own data. Those hooks still work if you need them — they run before the shell renders, inside the same try/catch (Component and SSR module).

Step 5 — register and mount with a category

// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/index.tsx
// (trimmed: the container scan and DOMContentLoaded guard are the standard
//  suite bootstrap — see Build a suite. Only the category is layout-specific.)
import { mount } from "@homerunner-next/widget-core/runtime";
import { registerPluginWidget } from "@homerunner-next/widget-core/globals";
import PdpFrame from "./widget";
import "./pdp-frame.css";

registerPluginWidget("pdp-suite", "pdp-frame", PdpFrame, { category: "layout" });

// …then, for each matching [data-hr-widget-container] on the page:
mount(container, {
  widget: PdpFrame,
  widgetType: "plugin:pdp-suite:pdp-frame",
  category: "layout",
});

This is the standard suite bootstrap plus { category: "layout" } in two places. The registry write and the mount() call sit in the same IIFE, so passing it once is enough to route correctly — pass it twice anyway, and never omit it from both. Omitting it everywhere is the one destructive mistake in this file: the shell is then treated as content, and the client render wipes the server’s composed slot children. Mechanics in Registration and mount and Runtime, mount and the DOM contract.

Step 6 — write light-DOM-safe CSS

Your stylesheet has to survive two homes: document level and unisolated on every server-rendered page (the shell is light DOM there), and inside a shadow root on a CSR embed. So: class-namespace everything, never depend on :host, never paint a background on the shell. The reasoning and the rule table are in Layout CSS.

/* pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/pdp-frame.css (trimmed) */
/* Light-DOM-safe shell: class-based rules only, no :host-only styles, no
   painted background (the same CSS also runs inside a shadow root on CSR
   embeds). */
.pdpf {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 360px;
  grid-template-areas:
    "hero sidebar"
    "main sidebar"
    "bottom bottom";
  gap: 28px 32px;
  align-items: start;
}
.pdpf-hero { grid-area: hero; }
.pdpf-main { grid-area: main; }
.pdpf-sidebar { grid-area: sidebar; }
.pdpf-bottom { grid-area: bottom; }
.pdpf-sticky-rail .pdpf-sidebar { position: sticky; top: 24px; }
.pdpf-region { position: relative; min-width: 0; }
.pdpf-label { font-family: "Playfair Display", serif; /* declared in assets.fonts */ }

/* …plus a max-width: 860px block that collapses the grid to one column and
   reorders the areas to "hero" "sidebar" "main" "bottom". */

Two habits worth copying: every selector is prefixed .pdpf, and the narrow breakpoint reorders sidebar above main so a bound booking rail stays reachable on a phone. Theming — including why a layout inherits none of the platform’s slot spacing — is in Styling and theming.

Step 7 — build

npm run build (your schema-inject pass, then hr-widget-build) loops every widget. Because pdp-frame declares ssr.url it gets two passes, and dist/pdp-frame/ holds pdp-frame.iife.js, pdp-frame.css and pdp-frame-ssr.umd.js, each with a content-hashed twin. If the SSR pass cannot resolve src/widgets/pdp-frame/ssr-entry.ts the build fails there — the fastest signal that you declared ssr.url without writing the entry. The pass loop and the shared build manifest belong to Build a suite and Keywords, assets and URLs.

Then preview the server render (widget-core 0.12.2+) — a suite has no default widget, so name this one:

npm run build
npm run dev:ssr -- --widget pdp-frame     # http://localhost:3003

That runs the layout’s built SSR bundle through the same vm contract the renderer uses, which is enough to catch an SSR-only crash, a disallowed import or an empty render in the shell itself. --widget=pdp-frame and HR_SSR_WIDGET=pdp-frame do the same thing.

Not supported yet. The preview renders the named widget and nothing else, so your layout renders with empty slots: on a server-rendered page the platform renders each bound child and hands the results to your component as renderedSlots, and no local harness does that. npm run dev composes the layout the client way instead, which exercises the other half of the contract. Install the plugin on a real feed for the server half — see Previewing SSR locally and Dev sandbox and mocking.

Step 8 — offer a one-click preset

A preset is a root-level entry that creates the layout, its children and every binding in one click. Its power is that a single slot array may mix your own widget slugs with system widget keywords:

// pdp-suite 1.3.3 (the reference suite) — manifest.json, root level
"presets": [
  {
    "name": "PDP Starter",
    "layout": "pdp-frame",
    "slots": {
      "hero":    ["stay-hero", "property-title", "gallery"],
      "main":    ["stay-facts", "description", "amenities", "calendar"],
      "sidebar": ["booking", "booking-cta"],
      "bottom":  ["reviews"]
    }
  }
]

stay-hero, stay-facts and booking-cta are this plugin’s own content widgets; the rest are system keywords the platform ensures or creates on the feed. That is the difference from prefill, which only takes system keywords — a preset is how you compose your suite into a finished page. What one click creates, in what order, and the referential rules that make it publishable are in Presets.

Not supported yet. Preset slot keys are never checked against your layout’s declared slot names. Write sidbar and the plugin publishes, the widget is created and bound, and it renders nowhere. Diff your preset keys against slots[].name by hand.

What the customer ends up seeing

  • The widget picker lists your layout beside your content widgets, marked as a layout and carrying a Layers glyph instead of the content one — plus a separate entry per preset (The customer-facing surface).
  • The layout’s playground shows the injected Slot Configuration panel at the top of your config form, one row per manifest slot.
  • Feed → Page URLs → Page layouts offers the layout for every page its summary declares, next to Automatic; an assignment the renderer cannot honour degrades to Automatic rather than breaking the page (page assignment).
  • Composing a page end to end, including the property a layout pushes down to its children, is in Composing a page.

Three things that look like bugs and are not

  1. On a server-rendered page the shell is plain light DOM — no shadow root, no Declarative Shadow DOM template. Deliberate: each slot child is its own island, which is what keeps third-party payment fields working.
  2. The client mount on that page is a no-op. Running the CSR path would discard the server’s composed children and re-fetch everything.
  3. Layouts cannot nest — refused on both render paths, and the slot picker never offers one (Layouts cannot nest).

Not supported yet. Because of (1) and (2), a plugin layout shell can never be interactive on a server-rendered page — no React tree is ever attached to it. Its slot children hydrate themselves normally, so put interactivity in a content widget and bind it into a slot.

Before you submit

Walk the full checklist, then the packaging rules in Packaging and publishing rules — your media/ icon and readme must be committed, because the reviewer only runs your build. The submission flow itself is Preflight and submit.