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

HomeRunner plugin development

A HomeRunner plugin is a React component, an optional server-render module, and a manifest.json that tells the platform what you ship. You submit it as a source zip: you host nothing, and you never build the artifact a customer loads. A HomeRunner reviewer reads your source, runs your build, and publishes the result to the plugin CDN under an immutable version prefix. Customers then install it per feed and configure it in a dashboard form generated from your zod schema. From there your widget renders alongside first-party ones — server-rendered into Declarative Shadow DOM where the request supports it, hydrated by the platform’s shared runtime, or client-mounted on a CSR embed.

A plugin can carry a single widget or a whole suite of them under one version, one review and one publish — including layout widgets that host other widgets in named slots.

Start here

Building one widget? Read Install and versions, then Your first widget. npx create-hr-plugin my-plugin produces exactly this shape, and its dev sandbox and SSR preview both work out of the box.

Building a suite — several widgets, or a layout, or a one-click preset? Scaffold it directly with npx create-hr-plugin my-suite --template suite (create-hr-plugin 0.8.0+), which writes a working three-widget suite including a layout. Do the first-widget page anyway (the component, schema and build steps are identical), then Build a suite and Add a layout widget.

Choose deliberately: the manifest shape is frozen at your first publish, and changing it afterwards needs a brand-new plugin slug. Converting is free until then — but only until then. The two manifest shapes explains the decision and its cost.

How this book is organised

At a glance

# derived from packages/create-hr-plugin/bin/create-hr-plugin.js and both templates' package.json
# non-interactive, argv only: [--template <single|suite>] [--author <name>] [--no-install]
npx create-hr-plugin my-plugin                   # one widget (the default)
npx create-hr-plugin my-suite --template suite   # several widgets + a layout
cd my-plugin

npm run dev        # Vite dev sandbox on http://localhost:3001, MSW-mocked by default
npm run build      # = npm run build:manifest && hr-widget-build
npm run dev:ssr    # SSR preview on http://localhost:3003 — run a build first.
                   #   A suite has no default widget, so name one:
                   #   npm run dev:ssr -- --widget intro-card
npm run preview    # = npm run build && node serve.js  →  http://localhost:3002

npm run build is two steps, not the three older versions of this guide described: your project’s scripts/inject-schema.mjs writes the config and UI schema into the manifest (or into per-widget sub-manifests), then the SDK’s hr-widget-build bin drives every Vite pass, hashes dist/, writes dist/manifest.json and stamps runtime.widgetCore. It rewrites tracked files, so expect manifest.json to be dirty in git afterwards — commit it.

npm run preview (create-hr-plugin 0.8.0+) serves dist/, /manifest.json, the /w/ hashed redirect and the /p/ asset-proxy path, and the page boots the built bundles against the real runtime IIFE. It runs with no mocks, so point its host ids at a real widget and feed first — Your first widget has the details.

SDK versions

You import from one package, @homerunner-next/widget-core. The build stamps the version it used into runtime.widgetCore, and that stamp is what the publish gate reads — never write it by hand.

widget-coreWhat it added
0.10.0Evergreen runtime (/runtime externalized from your bundle). The publish floor.
0.11.0Suites, registerPluginWidget, the hr-widget-build bin, layout widgets, presets.
0.12.0assets.fonts, bundled assets, layout pages, marketplace icon and cover.
0.12.1Slot prefill.
0.12.2createSSRDevServer previews one named widget of a suite (--widget {slug}).
0.12.3The SSR preview links a stylesheet for a widget that exports no getStaticAssets.

This book documents widget-core 0.12.3 and create-hr-plugin 0.8.1, whose templates pin ^0.12.1 (--template single) and ^0.12.3 (--template suite).

Both are on npm as of 2026-09-08 — npx create-hr-plugin <slug> --template suite works straight from the registry. Install and versions owns the version matrix and what each release added.

Install and versions

Get a toolchain that works, then find out exactly which SDK you ended up with and what it can do. This page is the book’s normative home for two things: the runtime version pins your package.json must carry, and which SDK version each feature needs — see What each SDK version gives you before you decide whether to build a single widget or a suite, because that choice is frozen at your first publish.

Node

VersionWhy
Hard floor20.19.0 (or 22.12.0+)Vite 7 declares engines: ^20.19.0 || >=22.12.0, and the scaffold pins vite: ^7.3.1.
Feature floor20.12.0process.loadEnvFile landed in 20.12 (and 21.7). npm run dev:ssr uses it.
Recommended22.12+ LTSSatisfies both, and matches the Node this SDK is developed on.

The 20.12 floor is the one that bites, because it fails silently. The SSR preview harness loads .env.local and .env itself — Vite is not involved — and swallows any failure:

// packages/create-hr-plugin/template/scripts/dev-ssr.mts:26-32 (verbatim)
// — byte-identical at template-suite/scripts/dev-ssr.mts:41-47
for (const file of [".env.local", ".env"]) {
  try {
    process.loadEnvFile(file);
  } catch {
    // file absent — fine
  }
}

On Node 20.0–20.11 process.loadEnvFile is not a function, the TypeError lands in that catch, and the script carries on with no env loaded at allHR_SSR_HYDRATE, HR_RUNTIME_URL, PORT and your NEXT_PUBLIC_* keys are simply absent and every default silently applies. You get a working-looking server pointed at the wrong places (../local-dev/ssr-preview.md covers those keys). Nothing warns you either: the template declares no engines field, so npm install prints no EBADENGINE for the 20.12 requirement. Enforcement level: advisory (unvalidated) — see the legend in ../contracts/limits-and-errors.md.

Scaffold a project

# packages/create-hr-plugin/bin/create-hr-plugin.js:26-82 — the whole argv surface
npx create-hr-plugin acme-weather --author "Acme Corp"   # one widget (the default)
npx create-hr-plugin acme-suite --template suite         # several widgets + a layout
cd acme-weather

The CLI is entirely non-interactive. It asks nothing; everything comes from argv. --help, -h, or no arguments at all prints this and exits 0:

# packages/create-hr-plugin/bin/create-hr-plugin.js:29-47 — the help text, verbatim
  Usage: create-hr-plugin <plugin-name> [options]

  Creates a HomeRunner widget plugin project (vm.Script SSR + IIFE client).

  Arguments:
    plugin-name     Plugin slug — lowercase letters, digits and hyphens
                    (e.g. "acme-weather"). Becomes the plugin's manifest id.

  Options:
    --template <t>   Project shape: "single" (one widget, default) or
                     "suite" (several widgets + a layout, one publish).
                     --template=<t> works too.
    --author <name>  Author name (default: from git config)
    --no-install     Skip npm install
    --help, -h       Show this help

  Examples:
    npx create-hr-plugin acme-weather --author "Acme Corp"
    npx create-hr-plugin acme-suite --template suite
BehaviourDetail
Slugargv[0], always. Put flags after the slug — a first argument starting with - is refused rather than turned into a directory name.
Slug validation (0.8.1+)Checked before any file is written, against the publish audit’s own rule /^[a-z0-9][a-z0-9-]*$/ plus the reserved names dist, widgets, media. Earlier versions accepted anything and you found out at publish, with the project already built and committed. The regex belongs to manifest.id../contracts/manifest-root.md.
--template <single|suite> (0.8.0+)Project shape; default single. --template=suite works too (0.8.1+ — 0.8.0 parsed only the space-separated form and silently scaffolded single, which matters because the shape is frozen at first publish). Anything else, including --template with nothing usable after it, exits 1.
--author <name>Falls back to git config user.name. If that command fails (git absent, or user.name unset) the author becomes the literal string Plugin Author. A dangling --author with no value falls back the same way.
--no-installSkips npm install. Without it, a failed install is not fatal: the scaffold prints a warning and still exits 0.
Target existsHard error, exit 1.
Copy mechanismEvery template file is read as UTF-8 text, token-replaced and rewritten. Nothing in either template directory may be binary.
_gitignoreRenamed to .gitignore after the copy (npm strips .gitignore from published packages).

Every failure message the scaffolder can print is quoted verbatim in ../contracts/limits-and-errors.md.

--template single writes the single-widget project; --template suite writes a three-widget suite with a layout, a preset and a media/ folder. Both shapes run npm run dev, npm run dev:ssr, npm run build and npm run preview. See ../concepts/manifest-shapes.md for the choice you are making — it is frozen at your first publish — then 02-your-first-widget.md or 03-build-a-suite.md to run it.

The React-family pins

Pin these exactly, with no caret, in your plugin’s package.json:

PackagePin
react19.2.5
react-dom19.2.5
@tanstack/react-query5.95.2

Shape tag: Both. Pin only the ones you actually import — the real suite pdp-suite declares no React Query dependency at all. @types/react and @types/react-dom are caret-ranged (^19.1.3); only the runtime packages are exact.

Your client bundle does not ship React. viteHomerunnerWidget externalises react, react/jsx-runtime, react-dom and react-dom/client against the host page’s runtime IIFE, and the SSR sandbox hands your server bundle the renderer’s own copies. So the React you compile against and the React you execute against are different installs, and they must agree to the patch.

# pnpm-workspace.yaml:13-16 — the platform's own rationale, verbatim
# Why exact pins for react/react-dom: plugin client umds externalize react/react-dom/* against
# `window.HRWidgetRuntime` (= the runtime IIFE). Even patch drift between the renderer's
# server-side React and a plugin's bundled react-dom internals will trip the
# `Incompatible React versions` runtime check.

A caret is enough to break this. "react": "^19.2.5" resolves to whatever 19.x is newest the day you install, and react-dom asserts the two match exactly:

# node_modules/react-dom/cjs/react-dom-client.development.js:27934-27938
Incompatible React versions: The "react" and "react-dom" packages must have the exact same
version.

Two traps in that message. It is a development-build assertion, so a mismatch can pass a production build on your machine and misbehave on a customer page instead of throwing. And enforcement is advisory (unvalidated) — nothing at submit, review or publish reads your package.json, so a drifted pin ships.

What each SDK version gives you

runtime.widgetCore in your built manifest.json records the version you actually built with. The build writes it; never hand-write it — see ../contracts/manifest-root.md for the field and ../contracts/packaging-and-publishing.md for the gate it feeds.

widget-coreWhat it addedOn npm today?
0.8.0The /mock dev-mocking subpath and the /testing SSR harness (renderPluginSSR, createNodeMockServer, createSSRDevServer)yes (superseded)
0.9.0spacing replaces width on the base schema; parseWidgetConfigno (never published)
0.10.0Evergreen runtime — /runtime externalised, runtime.widgetCore stamped. The publish floor.yes
0.10.1The shadowDOM manifest flag; the raw-options contractyes
0.11.0Suites (widgets[]), registerPluginWidget, the hr-widget-build bin, layout widgets, presets, mediaSafeAuto, enforced externalFetchyes
0.12.0assets.fonts, bundled assets, layout pages, marketplace icon / coveryes
0.12.1Slot prefillyes
0.12.2createSSRDevServer takes a widget option (--widget {slug} / HR_SSR_WIDGET), so npm run dev:ssr previews one named widget of a suite; resolvePluginSSRTarget and widgetFromInvocation exported; the harness’s sandbox moved closer to the renderer’syes
0.12.3The SSR preview falls back to the widget’s manifest assets.css when the bundle exports no getStaticAssets — the shape --template suite scaffolds, which previewed unstyled before thisyes — latest

create-hr-plugin tracks it:

create-hr-pluginWhat it addedOn npm today?
0.6.0The template that pins widget-core ^0.10.0 and builds in three stepsyes — latest
0.7.0npm run build delegates to hr-widget-build; template pins ^0.11.0no
0.8.0--template suite; npm run preview renders the built widget; public/mockServiceWorker.js no longer ships in dist/; both templates pin ^0.12.1no
0.8.1--template=suite (equals form); slug validation before any file is written; npm run dev:ssr in the suite template (which moves its pin to ^0.12.3)no

Version-gate notes appear inline throughout the book as (0.11.0+). The full export inventory for 0.12.2 is ../contracts/sdk-exports.md.

Versions on npm

Both packages are published and current (checked 2026-09-08):

npm latest
@homerunner-next/widget-core0.12.3
create-hr-plugin0.8.1

So npx create-hr-plugin <slug> and npx create-hr-plugin <slug> --template suite both work straight from the registry, and every feature in the table above is installable. Nothing in this book needs a tarball or a private build.

Two things are still worth checking rather than assuming:

  • Which version you actually ran. npx caches. If a scaffold comes out single-widget after you asked for a suite, you ran a pre-0.8.0 CLI, which had no --template flag and silently ignored the argument — and the manifest shape is frozen at your first publish. Confirm with npx create-hr-plugin --help (it lists --template from 0.8.0) or npx create-hr-plugin@latest. Older CLIs also had no slug validation, so an invalid manifest.id went unreported until the publish audit.
  • What an existing project pins. A project scaffolded before 0.8.0 pins an older widget-core and still uses the three-step build:manifest && build:iife && build:ssr build. Suites need 0.11.0+, and a suite’s npm run dev:ssr needs 0.12.3 — on 0.12.2 it previews unstyled, because the scaffolded widgets export no getStaticAssets. Bump the pin and switch build to build:manifest && hr-widget-build before following 03-build-a-suite.md.

Do not start a plugin as single-widget “for now” if you intend to ship a suite. The manifest shape is frozen at your first publish and changing it needs a brand-new plugin slug (../concepts/manifest-shapes.md). Converting is free before you publish and impossible after.

Check what you actually have

# run these in your plugin directory before you trust any version-gated instruction
node -v                                          # want 20.19+ or 22.12+
npm ls @homerunner-next/widget-core react react-dom
ls node_modules/.bin/hr-widget-build             # absent => widget-core < 0.11.0
npm view @homerunner-next/widget-core dist-tags  # what the registry serves right now

After a build, the authoritative answer is in your own manifest: runtime.widgetCore is the exact version the build ran with. Symptoms that trace back to a version mismatch are indexed in ../troubleshooting.md.

Your first widget

Twenty minutes, end to end: scaffold a project, run it against mocked data, change its settings schema and its component, build it, and read what the build produced.

This tutorial produces a single-widget plugin — --template single, the CLI’s default. The other shape, --template suite, is scaffolded the same way and covered in 03-build-a-suite.md; the component, schema and build steps below are identical in both. The choice is frozen at your first publish — the last section says what to do about that. This page also assumes a working toolchain; get one from 01-install-and-versions.md, which owns the Node floor, the React pins and the dated note on what npm serves versus what the template pins.

1. Scaffold

# packages/create-hr-plugin/bin/create-hr-plugin.js — the real flags
# --template defaults to "single"; pass it explicitly if you like being explicit
npx create-hr-plugin acme-weather --author "Acme Corp"
cd acme-weather

The CLI never prompts; the full argv surface and every failure mode are in 01-install-and-versions.md. What matters here is what it derives from the slug: the display name (Acme Weather), the component name (AcmeWeatherWidget), and the keyword your widget is addressed by everywhere — plugin:acme-weather. The manifest’s id is the slug; nothing re-slugifies it later.

The tree

acme-weather/
├── manifest.json           # plugin identity + the v1 widget fields; the build rewrites it
├── package.json            # scripts, and exact react / react-dom / react-query pins
├── vite.config.ts          # viteHomerunnerWidget({ slug, command }) + your overrides
├── tsconfig.json           # include is ["src/**/*", "vite.config.ts"] only
├── index.html              # DEV sandbox page — not a production artifact
├── serve.js                # zero-dep node:http static server (npm run serve / preview)
├── vercel.json + api/      # optional self-hosted demo shim; never needed to publish
├── mocks/fixtures.ts       # mock data for npm run dev AND npm run dev:ssr
├── public/                 # holds the MSW service worker
├── scripts/                # inject-schema.mjs (build:manifest), dev-ssr.mts (dev:ssr)
└── src/
    ├── index.tsx           # client IIFE entry — registerPlugin() + mount()
    ├── widget.tsx          # your React component (and getInitialData)
    ├── ssr.ts              # dehydrateState + getStaticAssets
    ├── ssr-entry.ts        # SSR bundle entry — re-exports widget.tsx + ssr.ts
    ├── config.ts           # zod schema → manifest configSchema + uiSchema
    ├── widget.css          # Tailwind entry + the .hr-widget-section styles
    ├── data.ts             # sample public-api fetcher + a query-key factory
    ├── components/ui/      # shadcn-style: you own these, edit them freely
    └── dev/                # dev sandbox entry + control bar — never published

Which of those are yours, which the build generates, and which never leave your laptop is laid out in ../concepts/anatomy-of-a-plugin.md.

2. Run the dev sandbox

npm run dev     # http://localhost:3001

npm run dev is mocked by default. src/dev/main.tsx starts an MSW worker bound to plugin:acme-weather, seeds the widget’s settings with configZod.parse({}) so every options.<field> read is defined, turns React Query retries off so the error scenario shows its error state instead of a long spinner, mounts every matching container, and renders a control bar along the bottom for scenario, theme, latency and a live settings JSON editor.

?scenario=empty|single|large|loading|error picks the fixture shape at boot and ?mock=off disables the worker. .env.local is optional — it is consulted only on the ?mock=off path (and by npm run dev:ssr), so you never need a live HomeRunner feed to build the widget. The full sandbox contract is in ../local-dev/dev-sandbox-and-mocking.md.

3. Add a setting

src/config.ts is the whole settings surface. You extend the shared base schema, and you export a JSON Schema built from it:

// packages/create-hr-plugin/template/src/config.ts — the scaffold, with one field added
import { z } from "zod";
import { widgetSchema } from "@homerunner-next/widget-core/schema";
import {
  zodToManifestSchema,
  type PluginUiSchema,
} from "@homerunner-next/widget-core/plugin-schema";

export const configZod = widgetSchema.extend({
  title: z.string().default("Acme Weather").describe("Widget title"),
  units: z.enum(["metric", "imperial"]).default("metric").describe("Units"),
});

/** Symbolic UI for plugin-specific fields only. */
export const uiSchema: PluginUiSchema = {
  title: { component: "TextInput" },
  units: {
    component: "Select",
    optionLabels: { metric: "Celsius", imperial: "Fahrenheit" },
  },
};

/** JSON Schema shipped in `manifest.configSchema`. */
export const configSchema = zodToManifestSchema(configZod);

export type PluginConfig = z.infer<typeof configZod>;

Three things are doing real work. widgetSchema brings in the base fields every widget shares — spacing, colour scheme, fonts, section header, custom CSS and the rest — so the dashboard renders those controls without you declaring anything. .default(...) and .describe(...) on your own fields are what make the customer’s form open clean and self-explaining. And uiSchema names only your fields: a plugin entry replaces a top-level key of the base UI schema wholesale, so re-declaring a base key destroys that panel.

The base field list, the component vocabulary, and which zod constructs survive the round trip to JSON Schema and back (versus which ones take the whole config panel down) are in ../contracts/config-and-ui-schema.md. Read the round-trip tables before you reach for a union, a tuple or z.any().

4. Edit the component

src/widget.tsx is the component the browser and the server both render. The scaffold already shows the two calls that are not optional:

// packages/create-hr-plugin/template/src/widget.tsx — the scaffold's real component,
// with <WidgetSection>, the new `units` setting, and the imported prop type
import React from "react";
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { resolveWidgetSpacingStyle } from "@homerunner-next/widget-core/spacing";
import type { WidgetProps } from "@homerunner-next/widget-core/contracts";
import { configZod, type PluginConfig } from "./config";
import { cn, WidgetSection } from "./components/ui";
import "./widget.css";

type WeatherData = { message?: string };

export default function AcmeWeatherWidget(
  props: WidgetProps<PluginConfig, WeatherData>,
) {
  // `props.options` is the RAW stored settings blob on BOTH server and client:
  // parseWidgetConfig strips the dashboard's null/'' save artifacts, coerces
  // stringified scalars and applies your defaults. Never read it directly.
  const options = React.useMemo(
    () => parseWidgetConfig(configZod, props.options) as PluginConfig,
    [props.options],
  );

  const isDark = props.resolvedTheme === "dark";

  return (
    // The base schema's `spacing` renders on the outer element; the helper does
    // its own defaulting and repair, so raw or parsed options both work.
    <div style={resolveWidgetSpacingStyle(options)}>
      <WidgetSection show={options.section.show} title={options.section.title} />
      <div
        className={cn(
          "rounded-lg border p-4 font-sans",
          isDark ? "border-zinc-700 bg-zinc-900" : "border-zinc-200 bg-white",
        )}
      >
        <h3 className="mb-2 text-lg font-semibold">{options.title}</h3>
        <p className="text-sm text-zinc-500">
          {props.data?.message ?? "Loading…"} ({options.units})
        </p>
      </div>
    </div>
  );
}

WidgetSection comes from src/components/ui/ — shadcn-style components the scaffold copies into your project, not a package import. Its real props are {show, title, className}; it renders the heading only, is not a wrapper, takes no options, and returns null when show is false or title is empty.

props.options arriving raw is the rule that catches most first-time authors, and it applies to getInitialData and dehydrateState in src/ssr.ts too — parse there as well, or your server-side query keys will not match the ones your hydrated component builds.

Two corrections to the scaffold, both worth making now. The template declares its own WidgetProps interface inline; import the real one from @homerunner-next/widget-core/contracts instead, because the inline copy omits fields. And props.resolvedTheme is fine for a class swap after mount but is not something to key first paint on — dark at first paint has to come from CSS. See ../contracts/component-and-ssr-module.md for the full prop contract and the SSR module, and ../recipes/styling-and-theming.md for theming.

Save with npm run dev running: Vite hot-reloads the component, and the sandbox’s settings editor pushes new options at the mounted widget without a rebuild.

5. Build

npm run build

build is npm run build:manifest && hr-widget-build — two steps, not the three-step build:manifest → build:iife → build:ssr chain older documentation describes:

StepScriptWhat it does
1build:manifest (tsx scripts/inject-schema.mjs)Imports src/config.ts, writes configSchema and uiSchema into the tracked manifest.json, and logs manifest.json updated — configSchema: N properties, uiSchema: M field specs.
2hr-widget-buildwidget-core’s bin. For a single-widget project it runs two Vite passes, labelled iife build and ssr build.

build:iife and build:ssr still exist in package.json, but build no longer calls them — they are single-widget escape hatches. On a suite, a bare vite build refuses to run at all. (hr-widget-build needs widget-core 0.11.0+.)

Expect manifest.json to be dirty in git after every build, and commit it. The build rewrites that tracked file twice: build:manifest injects the schemas, and the final Vite pass stamps runtime.widgetCore with the SDK version it built against. Never hand-write that stamp — but do commit it, because the publish audit gates on it. See ../contracts/packaging-and-publishing.md.

Read dist/

# a real built single-widget plugin (hr-plugins/featured-stays); .map files omitted here
dist/
├── featured-stays.iife.js            # client bundle
├── featured-stays.iife-cb11ab13.js   # its content-hashed twin
├── featured-stays.css
├── featured-stays-d4188b32.css
├── featured-stays-ssr.umd.js         # SSR bundle — CommonJS despite the name
├── featured-stays-ssr.umd-122b8a33.js
├── manifest.json                     # the BUILD manifest: {buildHash, generatedAt, files}
├── mockServiceWorker.js              # built by an OLD scaffold — see the note below
└── mockServiceWorker-7b2c8364.js

Three files are the plugin: the client IIFE named in assets.js, the stylesheet in assets.css, and the SSR bundle in ssr.url. The hashed twin beside each is what a customer page actually loads, and dist/manifest.json is the plain-name → hashed-name map the renderer and the asset proxy read (source maps are excluded from it but still ship). Filenames are fixed by the SDK; renaming an output is a publish error. All normative in ../contracts/assets-and-urls.md.

Your own dist/ will not have those last two files. public/mockServiceWorker.js — the dev-only MSW worker — used to be copied into dist/ by Vite on every production build, hashed and recorded in the build manifest. (create-hr-plugin 0.8.0+) both templates’ vite.config.ts pass publicDir: command === "build" ? false : "public", so the worker stays out of the build while npm run dev is untouched. featured-stays was built before that fix; if your scaffold predates 0.8.0, make the same one-line change.

6. Preview the server render

npm run build       # dev:ssr reads dist/, so build first
npm run dev:ssr     # http://localhost:3003 → redirects to /preview?scenario=default

scripts/dev-ssr.mts loads .env.local then .env, then runs your built SSR bundle through the same node:vm contract the production renderer uses, against the same mocks, with a scenario tab strip and an inspector showing the server HTML, the props and the dehydrated query cache. HR_SSR_HYDRATE=1 additionally runs real client hydration and surfaces mismatch warnings. Without a prior build it tells you the SSR bundle is missing.

It is close to production, not identical. What still differs is the environment around the sandbox — the envelope, the asset base, the pre-parsed settings — and each difference is enumerated in Where the preview differs from production.

npm run preview — the built bundles, no mocks

npm run preview is npm run build && node serve.js. serve.js is a zero-dependency node:http server on port 3002 (not Express, and not port 3000) that serves /manifest.json and /dist/* with Access-Control-Allow-Origin: *, answers /w/{file} with a 302 to the hashed twin, and answers /p/{slug}/{file} from dist/ — the plugin-asset proxy path your built IIFE resolves its stylesheet through.

(create-hr-plugin 0.8.0+) the page renders. index.html still loads the Vite dev entry /src/dev/main.tsx, which does not exist under serve.js; its onerror boots the preview path instead — the runtime IIFE first (your built bundle externalises React, React Query and widget-core against window.HRWidgetRuntime), then proxyBaseUrl pointed at the local server, then /dist/acme-weather.iife.js. Earlier scaffolds had no such fallback and rendered an empty mount div.

There are no mocks on this path — it talks to whichever Central the runtime IIFE points at. Before you use it, change the host div’s id="{widgetId}:{feedId}" in index.html to a real pair, and point HR_RUNTIME_URL at the environment you are testing against. For mocked visual work stay in npm run dev; for the server path use npm run dev:ssr.

curl -s localhost:3002/manifest.json | head -20
curl -sI localhost:3002/w/acme-weather.iife.js    # expect 302 → /dist/<hashed>

Next: choose your shape, while it is still free

You now have a widget that renders, a settings form the dashboard can draw, an SSR bundle and a built dist/. You have not created media/ (icons, cover, README — authored content nothing generates) and you have not submitted anything; both belong to ../ship/preflight-and-submit.md.

One decision is still open, and only until your first publish. A plugin is either the single-widget shape you just built or the modern suite shape — one plugin, many widgets, one version, one review, one publish. A suite may hold a single widget, so “suite” is not the same as “big”. That shape is frozen at first publish: flipping it later passes submit and even passes review, then dies at publish with a shape-change rejection, and the only remedy is a brand-new slug — losing every stored keyword and every embed URL a customer has copied. Today it costs you a re-scaffold, or a file move.

  • Ever going to ship a second widget, a layout or a preset? Start over with npx create-hr-plugin <slug> --template suite, or convert the project you just built — both routes are in 03-build-a-suite.md.
  • Want the decision spelled out with its costs on both sides: ../concepts/manifest-shapes.md.

Build a suite

A suite is one plugin that ships several widgets under one slug, one version, one review and one publish. The root manifest carries a widgets[] array instead of a single widget’s fields, each widget lives in its own folder, and each one gets a namespaced keyword plugin:{plugin}:{widget} instead of the bare plugin:{plugin}. Requires @homerunner-next/widget-core 0.11.0+ — see Install and versions.

There are two ways in, and the first is the one you want for a new plugin:

  1. Scaffold onenpx create-hr-plugin <slug> --template suite (create-hr-plugin 0.8.0+). The next section.
  2. Convert the single-widget project from Your first widget. The rest of the page — and also the field-by-field tour of what the scaffold wrote, if you took route 1.

The shape is frozen at your first publish — converting afterwards needs a new plugin slug — so do this before you submit, not after. See The two manifest shapes.

Scaffold one

# packages/create-hr-plugin/bin/create-hr-plugin.js — --template, 0.8.0+
npx create-hr-plugin acme-suite --template suite     # --template=suite works too (0.8.1+)
cd acme-suite

You get a suite that builds, publishes and passes both audits with zero errors and zero warnings as generated. It ships three widgets, one per surface you have to know about:

WidgetSlugCategorySSRWhy it is there
Page Framepage-framelayoutrequiredDeclares slots with prefill, renders renderedSlots, declares pages
Intro Cardintro-cardcontentyesThe ordinary server-rendered content widget
Quick Factsquick-factscontentnoCSR-only: "ssr": false, an expectedHeight, and deliberately no ssr-entry.ts
# packages/create-hr-plugin/template-suite/ — what `--template suite` writes
acme-suite/
├── manifest.json                  # widgets[] summaries + presets[] — the suite root
├── package.json · tsconfig.json · vite.config.ts · .env.example · .gitignore
├── scripts/
│   ├── inject-schema.mjs          # build:manifest — config.ts → widgets/{slug}.manifest.json
│   └── dev-ssr.mts                # npm run dev:ssr -- --widget {slug}
├── media/                         # AUTHORED: icon.svg, {slug}.svg, README.md, widgets/{slug}.md
├── index.html · src/dev/ · mocks/fixtures.ts · public/   # dev sandbox — never published
├── serve.js · vercel.json         # static hosting for the built output
└── src/
    ├── shared/property.ts         # code >1 widget uses, imported relatively
    └── widgets/
        ├── page-frame/            # config.ts · widget.tsx · index.tsx · page-frame.css
        │   └── ssr-entry.ts       #   present because the summary declares ssr.url
        ├── intro-card/            # …the same five files
        │   └── ssr-entry.ts
        └── quick-facts/           # …NO ssr-entry.ts — the summary says "ssr": false

widgets/{slug}.manifest.json is missing from that list because nothing has generated it yet; your first npm run build writes it, and you commit the result — step 4.

Two things this template deliberately does not ship, both of which the single-widget template does: a src/components/ui/ kit, and Tailwind. A layout’s stylesheet is loaded document-level and unisolated on a server-rendered page, so a CSS framework’s global reset would land on the customer’s whole document — see Styling and theming.

npm run dev                              # :3001 — all three widgets, MSW-mocked
npm run build                            # build:manifest && hr-widget-build
npm run dev:ssr -- --widget intro-card   # :3003 — ONE widget's built SSR bundle
npm run preview                          # build + serve.js on :3002, no mocks

npm run dev mounts all three: each widget’s own src/widgets/{slug}/index.tsx registers and auto-mounts it exactly as its built IIFE does, and the layout’s mocked settings carry slots, so page-frame resolves intro-card and quick-facts as real slot children through the client composition path. That works only because mocks/fixtures.ts overrides /widget/{id} with a factory answering each dev id with its own keyword — the mock worker’s built-in route answers every id with one keyword, and a layout would then refuse to render its own children. Full sandbox contract: Dev sandbox and mocking.

npm run dev:ssr (widget-core 0.12.2+, and create-hr-plugin 0.8.1+ for the script that drives it) has no default widget in a suite, so name one: --widget intro-card, --widget=intro-card, or HR_SSR_WIDGET=intro-card in .env.local. It renders dist/{widget}/{widget}-ssr.umd.js under plugin:{slug}:{widget} and seeds the mocked settings from that widget’s own configZod.parse({}). Naming nothing, naming a widget the manifest does not declare, or naming the CSR-only quick-facts exits 1 with a message listing the widgets that do work — the harness, its scenarios and its divergences from the renderer are in Previewing SSR locally.

The scaffold’s page-frame is a complete, working layout; Add a layout widget walks the same five files against the published reference suite so you can see which parts are layout-specific.

Or convert a single-widget project

The rest of this page converts the project from Your first widget into a suite. If you scaffolded one instead, read it as a tour of what --template suite already wrote — and as the procedure for adding a fourth widget.

What already understands a suite

Three parts of the scaffold branch on widgets[] by themselves and need no edits at all:

FileWhy it needs no change
vite.config.tsIt never names an entry. viteHomerunnerWidget({ slug, command }) reads the entry from the environment.
scripts/inject-schema.mjsIt already has a multi-widget branch — step 4 below.
tsconfig.jsoninclude is ["src/**/*", "vite.config.ts"], which covers src/widgets/**.

There is no rollup input map anywhere in a suite build. Multi-entry is driven entirely by two environment variables that the hr-widget-build CLI sets per pass:

// packages/homerunner-widget-core/src/vite/index.ts — build.lib.entry, abridged
entry: buildWidget                 // buildWidget = process.env.BUILD_WIDGET
  ? isSSR                          // isSSR      = process.env.BUILD_SSR === "1"
    ? `./src/widgets/${buildWidget}/ssr-entry.ts`
    : `./src/widgets/${buildWidget}/index.tsx`
  : isSSR
    ? "./src/ssr-entry.ts"
    : "./src/index.tsx",

fileName and the CSS asset name branch the same way, which is what nests the output under dist/{widget}/. With BUILD_WIDGET unset the config still resolves the flat entries, so a bare vite build on a suite would emit a plausible-looking, entirely broken dist/viteHomerunnerWidget reads your root manifest at config time and throws instead. The exact message is in Limits and error index. Always build with npm run build.

The conversion at a glance

PathFate
src/index.tsx, src/widget.tsx, src/config.ts, src/widget.cssmove into src/widgets/{slug}/
src/ssr-entry.tsreplace with a per-widget one, and only for widgets that server-render
src/ssr.tsmove into the widget folder, or delete — suite widgets rarely need it
src/data.ts, src/components/ui/keep where they are; import them relatively
manifest.jsonrewrite (step 1)
index.html, src/dev/, mocks/fixtures.tsreplace with the suite-shaped versions from --template suite, or delete — the single-widget harness statically imports the flat ../widget and ../config and hardcodes one keyword
public/keep only if you keep the sandbox — it holds the MSW worker
vercel.jsonreplace with the suite template’s — the single-widget one rewrites /w/:path* to /api/widget-asset, and you are about to delete that function. The suite’s simply drops the rewrite
serve.jskeep — the two templates’ routing is byte-identical, and it already handles nested paths: /p/{slug}/{file} matches greedily, so /p/acme/hero/hero.css resolves to dist/hero/hero.css, and /w/ keys straight off dist/manifest.json, whose entries are already nested. The suite copy only rewords the comments and the startup log
api/widget-asset.tsdelete — a flat /w/ shim, and a demo-only one
package.json scripts build:iife, build:ssrdelete — they are the bare vite build a suite refuses
package.json scripts dev:ssr, serve, previewkeep, if you took the harness with them
vite.config.ts, scripts/inject-schema.mjs, tsconfig.jsonno change
media/create it by hand if you want icons, covers or READMEs

A scaffolded suite’s package.json keeps six scripts: dev (vite), dev:ssr (tsx scripts/dev-ssr.mts), build (npm run build:manifest && hr-widget-build), build:manifest (tsx scripts/inject-schema.mjs), serve and preview. The published reference suite keeps only the first, third and fourth — a suite needs no sandbox to be publishable. --template suite pins @homerunner-next/widget-core ^0.12.2 (its npm run dev:ssr needs the 0.12.2 harness); --template single pins ^0.12.1. A converted project on ^0.11.0 builds and publishes, but has no assets.fonts, bundled assets, marketplace icons, layout pages or slot prefill. What the registry actually serves is in Install and versions.

1. Rewrite the manifest

Delete the v1 root fields — widgetType, ssr, assets, and the configSchema / uiSchema pair your last build injected — and add widgets[]. Nothing else at the root changes.

// The scaffolded plugin rewritten as a two-widget suite. Identity fields (id, version,
// name, description, author) carry over unchanged from the template's manifest.json;
// summary shapes follow the published reference suite's manifest.
{
  "id": "acme-weather",
  "version": "1.0.0",
  "name": "Acme Weather",
  "author": { "name": "Plugin Author" },
  "runtime": { "react": "^19.0.0" },  // build stamps "widgetCore" — never hand-write it
  "widgets": [
    {
      "slug": "forecast",
      "name": "Forecast",
      "ssr": { "url": "dist/forecast/forecast-ssr.umd.js" },
      "assets": { "js": "dist/forecast/forecast.iife.js",
                  "css": "dist/forecast/forecast.css" },
      "manifest": "widgets/forecast.manifest.json"   // written by build:manifest
    },
    {
      "slug": "snapshot",
      "name": "Snapshot",
      "ssr": false,                                   // CSR-only — see step 3
      "assets": { "js": "dist/snapshot/snapshot.iife.js",
                  "css": "dist/snapshot/snapshot.css" },
      "expectedHeight": 140,
      "manifest": "widgets/snapshot.manifest.json"
    }
  ]
}

Point assets and ssr.url at the logical filenames the build emits — never at a hashed twin. The manifest references are filled in for you by build:manifest.

Every field, its rule and its exact publish error live in Manifest: widget summary; the root fields are in Manifest: root fields.

2. Move the sources into src/widgets/{slug}/

acme-weather/
├── manifest.json · package.json · package-lock.json · tsconfig.json · vite.config.ts
├── scripts/inject-schema.mjs        # unchanged
├── widgets/                         # GENERATED by build:manifest — commit it
│   ├── forecast.manifest.json
│   └── snapshot.manifest.json
├── media/                           # authored by you; nothing generates it
└── src/
    ├── components/ui/               # optional shared code, imported relatively
    └── widgets/
        ├── forecast/                # index.tsx · widget.tsx · config.ts · forecast.css
        │   └── ssr-entry.ts         #   present ONLY because the summary declares ssr.url
        └── snapshot/                # index.tsx · widget.tsx · config.ts · snapshot.css
                                     #   NO ssr-entry.ts — the summary says "ssr": false
FileRequired?Notes
index.tsxyeshr-widget-build refuses to start if any declared slug has no src/widgets/{slug}/index.tsx.
config.tsin practice yesbuild:manifest imports it by exact path and exits 1 if it is missing.
widget.tsxconventionAny filename works; index.tsx and ssr-entry.ts import it.
{slug}.cssoptionalThe reference suite imports it from both index.tsx and widget.tsx.
ssr-entry.tsiff ssr.urlOne re-export line. A CSR-only widget has no such file at all.

Two things break silently when you move files:

  • Relative imports. The scaffold’s src/widget.tsx imports ./components/ui; from src/widgets/forecast/ that becomes ../../components/ui.
  • The @/* alias is TypeScript-only. tsconfig.json maps @/*./src/*, but nothing adds the matching Vite resolve.alias, so @/components/ui type-checks and then fails to build. Keep relative paths.

A server-rendered widget’s ssr-entry.ts is one line — that is the entire file in the published reference suite (pdp-suite, src/widgets/stay-hero/ssr-entry.ts):

export { default } from "./widget";

Suite widgets typically export only default — no getInitialData, no dehydrateState, no getStaticAssets. What each optional export does, and the sandbox it runs in, is in Component and SSR module.

3. Add the second widget, CSR-only

The clearest way to internalise the SSR-entry rule is to make your second widget CSR-only: "ssr": false plus an expectedHeight in the summary, and no ssr-entry.ts in the folder.

  • "ssr": false is a deliberate, explicit value. Omitting ssr entirely is a publish error — it is not the same as false.
  • hr-widget-build skips the SSR pass for that widget, so dist/snapshot/ never gets a -ssr.umd.js, and an ssr-entry.ts left in the folder is simply never built. The reverse also fails: declare ssr.url with no ssr-entry.ts and the vite pass dies on a missing entry.
  • On a server-rendered page the renderer emits an empty host div carrying data-hr-min-height from expectedHeight. Set it, or the page reserves nothing and shifts when your bundle paints (Manifest: widget summary).
  • A category: "layout" widget must declare ssr.url. The publish audit accepts ssr: false on a layout, but the render then fails — Layout widgets and Add a layout widget.

Each widget registers and mounts itself. This is the one file that genuinely differs from the single-widget scaffold — registerPluginWidget(plugin, widget, Component) instead of registerPlugin(slug, Component), and a namespaced widgetType:

// pdp-suite (the published reference suite) — src/widgets/stay-facts/index.tsx
import { mount } from "@homerunner-next/widget-core/runtime";
import { registerPluginWidget } from "@homerunner-next/widget-core/globals";
import StayFacts from "./widget";
import "./stay-facts.css";

registerPluginWidget("pdp-suite", "stay-facts", StayFacts);

function doMount() {
  document
    .querySelectorAll<HTMLElement>("[data-hr-widget-container]")
    .forEach((container) => {
      if (container.querySelector('[data-hr-widget="plugin:pdp-suite:stay-facts"]')) {
        mount(container, { widget: StayFacts, widgetType: "plugin:pdp-suite:stay-facts" });
      }
    });
}

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", doMount);
} else {
  doMount();
}

Copy it per widget. The only edits are the two imports, the two keyword strings — for the example above, registerPluginWidget("acme-weather", "snapshot", …) and plugin:acme-weather:snapshot — and, for a layout, a category on both calls. The registry keys, the [data-hr-widget-container] scan and the full MountConfig are specified in Runtime, mount and the DOM contract.

config.ts and widget.tsx keep the shape the scaffold gave you: extend widgetSchema, export configSchema from zodToManifestSchema, and parse props.options with parseWidgetConfig before reading a single field — options arrive RAW on both server and client. See Settings and options and Config schema and UI schema.

4. Regenerate the sub-manifests

A suite root carries no schemas. build:manifest writes one sub-manifest per widget and stamps the reference back onto the summary. This script lives in your project, not in the SDK — both templates ship the identical script, so you only need to know what it does:

// packages/create-hr-plugin/template/scripts/inject-schema.mjs — the multi-widget branch
// (byte-identical in template-suite/scripts/inject-schema.mjs)
if (Array.isArray(manifest.widgets) && manifest.widgets.length > 0) {
  const outDir = path.join(root, "widgets");
  fs.mkdirSync(outDir, { recursive: true });
  for (const widget of manifest.widgets) {
    const slug = widget.slug;
    const configPath = path.join(root, "src", "widgets", slug, "config.ts");
    if (!fs.existsSync(configPath)) {
      console.error(`  missing ${configPath} for widget "${slug}"`);
      process.exit(1);
    }
    const { configSchema, uiSchema } = await import(pathToFileURL(configPath).href);
    const sub = { slug, configSchema };
    if (uiSchema) sub.uiSchema = uiSchema;
    fs.writeFileSync(path.join(outDir, `${slug}.manifest.json`), JSON.stringify(sub, null, 2) + "\n");
    widget.manifest = `widgets/${slug}.manifest.json`;
  }
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
}

widgets/*.manifest.json is generated but committed and shipped in both zips — the reviewer only runs your build, and a referenced sub-manifest missing from the zip is a publish error. Only the dashboard ever fetches these files, and only to draw a config form. Details in Config schema and UI schema.

5. Build

npm run build is still npm run build:manifest && hr-widget-build. The CLI is what turns one vite config into many bundles:

  1. It validates every summary before any pass runs — slug shape, the reserved names dist / widgets / media, duplicate slugs, and the presence of src/widgets/{slug}/index.tsx. Each failure exits non-zero with its own message (Limits and error index).
  2. It deletes dist/ once. Per-widget passes then run with emptyOutDir: false, so running a single pass by hand leaves stale output from a previous build.
  3. It flattens the widgets into a (widget × pass) list — an iife pass for every widget, plus an SSR pass for every widget that does not declare ssr: false — and runs each with BUILD_WIDGET={slug} (and BUILD_SSR=1 on SSR passes).
  4. It marks the last pass HR_BUILD_FINALIZE=1. Only that pass hashes dist/, writes the shared dist/manifest.json, and stamps runtime.widgetCore into your tracked root manifest. Expect manifest.json dirty in git after every build; commit it.

build:iife and build:ssr survive in the single-widget scaffold’s package.json as escape hatches. On a suite they are the bare vite build that fails by design — delete them if you converted. --template suite never ships them.

The dist/ a suite produces

One directory per widget, one shared build manifest, and a content-hashed twin beside every non-.map file:

# pdp-suite (the published reference suite) — dist/ after one `npm run build`
dist/
├── manifest.json
├── pdp-frame/     pdp-frame.iife.js  pdp-frame.iife-be6fddd4.js  pdp-frame.iife.js.map
│                  pdp-frame.css      pdp-frame-9992937d.css
│                  pdp-frame-ssr.umd.js  pdp-frame-ssr.umd-5872d9b8.js  …js.map
├── stay-hero/     … + hero-bg-Cr1v4Zys.png  hero-bg-Cr1v4Zys-1cc265f5.png
├── stay-facts/    iife + css only          ← "ssr": false
└── booking-cta/   iife + css only          ← "ssr": false
// pdp-suite — dist/manifest.json, abridged. Keys AND values are dist-relative.
{
  "buildHash": "77328e0a",
  "generatedAt": "2026-09-07T06:25:25.567Z",
  "files": {
    "pdp-frame/pdp-frame.iife.js":    "pdp-frame/pdp-frame.iife-be6fddd4.js",
    "stay-facts/stay-facts.css":      "stay-facts/stay-facts-945e0d55.css",
    "stay-hero/hero-bg-Cr1v4Zys.png": "stay-hero/hero-bg-Cr1v4Zys-1cc265f5.png"
  }
}

Bundled images emitted from a widget folder are keyed there too; sourcemaps are published but deliberately absent from the map. How these names become URLs is in Keywords, assets and URLs.

What a suite costs you today

Both of the tooling gaps this section used to list are closed: --template suite (create-hr-plugin 0.8.0+) generates a suite-aware dev sandbox, and npm run dev:ssr (widget-core 0.12.2+, and create-hr-plugin 0.8.1+ for the script that drives it) previews a named suite widget. Two real limits are left, and neither affects publishing, installing, rendering or SSR in production.

Not supported yet. The dev sandbox has one mock state for the whole page. Scenario, latency, feed branding and the widget settings object are a page singleton in npm run dev: src/dev/main.tsx seeds settings with the union of every widget’s schema defaults and each widget’s zod strips the keys that are not its own, which works only while no two widgets share a field name. Give two widgets a title and they share its value in the sandbox — and the control bar’s settings editor edits that one object and pushes it to every keyword. npm run dev:ssr renders one widget, so it seeds only that widget’s own defaults and has no such collision. Details in Dev sandbox and mocking.

Not supported yet. There is no server-side slot composition locally. npm run dev:ssr renders the one widget you named and nothing else, so a layout renders its shell with empty slots: on a server-rendered page the platform renders each bound child and hands the results to your layout as renderedSlots, and no local harness does that. npm run dev composes the layout the client way, which is the other half of the contract. Install the plugin on a real feed to exercise the server half — Previewing SSR locally and Layout widgets.

If you converted a project by hand and kept the single-widget harness, you have a third problem that is yours rather than the platform’s: src/dev/main.tsx statically imports the flat ../widget and ../config, index.html hardcodes one plugin:{slug} keyword, and the mock worker’s built-in /widget/{id} route answers every id with that one keyword — so a layout resolves its own keyword for each slot child and refuses to render it. Take the suite template’s index.html, src/dev/ and mocks/fixtures.ts instead; the fixture’s widget factory is the part that makes three widgets coexist. The dev CSS route also becomes /dist/{plugin}/{widget}/{widget}.css.

Before you zip

  • npm run build is clean, dist/manifest.json exists, and manifest.json now carries a runtime.widgetCore stamp.
  • Every declared assets.js / assets.css / ssr.url path matches what dist/ actually contains, including the {widget}/ directory.
  • widgets/*.manifest.json is committed, and so is every media/ file you reference.
  • No dist/, node_modules/ or .git/ in the zip; package-lock.json is present.
  • Every widget declaring ssr.url has an ssr-entry.ts; every "ssr": false widget has none and has an expectedHeight.

The full preflight, the audits and what happens after upload are in Preflight and submit and Packaging and publishing rules.

Where to go next

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.

Anatomy of a plugin

A plugin is three things wearing one directory: source you author, artifacts your build produces, and content nothing produces but the platform still expects. Every consumer downstream — the reviewer, central, the renderer, the dashboard, the browser — reads a different subset of those files. Once you know which file is which, the rest of this book is detail.

This page is a map, not a rule set. Field rules live on the contracts pages.

Two trees

The scaffold’s project and a real published plugin look surprisingly different. Roughly half of what create-hr-plugin gives you is a local sandbox that never reaches a customer, and the one real suite in the fleet ships none of it.

(a) The single-widget scaffold

# what `npx create-hr-plugin my-widget` writes — `--template single`, the
# default (create-hr-plugin 0.8.1)
my-widget/
├── manifest.json           # SHIPPED. Identity + the v1 single-widget fields. Rewritten by the build.
├── package.json            # SHIPPED (source zip). Pins react/react-dom/react-query exactly.
├── package-lock.json       # appears after `npm install`. Ship it — absent is a publish warning.
├── tsconfig.json           # SHIPPED (source zip), never published
├── vite.config.ts          # SHIPPED (source zip). Delegates to viteHomerunnerWidget().
├── README.md               # yours, developer-facing. NOT the marketplace readme — that is media/.
├── .env.example            # dev only. Copy to .env.local; consulted by `?mock=off` and dev:ssr.
├── .gitignore              # written from the template's `_gitignore` (npm strips real dotfiles)
├── index.html              # DEV ONLY — deletable. Loads /src/dev/main.tsx, falling back to
│                           #   the BUILT bundles when that 404s under `npm run preview`.
├── serve.js                # DEV/DEMO ONLY — deletable. Zero-dep node:http static server.
├── vercel.json             # DEMO ONLY — deletable. Self-hosted preview deploy config.
├── api/
│   └── widget-asset.ts     # DEMO ONLY — deletable. Vercel edge /w/ → hashed-dist redirect.
├── public/
│   └── mockServiceWorker.js  # DEV ONLY — kept out of `dist/` since 0.8.0; see below.
├── mocks/
│   └── fixtures.ts         # DEV ONLY — deletable. Overrides for the mock endpoints.
├── scripts/
│   ├── inject-schema.mjs   # YOURS, and required. zod → JSON Schema → manifest.json.
│   └── dev-ssr.mts         # DEV ONLY — deletable. Boots the local SSR preview.
└── src/
    ├── index.tsx           # SHIPPED. Client IIFE entry: registerPlugin() + mount().
    ├── widget.tsx          # SHIPPED. Your component (+ getInitialData in this template).
    ├── ssr.ts              # SHIPPED. dehydrateState + getStaticAssets.
    ├── ssr-entry.ts        # SHIPPED. Re-export barrel — the SSR bundle's entry point.
    ├── config.ts           # SHIPPED. widgetSchema.extend() + zodToManifestSchema().
    ├── widget.css          # SHIPPED. Tailwind import + your styles.
    ├── data.ts             # SHIPPED. Sample public-api fetcher + a query-key factory.
    ├── dev/                # DEV ONLY — deletable.
    │   ├── main.tsx        #   the entry index.html loads: mock worker → mount → panel
    │   └── DevPanel.ts     #   the bottom control bar
    └── components/ui/      # SHIPPED and yours. Vendored Button/Dialog/Carousel/Stylesheet/
                            #   WidgetSection/…; widget.tsx imports `cn` from here.

Everything marked DEV ONLY or DEMO ONLY can be deleted without affecting what customers load. You lose the local sandbox and the SSR preview, nothing else — see Dev sandbox and mocking and Previewing SSR locally.

serve.js, vercel.json and api/widget-asset.ts in particular imitate the platform’s own asset proxy for a self-hosted demo. The publish pipeline never touches them, because you host nothing: you submit source, and HomeRunner builds and serves the result. See From your laptop to a customer page.

(b) A real published suite (widget-core 0.11.0+)

# /Users/…/hr-plugins/pdp-suite — the published v1.3.3 source tree (50 zip entries, 82 KB)
pdp-suite/
├── manifest.json                    # root manifest: widgets[] (4) + presets[] + media refs
├── package.json                     # three scripts: dev, build, build:manifest
├── package-lock.json
├── tsconfig.json
├── vite.config.ts                   # 13 lines — identical in shape to the single-widget one
├── scripts/
│   └── inject-schema.mjs            # same script, multi-widget branch
├── widgets/                         # GENERATED by build:manifest, but COMMITTED and shipped
│   ├── pdp-frame.manifest.json
│   ├── stay-hero.manifest.json
│   ├── stay-facts.manifest.json
│   └── booking-cta.manifest.json
├── media/                           # AUTHORED. Nothing generates this directory.
│   ├── README.md                    #   declared as manifest.readme
│   ├── CHANGELOG.md                 #   picked up by filename convention, declared nowhere
│   ├── pdp-frame.svg                #   per-widget icons — flat, one level
│   ├── stay-hero.svg
│   ├── stay-facts.svg
│   ├── booking-cta.svg
│   └── widgets/
│       ├── pdp-frame.md             #   per-widget readmes — one nested level is allowed
│       └── …
└── src/
    ├── vite-env.d.ts
    └── widgets/
        ├── pdp-frame/               # the layout
        │   ├── index.tsx            #   registerPluginWidget(…, { category: "layout" }) + mount
        │   ├── widget.tsx           #   reads props.renderedSlots
        │   ├── config.ts            #   MUST declare a `slots` zod object
        │   ├── ssr-entry.ts         #   one line: export { default } from "./widget";
        │   └── pdp-frame.css
        ├── stay-hero/
        │   ├── … + hero-bg.png      #   a bundled asset, imported from the widget folder
        │   └── ssr-entry.ts         #   present, because this widget declares ssr.url
        ├── stay-facts/              #   "ssr": false → NO ssr-entry.ts at all
        └── booking-cta/             #   "ssr": false → NO ssr-entry.ts at all

No index.html, no src/dev/, no mocks/, no public/, no api/, no serve.js, no vercel.json, no src/components/ui/, and no root README.md. A suite’s public README is media/README.md.

npx create-hr-plugin my-suite --template suite (create-hr-plugin 0.8.0+) writes that same shape — three widgets under src/widgets/, media/, presets[], scripts/inject-schema.mjs — plus the dev-only files this real suite has dropped: index.html, src/dev/, mocks/fixtures.ts, public/mockServiceWorker.js, serve.js, vercel.json, .env.example and scripts/dev-ssr.mts (create-hr-plugin 0.8.1+, and widget-core 0.12.2+ to run it). Every one of those is deletable on the same terms as in tree (a). It also adds src/shared/, which is only a demonstration of relative cross-widget imports. It does not add Tailwind or a src/components/ui/ kit: a layout’s stylesheet is unisolated light DOM on a server-rendered page, so a framework reset would land on the customer’s whole document.

Build a suite covers both the scaffold and the hand conversion; The two manifest shapes is the decision, and it is permanent once you publish.

Who authored what

Three categories, and confusing them is the most common cause of a publish that fails after your submission was accepted.

Authored by you. Everything under src/, manifest.json, vite.config.ts, scripts/inject-schema.mjs (it lives in your project, not in the SDK — you can edit it), and all of media/.

Generated by the build, and committed anyway. widgets/{slug}.manifest.json (Suite). Your build:manifest step writes them from each widget’s config.ts, and they must be present in both zips because the dashboard fetches them from the CDN to draw the config form.

Generated by the build, and never committed. dist/. The scaffold’s .gitignore already excludes it, and a root-level dist/ in a source zip is rejected — the reviewer rebuilds it from your source. See Packaging and publishing rules.

media/ is content, not output

Nothing generates media/. You create the directory, you draw the icons, you write the README, and you commit them. The reviewer runs your build and nothing else, so a manifest that declares icon: "media/logo.svg" against a file that is not in your source zip passes the upload audit, passes review, and fails at publish. If media/ is in your .gitignore, or your icons live outside it, that is the failure you will hit.

Which paths each media field accepts is on Manifest: root fields; the commit rule and the exact publish errors are on Packaging and publishing rules.

What npm run build changes on disk

npm run build is npm run build:manifest && hr-widget-build. Between them they rewrite your tracked manifest.json twice:

  1. build:manifest (scripts/inject-schema.mjs) injects the schemas. (Single-widget) it writes configSchema and uiSchema into the root manifest. (Suite) it writes one widgets/{slug}.manifest.json per summary and back-fills each summary’s manifest path — the root manifest never carries a schema.
  2. The build’s finalize pass stamps runtime.widgetCore with the SDK version it built with, and logs runtime.widgetCore stamped: X.Y.Z.

So manifest.json is dirty in git after every build. That is correct — commit it. Never hand-write runtime.widgetCore; the publish audit gates on the stamped value.

The same pass walks dist/, writes a content-hashed twin next to every file, and emits dist/manifest.json — the build manifest, {buildHash, generatedAt, files}, keyed by dist-relative path. .map files and manifest.json itself are skipped by the hashing pass but sourcemaps are still published.

# /Users/…/hr-plugins/pdp-suite/dist — the real output of one `npm run build`
dist/
├── manifest.json                          # the build manifest — hashed twins, keyed dist-relative
├── pdp-frame/
│   ├── pdp-frame.iife.js  + …-be6fddd4.js # client bundle + hashed twin
│   ├── pdp-frame.css      + …-9992937d.css
│   ├── pdp-frame-ssr.umd.js + …-5872d9b8.js
│   └── pdp-frame.iife.js.map              # published, but absent from the build manifest
├── stay-hero/
│   └── … + hero-bg-Cr1v4Zys.png           # a bundled asset (widget-core 0.12.0+)
├── stay-facts/                            # iife + css only — "ssr": false
└── booking-cta/                           # iife + css only — "ssr": false

File-naming rules, the {env}/{slug}/{version}/ CDN key and how a URL is resolved live on Keywords, assets and URLs.

The MSW leak, and its fix. Both scaffolds put the mock service worker in public/, and Vite copies publicDir into outDir on every production build — so on scaffolds older than create-hr-plugin 0.8.0, mockServiceWorker.js and a hashed twin end up in your published assets and in dist/manifest.json. Harmless but pointless. 0.8.0’s vite.config.ts returns publicDir: command === "build" ? false : "public", which keeps it out of the build and leaves npm run dev untouched; add that line yourself if your project predates it. (The real suite has no public/, so it never had the leak.)

The artifacts, and who reads each one

After the reviewer builds and publishes, a handful of file kinds sit on the CDN under one immutable version prefix. They have almost disjoint audiences:

ArtifactRead by
Root manifest.jsonCentral caches it (manifest_cache) and attaches it to every plugin widget row. The renderer resolves your keyword against that copy. The dashboard reads it for picker entries, icons and presets.
widgets/{slug}.manifest.json (Suite)The dashboard only, fetched from the CDN on demand when it draws the config form. The renderer never fetches it.
dist/manifest.jsonThe renderer, to prefer the content-hashed twin of a bundle over the version-less proxy.
dist/…iife.js (client)The browser, after the shared runtime IIFE has loaded.
dist/…-ssr.umd.js (server CJS)The renderer, loaded into a node:vm sandbox. Absent for "ssr": false widgets.
dist/….cssBoth paths — the renderer emits <link>s into the shadow root, the client adopts or loads them.
media/…The dashboard, for the marketplace page, the plugin readme and picker icons.

Two consequences worth designing around:

  • Only the ROOT manifest is cached and shipped per render. Keep it small. That is the whole reason a suite’s schemas live in sub-manifests instead of inline — see Config schema and UI schema.
  • The sub-manifest fetch is best-effort. If it is missing or slow the dashboard degrades to a schemaless panel rather than erroring — but a sub-manifest your manifest declares and your zip does not contain is a hard publish failure.

The end-to-end path — source zip, review, built zip, CDN, install — is From your laptop to a customer page. What each render path does with these bundles is How a widget renders.

TypeScript setup

The scaffold’s tsconfig.json is byte-identical to the real suite’s, so treat it as the platform baseline: jsx: "react-jsx", target: "ES2017", module: "ESNext", moduleResolution: "bundler", lib: ["ES2020", "DOM", "DOM.Iterable"], strict, noEmit, isolatedModules, and a @/*./src/* path alias.

include is ["src/**/*", "vite.config.ts"] only. mocks/, scripts/, serve.js and api/ sit outside the TS project, and there is no typecheck script — nothing type-checks your project unless you add "typecheck": "tsc --noEmit" yourself. Vite’s build does not type-check either.

Where to go next

The two manifest shapes

Every HomeRunner plugin is one of two things, and you choose which on the day you write your first manifest.json:

  • a single-widget plugin (the v1 shape) — one widget, described by root-level fields;
  • a suite (the multi-widget shape) — up to 24 widgets under one version, one review and one publish, each described by an entry in widgets[]. (widget-core 0.11.0+)

Both shapes are supported forever, and neither is deprecated. But the shape is frozen at your first publish: the only way out of the wrong choice is a new plugin slug — a new marketplace listing your existing customers would have to install and re-configure. Most authors take the single-widget shape because it is the scaffold’s default, then discover six months later that they want a second widget, a layout, or a preset. It is one flag either way — decide on purpose. The short answer is in Which should I choose: start as a suite, even with one widget.

The discriminator is one line

// packages/homerunner-widget-core/src/manifest.ts — isMultiWidgetManifest
return Array.isArray(manifest.widgets) && manifest.widgets.length > 0;

That is the whole test. There is no version flag, no type field, no "shape" key. A manifest with a non-empty widgets array is a suite root; anything else — widgets absent, widgets: [], widgets: {} — is a v1 single-widget manifest.

The same predicate is re-implemented at every layer that touches your plugin, which is why one array reconfigures so much at once:

WhereWhat it decides
The SDK resolver (resolvePluginWidget)Which branch a stored keyword resolves through
The source-zip auditWhether widgetType/ssr.url/assets.js are required at the root, or widgets[] is validated instead
hr-widget-buildTwo Vite passes, or a (widget × pass) loop with BUILD_WIDGET set
viteHomerunnerWidgetA bare vite build on a suite fails fast rather than emitting a flat, broken dist/
Your scripts/inject-schema.mjsSchemas inlined into the root manifest, or written to per-widget sub-manifests
The dashboardOne picker entry, or one per non-deprecated summary

Central applies it too, comparing your new manifest against the live one — that is the freeze, and it is the subject of The one-shape rule below.

Side by side

Full field tables live in Manifest: root fields and Manifest: widget summary. This is the difference, not the reference.

Single-widget (v1)Suite
Discriminatorno widgets[]non-empty widgets[]
Widget described byroot widgetType, ssr, assetsone widgets[] entry each
Keyword a customer storesplugin:{slug}plugin:{slug}:{widget}
Config schema lives inroot configSchema / uiSchemawidgets/{slug}.manifest.json, fetched by the dashboard on demand
Source treesrc/widget.tsx, src/config.ts, src/index.tsx, src/ssr-entry.tssrc/widgets/{slug}/{widget,config,index}.tsx per widget
Built filesdist/{slug}.iife.js, dist/{slug}.css, dist/{slug}-ssr.umd.jsdist/{widget}/{widget}.iife.js, … nested per widget
Server renderingalways required — a v1 plugin cannot be CSR-onlyper widget: ssr.url, or an explicit ssr: false
Layout widgetsimpossiblecategory: "layout" with slots[] and pages[]
Presetsnot validated, not usedup to 8 one-click compositions
Per-widget icon, readme, description, expectedHeight, deprecated, mediaSafeAuto, externalFetch, assets.fontsroot-level only, one set for the pluginper widget
Widget pickerone entry, named from the root manifestone entry per non-deprecated summary, plus preset entries
Plugin pageone synthetic card (plugin icon, name, content badge, keyword, description, Create button) — and nothing more: no per-widget README, no SSR/CSR badge, no instance count, no per-feed toggle, no count in the tab labela full section per widget with its own icon, readme, badges, instance count, per-feed toggle and Create button
Widgets per version1up to 24
Minimum SDKanywidget-core 0.11.0+

A v1 root, from the scaffold:

// packages/create-hr-plugin/template/manifest.json (placeholders substituted)
{
  "id": "my-widget",
  "version": "1.0.0",
  "name": "My Widget",
  "widgetType": "my-widget",                 // required here; its VALUE is never read
  "ssr":    { "url": "dist/my-widget-ssr.umd.js" },
  "assets": { "js": "dist/my-widget.iife.js", "css": "dist/my-widget.css" },
  "runtime": { "react": "^19.0.0" }
  // the build injects "configSchema" and "uiSchema" at this level
}

The same plugin as a suite of one — note what moved and what disappeared:

// DERIVED: the manifest above rewritten in the shape of
// pdp-suite (reference plugin): manifest.json — widgets[] with one entry
{
  "id": "my-widget",
  "version": "1.0.0",
  "name": "My Widget",
  "runtime": { "react": "^19.0.0" },
  "widgets": [                               // presence => suite. No widgetType,
    {                                        // no root ssr/assets/configSchema.
      "slug": "hero",
      "name": "Hero",
      "ssr":    { "url": "dist/hero/hero-ssr.umd.js" },
      "assets": { "js": "dist/hero/hero.iife.js", "css": "dist/hero/hero.css" },
      "manifest": "widgets/hero.manifest.json"
    }
  ]
}

Every customer of the first manifest stores the keyword plugin:my-widget. Every customer of the second stores plugin:my-widget:hero. Those two strings are the whole reason the shape can never change.

Mixing the shapes is not an error

Mutual exclusivity is convention, not enforcement. A manifest that carries both widgets[] and the v1 root fields passes every validator: resolution takes the suite branch, silently ignores root widgetType / ssr / assets / configSchema, and the bare plugin:{slug} keyword stops resolving. Nothing tells you. Delete the root fields when you convert.

Shape mismatches fail closed

Resolution never guesses. When the stored keyword and the manifest disagree, the resolver returns null and the widget is removed — it is never rendered with defaults, and never falls back to another widget.

ManifestKeywordResult
v1plugin:{slug}resolves
v1plugin:{slug}:{anything}null — v1 manifests have no widget slugs
Suiteplugin:{slug}null — a suite needs a widget segment
Suiteplugin:{slug}:{unknown}null — no summary with that slug
Suiteplugin:{slug}:{widget} whose summary has no assets.jsnull
v1 with no root assets.jsplugin:{slug}null
eithera malformed keyword (plugin:, plugin::hero, plugin:acme:)null — the keyword does not even parse

Keyword grammar is specified in Keywords, assets and URLs.

A null looks different on each of the three paths a plugin reaches a customer through:

  • Server-rendered pages. The renderer replaces the widget with a hidden breadcrumb node carrying data-hr-widget-error="{keyword}" and an HTML comment whose reason is keyword does not resolve against the plugin manifest. The rest of the page renders normally, and the page is flagged degraded so it caches for a much shorter window. View-source and search for data-hr-widget-error. The full reason index is in Limits and error index.
  • CSR embeds. Nothing errors, because nothing runs. The copied snippet’s script tag is derived from the keyword — /p/{slug}/{slug}.iife.js for v1, /p/{slug}/{widget}/{widget}.iife.js for a suite — and the proxy serves only files the live manifest declares, so a stale embed gets {"error":"Asset not in plugin manifest"} and the host <div> is left untouched.
  • The dashboard. A keyword that no longer resolves against the manifest yields no schema, so the customer’s config form degrades to a schemaless panel rather than erroring.

Every one of those is silent from your side. Nobody is notified — not the customer, not you.

The one-shape rule

Before your first publish you can convert freely. The gate compares your new manifest against the plugin’s live one, and an unpublished plugin has none. Flip as often as you like while you are building.

After your first publish the shape is permanent. Central refuses a manifest that flips it with 422 MANIFEST_SHAPE_CHANGED, on publish and on rollback, so you cannot even roll back across a shape change. The dashboard mirrors the same check just before the reviewer uploads the built zip. Both messages are quoted verbatim in Limits and error index.

The trap is when it fires:

StepShape checked?
The audit in your browser, before uploadno
POST /api/v3/plugins/submissions (submit)no
Admin review — approve / changes requested / rejectedno
Publish (and rollback)yes — 422 MANIFEST_SHAPE_CHANGED

So a shape flip passes your own audit, passes submit, and passes review. You are told the submission looks fine, and days later the publish dies. Do not read “the reviewer approved it” as “the shape is acceptable”.

The reason is a serving contract, not a policy: customers’ widget rows store the keyword, customers’ web pages contain copied embed URLs built from that keyword, and neither survives a shape change. Both would resolve to null on the very next render, across every feed at once.

The only remedy is a new plugin slug. manifest.id is the slug, so a new shape means a new id, a new plugin row, a new marketplace listing and a new CDN prefix. Your existing customers keep running the old plugin, and reach the new one only by installing it and re-creating their widgets from it.

Not supported yet. There is no migration path, no alias, no “this plugin replaces that plugin” pointer. replacedBy exists in the widget summary type and is read by nothing. Inside a suite you can retire a widget with deprecated: true and keep every placement rendering — see Manifest: widget summary. There is no equivalent for retiring a whole shape.

Which should I choose

Start as a suite, even with one widget. A suite of one is legal, publishes normally, and costs you nothing you cannot get back. Choosing v1 costs you a decision you cannot revisit.

Single-widget (v1)Suite of one
Can add a second widget laterno — new slugyes, in the next version
Can add a layout laternoyes
Can add a preset laternoyes
Can ship a CSR-only widgetnoyes
npx create-hr-plugin generates ityes — the default, --template singleyes — --template suite (0.8.0+)
Works with npm run dev (:3001, MSW-mocked)yesyes — all widgets on one page, the layout composing the others
Works with npm run dev:ssryesyes — one named widget at a time (widget-core 0.12.2+, and create-hr-plugin 0.8.1+ for the script)
npm run build / hr-widget-buildyesyes
Publishes, installs, renders, SSRsyesyes

A suite used to cost you the dev tooling. It no longer does: --template suite generates a suite-aware sandbox (create-hr-plugin 0.8.0+) and a dev:ssr script (0.8.1+), and nothing in the build was ever single-widget-shaped — hr-widget-build branches on widgets[] itself, and the scaffold’s vite.config.ts and scripts/inject-schema.mjs handle both shapes with no edits. Two limits are left, and both are local-only:

Not supported yet. The dev sandbox holds one mock state for the whole page — scenario, latency, branding and the widget settings object are a page singleton, so two widgets with the same field name share its value. And there is no server-side slot composition locally: npm run dev:ssr renders the one widget you named, so a layout previews with empty slots. Details in Dev sandbox and mocking and Previewing SSR locally.

So the recommended path is now the short one:

  1. Scaffold the shape you actually want: npx create-hr-plugin <slug> --template suite, even if it holds one widget. See Build a suite.
  2. If you started single-widget and changed your mind, convert before you publish — rewrite manifest.json and move src/* under src/widgets/{slug}/. Same page.
  3. Publish. From here the shape is fixed, but you can add widgets, a layout and presets in any later version.

Choose v1 deliberately only when all of these are true: the plugin is one widget, it will always be one widget, it server-renders, and you want the single-widget template’s Tailwind and src/components/ui/ kit more than you want the option to grow. A marketing badge or a one-off embed is a fair v1. Anything you expect to iterate on is a suite.

Suites need @homerunner-next/widget-core 0.11.0+ (layouts and presets too; assets.fonts and pages need 0.12.0+, slot prefill 0.12.1+, a suite’s npm run dev:ssr 0.12.2+), and the suite scaffold itself needs create-hr-plugin 0.8.0+ — 0.8.1+ for the dev:ssr script it writes. What npm currently serves, and how to get a newer SDK, is in Install and versions.

See also

How a widget renders

Your plugin is React code that runs in two very different places: a server sandbox with no DOM, and a customer’s browser. Which of those actually run — and in which order — depends entirely on what your manifest declares. There are exactly three paths:

KindDeclared asServer renderBrowser
Content widget with SSRssr: { url: … } — Both shapesYes, in an isolated React rootHydrates that markup
CSR-only content widgetssr: false — Suite only (widget-core 0.11.0+)No — an empty host divFetches its own settings and renders
Layout widgetcategory: "layout" + ssr.url — Suite only (0.11.0+)Yes, composing its slot children firstNothing — the mount is a no-op

The field rules behind that table belong to Manifest: widget summary and Layout widgets. This page is about what each path does.

The page your widget lands on

A customer’s page is not “your widget”. It is a composed document that may carry a dozen widgets — first-party and plugin, in any mix — sharing one React Query cache and one runtime bundle.

customer's domain
  └─ Cloudflare worker (reverse proxy in front of the customer's site)
       └─ renderer  GET /api/render/{feedId}/property/{slug}
            ├─ resolves the page schema: which widgets, in what order
            ├─ renders every top-level entry CONCURRENTLY
            └─ returns JSON { html, assets, dehydratedState, meta }
       └─ worker splices that html + assets into the site shell
  → browser

The renderer never sends a whole page. It sends a fragment plus an asset list, and the worker composes. That is why your CSS and JS arrive as page-level <link> and <script> tags rather than anything you control, at URLs the platform derives — see Keywords, assets and URLs.

What reaches the browser, in document order:

<!-- Shape derived from packages/homerunner-renderer/lib/render-utils.tsx
     and src/lib/homerunner/cf-worker.ts (page composition). -->
<head>
  <link rel="stylesheet" href="…">          <!-- one per entry in assets.css -->
  <link rel="preload" as="script" href="…"> <!-- one per entry in assets.js -->
  <link rel="preconnect" href="{central api origin}" crossorigin>
</head>
<body>
  <div data-hr-page="…" data-hr-widget-container>
    <div data-hr-widget="plugin:pdp-suite:stay-hero" id="…" data-hr-ssr-id="…">…</div>
    <div data-hr-widget="plugin:pdp-suite:stay-facts" id="…" data-hr-min-height="140"></div>
  </div>
  <script>window.HRWidget.__WIDGET_PROPS__ = { "<ssr id>": { props, options, css } }</script>
  <script>window.HRWidget.__REACT_QUERY_STATE__ = { … }</script>
  <script src="…/w/runtime.iife.js" defer></script>
  <script src="…/p/pdp-suite/stay-hero/stay-hero.iife.js" defer></script>
</body>

Three page-level facts follow from that shape, and every path below depends on them:

  1. Props travel in a registry, not in attributes. Each host carries a small data-hr-ssr-id key; one inline script holds every widget’s props, per-embed options and stylesheet list. If that script is blocked — a strict CSP, an ad blocker, an HTML-rewriting CDN — the markup is there but the props are not, and your widget falls through to the client-render path instead of hydrating.
  2. One shared React Query cache. Everything the server prefetched is serialised once and rehydrated into a single browser-side client that every widget on the page shares. Namespace your query keys or you will read someone else’s data.
  3. The runtime IIFE loads before yours. Since widget-core 0.10.0 your bundle contains no React, no React DOM, no React Query and no widget-core runtime — they resolve off window.HRWidgetRuntime at execution time. Your IIFE then registers your component and calls mount().

The complete attribute contract, the boot order and the registry lookup rules are in Runtime, mount and the DOM contract.

Path 1 — a content widget with SSR

What runs on the server

The renderer fetches your published SSR bundle, evaluates it once in a node:vm sandbox (cached afterwards), then, inside a single try:

# packages/homerunner-renderer/lib/render-utils.tsx (getWidgetMarkup)
getInitialData(ctx) → dehydrateState(sharedQueryClient, ctx) → getStaticAssets(settings)
  → renderToString(<WidgetHydrationTree><YourComponent {...props} /></WidgetHydrationTree>,
                   { identifierPrefix: ssrId })

Two things about that sandbox shape the code you can write. It is a bare V8 realm — only a short, fixed list of modules and globals is seeded, so crypto, TextEncoder and Buffer simply are not there. And window/document exist as inert stubs whose property reads return another truthy stub, so if (document) passes on the server. Only typeof window === "undefined" guards are rewritten and tree-shaken out of the SSR bundle. The exact module list, globals, externalFetch rules and execution bounds are in Component and SSR module.

The props you receive are raw — plugin settings are deliberately not parsed for you, on either side. parseWidgetConfig(configZod, props.options ?? {}) is mandatory in the component and in every SSR export. Why, and what raw actually looks like, is Settings and options.

What the customer’s page contains

Your rendered HTML goes inside one host <div>. When the request is Declarative-Shadow-DOM capable, the renderer wraps it in a shadow template instead of emitting it bare:

<!-- packages/homerunner-renderer/lib/render-utils.tsx — DSD-capable request -->
<div data-hr-widget="plugin:pdp-suite:stay-hero" id="{widgetId}:{feedId}" data-hr-ssr-id="…">
  <template shadowrootmode="open">
    <link rel="stylesheet" href="…">        <!-- your CSS, plus any declared fonts -->
    <div class="hr-react-root">…your markup…</div>
    <div class="hr-portal-container"></div>
  </template>
</div>

What runs in the browser

Your IIFE registers your component, then mount() finds the host, adopts the shadow root, and hydrates the existing DOM in place. Nothing re-renders from scratch and no data is re-fetched — the query cache already holds what the server prefetched.

Where your code can fail

Four distinct ways, and they behave very differently. A throw in any data hook or in your component during the server render is contained the same way: your widget is replaced by a hidden breadcrumb node, the rest of the page renders, and the page is marked degraded — its cache lifetime collapses at every layer so it re-renders soon rather than serving a broken version for a day. A rejected prefetch inside a queryFn is quieter still: it is swallowed by design, logged, and your widget ships whatever it renders with no data (if that is a spinner, crawlers see a spinner). A throw in the browser is caught by the render boundary, which marks the host and shows a neutral error card. And output that merely differs between the two passes does not throw at all — React silently discards your server DOM; see Hydration.

The full failure table, the breadcrumb markup and the log lines to grep are in Component and SSR module; the numbers are in Limits and error index.

Declarative Shadow DOM, conceptually

Every content widget renders inside a shadow root: it is what stops the customer’s site CSS from repainting your widget and your CSS from leaking into their page. The only question is when that root is created.

Without DSD, the browser parses your markup into the light DOM, and JavaScript creates the shadow root, moves the markup into it, attaches the stylesheets and waits for them. Your widget is briefly visible unstyled, or briefly hidden — the mount blink.

With DSD, the server ships a <template shadowrootmode="open"> and the browser attaches and styles the root while parsing. By the time your JavaScript runs there is nothing to build and nothing to wait for. No blink, correct paint on the very first frame, and — with the right CSS — correct dark mode before any script executes.

A plugin earns a DSD template only when all of these hold:

ConditionOwned by
The renderer’s DSD emission is on (it is, by default) and the request’s browser supports it
The root manifest does not set shadowDOM: falseManifest: root fields
The build stamped runtime.widgetCore at 0.10.0 or newer — which is also the publish floorManifest: root fields

mount() then takes one of three branches, in order:

# packages/homerunner-widget-core/src/runtime/mount.tsx
1. ADOPT   host.shadowRoot already exists (native DSD) → already styled, hydrate now
2. ADOPT   an inert <template shadowrootmode> in light DOM → attach it, then wait for CSS
3. BUILD   no template at all → create the shadow root, move the server markup into it

Branch 3 is the migrate path: it moves nodes with appendChild rather than re-serialising through innerHTML, because hydration matches on node identity. That is one of several reasons you must never call hydrateRoot yourself.

Two conceptual traps worth carrying with you:

  • Your manifest’s shadowDOM and what your bootstrap passes to mount() must agree. Declare shadow DOM and mount with shadowDOM: false and the browser attaches the server’s shadow root at parse while your code renders into the light DOM, which is never displayed: visible server content that never hydrates. Nothing checks this before you ship, and it looks fine on a browser that missed the DSD bucket. The full matrix is in Runtime, mount and the DOM contract.
  • DSD is what makes correct dark-at-first-paint possible at all. The server cannot know the visitor’s colour scheme (resolvedTheme is effectively always light in production), so first-paint dark has to come from CSS inside that template. The recipe is in Styling and theming.

Path 2 — a CSR-only content widget

Suite only (widget-core 0.11.0+). Declaring "ssr": false on a widget summary opts it out of the server pipeline entirely. It has no ssr-entry.ts, no SSR bundle, and its component is never invoked on the server. This is a suite-shape feature: a single-widget manifest with no ssr.url is a failure, not a CSR-only widget — the renderer drops it.

What the renderer still does: emit an empty host div, add your JS and CSS to the page’s asset list, add a registry entry carrying the per-embed options, and — when the summary declares expectedHeight — stamp a minimum height on the host so the page does not jump when the widget finally paints.

<!-- packages/homerunner-renderer/lib/render-utils.tsx — the ssr:false branch -->
<div data-hr-widget="plugin:pdp-suite:stay-facts" id="{widgetId}:{feedId}"
     data-hr-ssr-id="…" data-hr-min-height="140"></div>

In the browser, mount() sees a childless host, takes the client-render path, and your component is rendered by a wrapper that first resolves settings: it fetches the feed and the widget row, merges feed branding, feed globals, the stored settings and the per-embed override, and hands you the result. So on this path your options are resolved in the browser from a live config fetch rather than baked into the HTML — still not schema-parsed, so parseWidgetConfig remains mandatory — widgetType is supplied (it is not on the SSR path), and data is undefined because no getInitialData ever ran.

On a server-rendered page that config query is pre-seeded by the renderer and travels in the dehydrated state, so it resolves from cache with no network round trip. In a hand-written CSR embed on a page the renderer never touched, it is two live API calls before anything paints.

Where your code can fail here: a render throw is caught by the render boundary and the host is marked; a failed config fetch renders the shared error state instead of your widget. Both markers, and the full vocabulary, are in Runtime, mount and the DOM contract.

Choose CSR-only deliberately. You lose server HTML (nothing for crawlers, a later first paint, a real CLS risk unless you set expectedHeight). You gain a much simpler build, no sandbox constraints, and live editing — the dashboard’s options-updated preview channel works on this path and does not work on a hydrated SSR widget.

Path 3 — a layout widget

Suite only (widget-core 0.11.0+). A layout does not render content; it renders regions, and the platform fills them with other widgets bound into named slots. Its render path is inverted relative to everything above.

# packages/homerunner-renderer/lib/render-utils.tsx (getLayoutWidgetMarkup) — SSR
1. collect every binding across every slot (a binding to a missing widget is logged, skipped)
2. render ALL slot children CONCURRENTLY
     each child is a complete island: own SSR id, own DSD template,
     own props-registry entry, own assets
3. reassemble each slot in binding order  →  renderedSlots[slotName] = <>{…children}</>
4. load YOUR layout bundle LAST, run its optional data hooks, render the shell
5. unshift the layout's CSS and JS ahead of every child's assets

Your component receives the finished children as React nodes in renderedSlots and simply places them. The shell is then emitted as static light DOM — no shadow root, no DSD template, no SSR id, no props-registry entry.

That has three consequences that consistently read as bugs and are not:

  • Your layout CSS is document-level on every server-rendered page. Namespace every class, never write a :host-only rule for the shell, never paint a background. Details in Layout widgets and Styling and theming.
  • The client mount is a deliberate no-op. Running the client path there would throw away the server’s slot children and re-fetch everything. Each child hydrates itself through its own IIFE.
  • A layout that throws during render takes the whole page down. Content widgets render inside a try; a layout shell is returned as an un-rendered element and rendered later, in the page’s single renderToString, which has none. Parse defensively and default every slot.

Not supported yet. Because the shell has no SSR id, no registry entry and no mount, a plugin layout can never be interactive on a server-rendered page — no React tree is ever attached to it. Put interactivity in a content widget and bind it into a slot. See Layout widgets.

Not supported yet. Layouts cannot nest. A layout bound into another layout’s slot is refused on both render paths, before anything renders.

The same layout in a CSR embed is the mirror image

Drop a layout into a single-snippet embed and everything inverts: the shell does get a shadow root, and your layout mounts its own children — reading the stored slot bindings, fetching each child’s row, loading each child’s bundle, waiting for it to register, then mounting it into a host div after commit. It also pushes the parent colour scheme and, when it resolved one, the property filter onto every child.

So a layout stylesheet has to work document-level and inside a shadow root, and renderedSlots means two different things on the two paths — server-rendered nodes on one, childless host divs on the other. The shapes differ too: a slot that was never bound is absent from the object on the server but present as an empty array on the client, so slots.hero ?? fallback does not behave the same way in both places. The per-path prop table is in Layout widgets.

Hydration, in one idea

Hydration is React adopting DOM it did not create. It walks your component tree and the existing markup in lockstep and attaches event handlers to the nodes it finds. It works only while the two agree.

The platform makes them agree by rendering each widget as an isolated React root on the server, inside a fixed wrapper, with an identifier prefix derived from that widget instance — and by having mount() mirror the wrapper shape and the prefix exactly. React derives useId values from tree position plus that prefix, so any drift makes every useId consumer (Radix dialog and popover aria attributes, form ids) mismatch. React’s response is not a warning: it silently regenerates the tree and discards every byte of your server HTML.

Which gives you three rules — Both shapes, all runtime, nothing checks them before you ship:

  1. Mount through widget-core’s mount(). Never call hydrateRoot yourself; you cannot reproduce the wrapper and prefix from outside.
  2. Render deterministically. No Math.random(), no Date.now(), no reading window, and no reading props.widgetType — it is absent on the SSR/hydrate path and present on the CSR path, which makes it a hydration trap disguised as a prop.
  3. Do not fetch by suspending during render. renderToString does not await; a suspended boundary ships its fallback into the HTML. Fetch in getInitialData or dehydrateState.

The wrapper, the prefix and the exact prop differences per path are in Component and SSR module.

Failure containment, end to end

One design rule explains most of the platform’s behaviour here: a plugin must not be able to break a customer’s page. Everything except one case is contained.

What brokeBlast radius
Your keyword does not resolve, your SSR bundle 404s or has no default exportThat widget only — replaced by a hidden breadcrumb, page marked degraded
A data hook or a content component throws on the serverThat widget only
A data hook throws on a layoutThat layout and its already-rendered children — the page still responds, but an assigned page layout failing this way leaves the page empty
A component throws in the browserThat widget only — the render boundary catches it
One embed’s mount() throwsThat embed only — every other embed on the page still mounts
A layout component throws during the server renderThe whole page 500s

Every hidden breadcrumb stays in the page source, so curl plus view-source tells you which widget the platform dropped and why. Symptom-first diagnosis is in Troubleshooting.

Settings and options

Every widget you ship has a settings blob. A customer edits it in the dashboard, central stores it, the platform merges it with the feed’s branding, and the result lands on your component as props.options. One thing to remember from all of that:

props.options arrives RAW — on the server and in the browser. Nothing schema-parses it for you. Call parseWidgetConfig(configZod, props.options ?? {}) yourself, in your component and in getInitialData, dehydrateState and getStaticAssets. Both shapes, widget-core 0.9.0+.

The rest of this page is the chain that makes that rule necessary — read it once and the storage artifacts, stringified numbers and colorScheme surprises stop surprising.

The chain

config.ts (zod) --zodToManifestSchema--> manifest.configSchema   build time, yours
   |
   v  "Create widget": schema defaults + feed branding (no colorScheme) + filter.feed_id
   v
   widget.settings  <--- the form autosaves the raw value on every change
   |                (opaque JSON on central: nullable|array, replaced wholesale)
   v
   getFinalSettings(widget, feed, optionsOverride?)
   |    feed branding -> feed globals -> widget.settings -> per-embed override
   v
   props.options  -- RAW, on the server and in the browser
   v
   parseWidgetConfig(configZod, props.options)   <-- your job, at every entry point

The chain runs in two places: server-side in the renderer for an SSR widget, client-side in the runtime for a CSR embed. Both call the same merge function and hand you the same unparsed object — see How a widget renders.

One setting, end to end

Take the simplest field in the reference suite — a heading with a default.

// pdp-suite (reference plugin): src/widgets/stay-facts/config.ts
export const configZod = widgetSchema.extend({
  heading: z.string().default("Key facts").describe("Section heading"),
  facts: z
    .array(z.string())
    .default(["Sleeps 6 in 3 bedrooms", "Fast wifi + workspace", "5 min to the beach"])
    .describe("Facts, one per line"),
});

zodToManifestSchema(configZod) turns that into JSON Schema and your build writes it into the manifest (single-widget shape) or the widget’s sub-manifest (suite shape); the dashboard reads it back and builds the form. Which zod constructs survive that round trip is normative in Config schema and UI schema.

1. Creation: your defaults become stored values

When a customer creates a widget from your plugin, the dashboard seeds widget.settings with your schema’s default values, layers the feed’s branding over them (minus colorScheme, so the widget keeps inheriting live), and forces filter.feed_id. The exact composition is in Config schema and UI schema. Both shapes.

  • Branding wins over your default for the branding keys. If your schema defaults font.family, the feed’s value overwrites it at creation. Pick defaults for your own fields; treat the base theme fields as the customer’s.
  • A default stops being a default the moment the row is written. Changing default() in a later version never reaches widgets that already exist. Migrate in your component (read the old shape, fall back) instead.

2. The dashboard form: live validation is cosmetic

The plugin config form validates on every keystroke and shows errors as path.to.field: message. It also autosaves on every change — including while the form is invalid.

// src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/PlayGround.tsx
const handleChange = async (data: any) => {
  const cleaned = stripEmptySlotEntries(data);  // the ONLY transform on the plugin path
  await upsertWidget({ id: widget.id, feed_id: widget.feed_id,
    keyword: widget.keyword, status: widget.status, settings: cleaned });
};
// wired as:  <Form liveValidate onValueChange={handleChange} ... />

Central accepts the blob as 'settings' => 'nullable|array' and replaces it wholesale — no partial merge, no schema check, no 422 for a bad value.

Not supported yet. Nothing validates stored settings at any layer — advisory (unvalidated). Your own parseWidgetConfig call is the only real gate, and it runs on the customer’s page, not in the dashboard. A cross-field rule written with .refine() is erased by the manifest round trip and enforced nowhere.

The plugin save path runs stripEmptySlotEntries (blank slot bindings on a layout) and nothing else; the blank-row strippers system widgets get never run for plugins — see the Translations gap in Config schema and UI schema.

3. Storage: what central does to your JSON

Your blob makes two lossy trips: Laravel’s request middleware on the way in, and PHP’s array cast on the way out. Three artifacts reach production plugins.

ArtifactCauseWhat you get back
"" becomes nullConvertEmptyStringsToNull runs on every request, at any depth (app/Http/Kernel.php)A cleared text field, enum or number all read as null
An emptied map becomes []Widget::$casts declares 'settings' => 'array'; PHP cannot tell {} from [], so an empty object re-encodes as an empty listA record field the customer emptied arrives as [], not {}
Scalars arrive stringifiedOlder blobs and feed-level writes survive a JSON round trip as textfont.size: "16" — fine in the form, a zod failure at render

You repair none of these by hand: parseWidgetConfig absorbs all three. See Config schema and UI schema for what it does to each, and in what order.

One consequence is worth designing around. A cleared text field is stored as null, and a null your schema rejects is stripped — so clearing a field that has a default() silently restores your default on the next render: an empty input in the dashboard, your placeholder text on the page. If “deliberately cleared” must be distinguishable from “never touched”, declare that field .nullable() so the null survives the strip and reaches you as a real value.

4. The merge: getFinalSettings

Stored settings are never handed to you alone. The runtime merges them first.

// packages/homerunner-widget-core/src/runtime/api.ts
getFinalSettings<T>(widget: Widget<T>, feed: Feed, optionsOverride?: Partial<T>): T

The widget comes first, then the feed. The old guide had these reversed, and so does anything copied from it. The signature is tabulated in SDK reference; the precedence below is this page’s.

Merge order, lowest priority first:

  1. Feed brandingcolorScheme, the light and dark accents, font.size, font.family.
  2. Feed-wide globals — explorer and booking defaults. These never apply to a plugin: the scope test is on the widget’s keyword and yours is plugin:{slug}[:{widget}], so this step is always empty. Both shapes.
  3. widget.settings — the stored blob.
  4. optionsOverride — the per-embed data-hr-options payload, and how a layout pushes state onto its slot children. See Runtime, mount and the DOM contract.

Three behaviours that bite:

  • Arrays replace wholesale. A higher-priority array is taken atomically, never merged by index. A stored facts: ["one"] replaces your three-item default outright — it does not leave entries two and three behind.
  • __parentColorScheme is reserved. A layout pushes its own resolved scheme onto every slot child under this key. It is stripped before the merge and consulted only when the child chose no concrete scheme, so a child that picked dark keeps dark. Never declare it in your schema or read it as a setting; see Layout widgets.
  • colorScheme always resolves to something concrete. global, null and absent all mean “inherit”: the pushed parent scheme wins, else feed branding, else the terminal default light — not auto. A visitor’s OS preference is honoured only when someone explicitly chose auto.

The merge finishes by lifting a legacy width: {maxWidth, unit} onto spacing and normalising customCss to "". That is all of it — no zod, and none of your configSchema defaults.

5. Your component: raw, on both sides

// pdp-suite (reference plugin): src/widgets/stay-facts/widget.tsx
export default function StayFacts(props: { options?: Record<string, unknown> }) {
  const cfg = parseWidgetConfig(configZod, props.options ?? {});
  return <h3 className="sfacts-heading">{cfg.heading}</h3>;
}

cfg.heading is a string. props.options.heading may be null, missing, or a value your schema rejects. Read the parsed object, never the raw one.

That includes fields the platform injects rather than the customer: because you built your schema with widgetSchema.extend({...}) the base filter is declared, so the filter.property a layout pushes into a slot child survives the parse and reads as cfg.filter?.property. Keys your schema does not declare are dropped, which is the point rather than a loss. The same rule applies to every server-side export:

// packages/create-hr-plugin/template/src/ssr.ts — scaffold guidance, made executable
export async function dehydrateState(
  queryClient: QueryClient,
  ctx: { options: Record<string, unknown>; feedId: number; widgetId: string },
) {
  const options = parseWidgetConfig(configZod, ctx.options);  // ctx.options is RAW too
  await queryClient.prefetchQuery({ /* ... uses options ... */ });
}

System widgets get a parsed object in those three hooks; plugins do not, and that is deliberate — your schema is a zod value the renderer has never seen.

A parse failure is not silent. parseWidgetConfig ends in schema.parse, so it throws: on the client the render boundary catches it and marks the host data-hr-error="render-failed"; on the server a content widget is dropped from the page while a layout shell takes the whole page down. Both the export contract and those failure semantics live in Component and SSR module.

What the platform does not apply for you

The base schema gives every widget theme fields. Merging them is the platform’s job, rendering them is yours — nothing in mount() or the renderer injects customCss, sets a font, or paints an accent colour. Both shapes.

// reviews-marquee (published plugin): src/widget.tsx
const accent = isDark ? options.darkModeColors.accent : options.lightModeColors.accent;
const themeCss = `
  :host {
    --hr-accent: ${accent};
    font-size: ${options.font.size}px;
    font-family: ${options.font.family}, ui-sans-serif, system-ui, sans-serif;
  }
  ${options.customCss ?? ""}   /* after your own rules, so the customer wins */
`;
// <WebFont family={options.font.family} /> <Stylesheet css={themeCss} />
// <div style={resolveWidgetSpacingStyle(options)}> ... </div>

spacing is the one field with a helper: resolveWidgetSpacingStyle(options) returns the style object for your outer container and absorbs legacy saves. Everything else — section, language, filter — is data you decide what to do with, and a customer who edits a field you never render sees nothing change with no way to know why. Styling and theming covers the full pattern.

Live edits in the dashboard preview

While the customer edits, the dashboard pushes the in-progress settings into the preview frame and the runtime dispatches them as an options-updated event on the mount root, routed back through getFinalSettings so colorScheme: "global" still resolves. It is wired on the CSR content and CSR layout paths only — a hydrated SSR widget has no listener, which is why the preview updates instantly while the same widget on a live page does not. See Runtime, mount and the DOM contract.

Corrections to earlier guidance

Three statements in older versions of this guide were wrong.

Old claimReality
“The renderer pre-validates, so by the time WidgetProps.options hits your component, it conforms.”The renderer keeps plugin options raw on purpose, on both server and client.
“Required fields without defaults reject the save with a 422. Unknown fields are stripped silently.”Central stores the blob as opaque JSON and replaces it wholesale. Nothing is rejected, nothing is stripped.
getFinalSettings(feed, widget, override?)The widget is the first argument.

See also

From your laptop to a customer page

You never publish anything. You submit a source zip; a HomeRunner administrator reads it, builds it on their machine, and publishes the result to the plugin CDN. Everything a customer loads was produced by that person, from your source, after a human decided to allow it.

That one fact shapes the whole pipeline and explains its two most surprising properties: your build output is audited after you have been told your submission was accepted, and nothing anywhere sends you a notification. This page is the map; every rule it mentions is stated normatively on another page, and linked.

The pipeline at a glance

ACTOR         STEP                                        ARTEFACT / STATE
──────────────────────────────────────────────────────────────────────────────────
you           npm run build                               dist/ + a stamped manifest.json
you           zip the project folder                      source zip
                │
your browser    ├─ source-zip audit                       a failure sends nothing at all
your browser    ├─ presigned PUT ────▶ PRIVATE BUCKET     submissions/{slug}/….zip
                │                                          (bytes never transit a server)
dashboard       └─ metadata leg ─────▶ CENTRAL            submission = pending
                │
admin           ├─ downloads the zip, reads your source
                │    changes_requested / rejected ────────▶ back to you, review notes only
                ▼    approved                              visibility fixed here
admin           ├─ runs YOUR build, zips the result       built zip
admin's browser ├─ built-zip audit                        fails = no publish, silently
admin's browser ├─ presigned PUTs ───▶ PLUGIN CDN         {env}/{slug}/{version}/…
                │    the root manifest.json uploads LAST   (the version prefix is immutable)
dashboard       ├─ fetches that manifest back             proves the CDN really serves it
dashboard       └─ publish call ─────▶ CENTRAL            one txn: manifest_url,
                                                           current_version, manifest_cache,
                                                           status = approved
                │
dashboard       └─ warm the renderer, purge every feed    live in seconds, not cache TTLs
                │
customer        ├─ feed ▸ Plugins ▸ Marketplace ▸ Install
customer        └─ Create widget from your plugin         the widget row stores the keyword
                                                           plugin:{slug}[:{widget}]
                │
renderer        resolves that keyword against the         your component renders on a
                cached ROOT manifest                       customer's page

Who acts, and where you watch it

#StepWho actsWhere you see it
1Build and zipYouYour terminal
2UploadYouThe uploader in Dashboard ▸ Plugins ▸ My plugins
3Record the submissionCentralYour plugin row flips to a pending badge
4ReviewAn administratorBadge + review notes on the same row
5Build your source, publish the built zipThe same administratorNothing until it lands
6Verify and repointDashboard, then centralThe row goes live; a “Published manifest” link appears
7Warm and purgeDashboardNothing — it is invisible and best-effort
8Install and configureYour customerNothing you can see

Steps 3 through 7 are one machine sequence with exactly one human decision in it — step 4. After step 2 you cannot influence anything except by submitting again.

The eight steps, in words

1. You build

npm run build produces dist/ and stamps runtime.widgetCore into your tracked manifest.json. That stamp is the platform’s evergreen-runtime gate and the reason a build older than widget-core 0.10.0 cannot be published; it is written by the build and must never be hand-edited. See Install and versions.

2. You zip and upload

(Both shapes.) You zip the project without dist/ and node_modules/ — the reviewer rebuilds those. The uploader audits the archive in your browser first: if it fails, nothing is transmitted at all. On success your browser PUTs the bytes straight into a private bucket using a short-lived presigned URL, so the zip never passes through a dashboard server, and a second call registers the metadata with central.

Two consequences. First, media/ and (for suites) widgets/*.manifest.json must be committed — nothing but your build runs later, and nothing generates them. Second, the browser audit is convenience, not authority: central re-validates everything server-side and adds rules the browser cannot check. Both rule sets, with every limit and every verbatim message, live in Packaging and publishing rules.

3. Central records the submission

The submission lands on your plugin row: status pending, your version, your parsed manifest, your notes. A new slug creates the row and you own it; a slug owned by someone else, or a legacy admin-registered row with no owner, is refused — see the API codes in Packaging and publishing rules.

4. A human reviews it

An administrator downloads your zip, verifies its checksum, and reads your source; for an update they also see a diff of the live manifest against yours. Three outcomes:

OutcomeNotesWhat happens
approvedoptionalYour submission waits for a publish. Public-or-private visibility is fixed here, in the approve dialog.
changes_requestedrequiredThe notes appear on your row. Fix and re-submit.
rejectedrequiredThe notes appear on your row, and your uploaded zip is deleted from the bucket.

Nothing is live yet. Approval is a decision, not a release.

5. The same administrator builds your source

They run your build however they like, zip the built project folder, and drop it into the publish card. A second, different audit now inspects what your build actually produced: declared assets and their hashed twins, the registration marker in every client bundle, sub-manifests, icons, screenshots and READMEs. This is the first moment anyone checks your build output, and it happens after you were told your submission was fine. A failure here does not reopen the review — it costs you a resubmission with a bumped version. Both audits are listed in full, with their exact messages, in Packaging and publishing rules.

6. The version prefix, verification, and the repoint

Files upload one by one into the immutable CDN key prefix {env}/{slug}/{version}/, root manifest.json last. That ordering makes a half-finished publish safe to retry: until the root manifest exists, nothing is live and the sentinel that would refuse a re-publish is absent.

The dashboard then fetches the published manifest back from its public URL and refuses to continue unless it serves and matches the submission — that is what “published” means here, not “we assume the bucket worked”. Only then does central repoint manifest_url, current_version and its cached copy of your root manifest, set the plugin to approved and clear the submission, in one transaction. Publishing therefore also un-suspends a suspended plugin.

Where those URLs resolve to is Keywords, assets and URLs; what a version buys you, and how to undo one, is Publishing, versions and rollback.

7. Warm and purge

Composed customer pages are cached aggressively, so publishing does not wait for TTLs to expire: the dashboard asks the renderer to load your new SSR bundle and purges the page caches of every feed that has your plugin installed. Both are best-effort and neither can fail the publish. Typical propagation is seconds; the documented worst case is the composed-page cache TTL for feeds the fan-out did not reach in its budget. The numbers are in Publishing, versions and rollback.

8. A customer installs it, per feed

Nothing is global. A customer opens a feed ▸ Plugins ▸ Marketplace, installs your plugin, activates it, and only then can create a widget from it. The widget row stores your keyword; the renderer resolves it against the root manifest central cached at publish and loads the bundles it names. Install, per-widget toggles, uninstall and the kill switches that can stop your plugin serving are in Install, customers and kill switches; the keyword and manifest side is Manifest: root fields.

The five states you can be in

Your row in Dashboard ▸ Plugins ▸ My plugins renders one of exactly five timelines.

StateStepperWho is blockedWhat to do
pendingSubmitted ✓ · In review · Approved · PublishedAn administratorWait
changes_requestedSubmitted ✓ · Changes requested ⚠ · Approved · PublishedYouRead the notes, fix, re-submit
rejectedSubmitted ✓ · Rejected ✕ · Approved · PublishedYouRead the notes, fix, re-submit
approvedSubmitted ✓ · Reviewed ✓ · Approved ✓ · Publish pendingAn administratorWait
published (nothing in flight)Submitted · Reviewed · Approved · Published ✓NobodyShip the next version

Review notes are shown inline for the two failure states, followed by “— fix and re-submit the zip above.” A plugin that is live and has an update in review shows both at once: the liveness badge for what is serving, and a timeline sitting at In review.

Nobody will tell you

Not supported yet. There are no notifications of any kind. No email, no in-app alert, no webhook fires when your plugin is approved, rejected, published or suspended. Nothing in the pipeline sends one. The only channel is opening Dashboard ▸ Plugins ▸ My plugins and reading the badge, the timeline and the review notes yourself. Poll it.

Not supported yet. The built-zip audit’s warnings are visible only to the reviewer. A config schema that does not extend the base, an oversized bundle, a filename that cannot be published — none of them block the publish, and all of them are recorded in a browser you will never look at. Unless the reviewer copies them into review notes, you will never learn that your plugin shipped with an empty settings form. Run those checks yourself: Preflight and submit.

A third asymmetry to plan around: the one-shape rule is not enforced before upload. Flipping a live plugin between the single-widget and suite shapes passes the source audit and passes review, and only dies at publish. See The two manifest shapes for the decision and Packaging and publishing rules for the gate.

Re-submitting replaces whatever is in flight

(Both shapes.) Uploading a new zip for the same slug overwrites the in-flight submission from any state — pending, changes_requested, rejected, even approved-but-unpublished. The status returns to pending, the reviewer, notes and review timestamp are cleared, and the previously uploaded zip is deleted.

Your published version is never touched by this. A plugin that is live keeps serving its current version throughout review, rejection and resubmission — the only things that change what customers load are a publish and a rollback. So if an approved submission has not been published yet and you spot a bug, just submit the fix: you are not queue-jumping, you are replacing the artefact the reviewer was about to build.

You host nothing

Third-party plugins cannot be self-hosted. Registering a plugin by pointing central at a manifest URL you control still exists, but it is an administrator-only escape hatch for pre-pipeline plugins: it has no dashboard UI at all, and it refuses a slug that already exists. The zip pipeline is the only path open to you.

This matters because the scaffold looks like it disagrees. serve.js, vercel.json and api/ in a freshly generated project are local preview scaffolding — they serve dist/ on your own machine. They are not a deployment target, nothing on the platform consults them, and you can delete them.

Where each step’s rules live

You wantPage
Zip rules, audit gates, size limits, verbatim messagesPackaging and publishing rules
Preflight checklist, the upload UI, review states in detailPreflight and submit
Version rules, propagation, rollbackPublishing, versions and rollback
Install, per-feed config, suspension, kill switchesInstall, customers and kill switches
Which URL a bundle, image or font resolves toKeywords, assets and URLs
The shape decision you can never reverseThe two manifest shapes
Every number and message in one indexLimits and error index

Manifest: root fields

manifest.json sits at the root of your project, next to package.json. It is the only file the platform reads to learn that your plugin exists, what it ships, and how to serve it. It travels in both zips, is cached by the platform, and is handed to the renderer on every server render.

There are two shapes. A manifest is the suite (multi-widget) root if and only if widgets is a non-empty array; otherwise it is the single-widget (v1) shape. Nothing else distinguishes them — there is no version flag and no type field. See The two manifest shapes for how to choose, and Manifest: widget summary for every field inside a widgets[] entry.

This page is normative for every ROOT-level field. Per-widget fields live in widget-summary.md; layout semantics in layouts.md; every hard number and the full error index in limits-and-errors.md.

Where root fields are enforced

Three checkpoints read your root manifest. This page tags each rule with the one that catches it, because they happen days apart.

TagCheckpointWho sees it
publish ERROR (audit)The dashboard audits your source zip in your browser, before a byte is uploaded.You, immediately.
publish ERROR (submit)POST /api/v3/plugins/submissions re-validates the manifest server-side.You, as a 422 on Upload.
publish ERROR (build)The built-zip audit plus central’s publish/rollback gates, run by the reviewer after approval.The reviewer. You learn only that publishing failed.

publish warning never blocks anything; warnings raised on the built zip are visible only to the reviewer. Enforcement-level vocabulary is defined once in limits-and-errors.md.

Always required (both shapes)

FieldTypeRequiredRuleOn violation
idstringBothThe plugin slug. /^[a-z0-9][a-z0-9-]*$/, ≤ 255 chars. Used verbatim — it is not slugified for you.publish ERROR (audit): manifest "id" ("My_Plugin") must be lowercase alphanumeric with hyphens — it is the plugin's slug. · missing → manifest.json is missing the required "id" field.
versionstringBothThree-part MAJOR.MINOR.PATCH, ≤ 64 chars. Strictly greater than your published version.publish ERROR (audit): manifest "version" ("1.2") is not valid semver (e.g. 1.2.0). · publish ERROR (submit): 422 INVALID_VERSION, 422 VERSION_NOT_GREATER
namestringBothNon-empty, ≤ 255 chars. Display name in the marketplace and the widget picker.publish ERROR (audit): manifest.json is missing the required "name" field.
runtime.reactstringBothA semver range, e.g. "^19.0.0". Required by the PluginManifest type, and every real manifest carries it — but it is declarative metadata only (see below).advisory (unvalidated): no checkpoint reads or requires it. Ship it anyway; a reviewer looks at it.

Required in the single-widget shape only

Present these three when widgets is absent or empty. A suite root must not carry them.

FieldTypeRequiredRuleOn violation
widgetTypestringSingle-widgetNon-empty string. Its value is never read by anything — routing uses plugin:{id}. The scaffold sets it to your id; keep doing that.publish ERROR (audit): manifest.json is missing the required "widgetType" field.
ssr.urlstringSingle-widgetPath to the SSR bundle, e.g. dist/my-widget-ssr.umd.js. A v1 plugin has no CSR-only mode — there is no ssr: false at the root.publish ERROR (audit): manifest.json is missing "ssr.url" (the SSR bundle path). · at publish: manifest is missing "ssr.url".
assets.jsstringSingle-widgetPath to the client IIFE. A v1 manifest with no assets.js resolves to null and the widget never renders.publish ERROR (audit): manifest.json is missing "assets.js" (the client bundle path). · at publish: manifest is missing "assets.js".
assets.cssstringNoOptional in the type and in both audits.
assets.fontsstring[]NoMax 8; absolute http(s) URLs or paths confined to the version prefix. (widget-core 0.12.0+) Honoured at resolve for v1 roots and validated by central, but not by the browser audit. See assets-and-urls.md.publish ERROR (submit) only
configSchemaJSON SchemaNov1 only. Generated by your build from zodToManifestSchema(configZod) — never hand-edited. In a suite these move to per-widget sub-manifests.publish warning (build): manifest has no configSchema — was this zipped after `npm run build`? The dashboard form will be empty.
uiSchemaobjectNov1 only, same as above. See config-and-ui-schema.md.

Two more root fields are read for v1 manifests but not declared in the PluginManifest TypeScript type, so your editor will not offer them: root-level mediaSafeAuto (strict true) and root-level externalFetch (string[]). Both resolve exactly like their widgets[] counterparts and neither is validated at publish. In a suite, declare them per widget.

Not supported yet. A v1 root has no category, no slots, no pages and no expectedHeight. A single-widget plugin can never be a layout and can never be CSR-only. Both need the suite shape.

Optional everywhere

FieldTypeRequiredRuleOn violation
descriptionstringNoOptional. Shown on the marketplace card. String, ≤ 4000 chars if present.publish ERROR (submit): 422 INVALID_MANIFEST Manifest "description" must be a string of at most 4000 characters.
author{name, url?}NoBoth members are strings ≤ 255 chars. author.url renders only when it matches ^https?://.publish ERROR (submit): Manifest "author.name" must be a string of at most 255 characters.
homepagestringNoRendered on the plugin page only when it matches /^https?:\/\//i. Anything else is silently dropped.advisory (unvalidated)
supportstringNoSame rule as homepage.advisory (unvalidated)
docsstringNoSame rule as homepage.advisory (unvalidated)
licensestringNoFree text (e.g. "MIT"). Nothing parses it.advisory (unvalidated)
tagsstring[]NoNon-string entries are dropped; the list is truncated to 12 when displayed.silent runtime truncation
screenshotsstring[]Nomedia/<file> paths only — an absolute URL is rejected. Each file must ship in the built zip. No count cap.publish ERROR (submit + build), see Media references
iconstringNomedia/<file> (flat) or an absolute http(s) URL, ≤ 2048 chars. (widget-core 0.12.0+)publish ERROR (submit + build)
coverstringNoSame value rules as icon. (widget-core 0.12.0+)publish ERROR (submit + build)
readmestringNomedia/<file>.md or media/<dir>/<file>.md, ≤ 255 chars, file ≤ 64 KB. (widget-core 0.12.1+)publish ERROR (submit + build)
widgetsPluginWidgetSummary[]NoPresence flips the manifest to the suite shape. Max 24 entries. (widget-core 0.11.0+) See widget-summary.md.publish ERROR (audit): manifest declares 26 widgets — the limit is 24 per plugin.
presetsPreset[]NoMax 8. Suite shape only. (widget-core 0.11.0+) See Presets.publish ERROR (audit + submit)
shadowDOMbooleanNoWhole-plugin flag. (widget-core 0.10.1+) See shadowDOM.advisory (unvalidated), but see the stranding failure below
runtime.widgetCorestringNo in the type, required to publishStamped by the build. Never hand-write it. Must be ≥ 0.10.0.publish ERROR (build), see runtime

A media/CHANGELOG.md in your zip becomes the plugin page’s Changelog tab by convention — it is not declared anywhere in the manifest.

Not supported yet. There is no root-level keywords, minPlatformVersion, permissions or dependencies field. Unknown root keys are ignored everywhere, so a typo like "screenshot" fails silently rather than erroring.

id

manifest.id is the plugin slug. Central stores it verbatim after checking /^[a-z0-9][a-z0-9-]*$/; there is no Str::slug normalisation, so My_Plugin is rejected rather than repaired.

The slug is permanent and globally unique across all authors:

  • An existing row owned by someone else → 409 SLUG_TAKEN, The plugin id "acme-suite" already belongs to another author.
  • An existing row with no owner (a legacy admin-registered plugin) → 409 SLUG_RESERVED, The plugin id "acme-suite" is reserved. Contact an administrator to claim it.

It also constrains your upload key: the submission object must live under submissions/{id}/…, else 422 INVALID_ZIP_KEY. Full submit flow in ship/preflight-and-submit.md.

Your id is the first half of every keyword the platform stores against a customer’s widget row (plugin:{id} or plugin:{id}:{widget}), which is why it can never change.

version

Three hard rules, in the order you will hit them:

  1. Shape, in the browser. /^\d+\.\d+\.\d+(-…)?(\+…)?$/. 1.2 fails with manifest "version" ("1.2") is not valid semver (e.g. 1.2.0).
  2. Three parts, at central. Composer’s parser would accept 1.0; central re-checks with its own regex and returns 422 INVALID_VERSION, Manifest "version" must be MAJOR.MINOR.PATCH semver: 1.0.
  3. Strictly greater, at central. 422 VERSION_NOT_GREATER, Version 1.2.0 must be greater than the published 1.3.0.

One version number covers the whole suite. There is no per-widget version.

Published version prefixes on the CDN are immutable — re-publishing the same version is refused. See ship/publish-versions-and-rollback.md.

Not supported yet. Pre-release suffixes (1.0.0-rc.1) pass every validator, but each immutability detector in the fleet requires a bare X.Y.Z path segment. A pre-release publish silently loses direct content-addressed asset URLs, the life-of-instance build-manifest cache and the slug-keyed SSR bundle cache, falling back to short-TTL proxied serving everywhere. Ship plain X.Y.Z versions.

runtime

// packages/create-hr-plugin/template/manifest.json — the build adds `widgetCore`
{
  "runtime": { "react": "^19.0.0" }
}

After a build:

// /Users/.../hr-plugins/pdp-suite/manifest.json (real, published 1.3.3)
{
  "runtime": { "react": "^19.0.0", "widgetCore": "0.12.0" }
}

runtime.react is declarative metadata. Nothing in the renderer, the dashboard, the asset proxy or central reads its value, and no checkpoint requires it to be present — the build’s stamp pass creates the runtime object for you if it is missing. It documents intent for a reviewer; it does not gate anything. (The old guide claimed “the renderer rejects plugins whose runtime.react doesn’t match”. There is no such code.)

What actually controls which React you run against is the exact pin set in your package.json, because the platform externalises React and hands you its own copy at runtime. Patch drift trips react-dom’s own Incompatible React versions assertion. The canonical pins live in get-started/01-install-and-versions.md.

runtime.widgetCore is the enforced gate. It records the exact @homerunner-next/widget-core version your build ran with, and it is written for you by the build’s finalize pass — treat it as generated output, not a field you own. Expect manifest.json to come back dirty in git after npm run build; commit it.

RuleEnforcementMessage
Present and semverpublish ERROR (build)manifest has no "runtime.widgetCore" stamp — this build predates the evergreen runtime. Update @homerunner-next/widget-core to 0.10.0+ and rebuild.
0.10.0publish ERROR (build)Built against widget-core 0.9.0 — the fleet requires 0.10.0+ (older builds bundle a stale mount that breaks on SSR pages). Update and rebuild.
Bare X.Y.Z at centralpublish ERROR (build)422 MANIFEST_WIDGET_CORE_MISSING — central’s regex is /^\d+\.\d+\.\d+$/, so a pre-release stamp is rejected even though the dashboard audit accepts one.

The same gate runs on rollback, so you cannot roll back to a version built with a pre-0.10.0 SDK.

shadowDOM

// /Users/.../hr-plugins/pdp-suite/manifest.json
{ "shadowDOM": true }

A root-level flag governing the whole plugin — there is no per-widget override. Omit it (or set true) unless every one of your client bootstraps passes shadowDOM: false to mount().

It buys one thing: Declarative Shadow DOM for your server-rendered markup, which means your widget is styled at parse time with no mount blink. The renderer grants DSD only when both hold:

  • manifest.shadowDOM !== false, and
  • runtime.widgetCore parses to ≥ 0.10.0.

Nothing validates that the flag agrees with your bootstrap. When they disagree — the manifest says shadow, mount() says none — the server attaches a shadow root that your mount never adopts, and the SSR content is stranded: visible in view-source, invisible on screen. See runtime-and-mount.md for the mount side and recipes/styling-and-theming.md for the styling consequences.

Media references

Four fields point at files under media/. Their value rules are not the same, and the differences are load-bearing.

FieldAccepts a media/ pathAccepts an absolute URLNestingLength cap
iconyes, flat media/<file>yesnone≤ 2048 chars
coveryes, flat media/<file>yesnone≤ 2048 chars
widgets[].iconyes, flat media/<file>yesnone≤ 2048 chars
screenshots[]yes, flat media/<file>no — rejectednone
readme, widgets[].readmeyes, .md onlynoone directory level≤ 255 chars path, ≤ 64 KB file

Exact patterns, from the SDK:

// packages/homerunner-widget-core/src/manifest.ts (isPluginMediaRef, isPluginReadmeRef)
/^media\/[^/\\]+$/            // icon, cover, widgets[].icon — flat, no subdirectory
/^https?:\/\/[^\s]+$/i        // …or an absolute URL, for those three only
/^media\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?\.md$/   // readme — one optional subdir

Violations:

ConditionEnforcementMessage
icon/cover is neither shapepublish ERROR (build)manifest "icon" must be a "media/<file>" path or an absolute http(s) URL.
A declared media/ icon is absent from the zippublish ERROR (build)Declared icon media/logo.svg is missing from the zip.
A screenshot is not media/<file>publish ERROR (build)manifest screenshot "https://cdn.example.com/1.png" must be a "media/<file>" path.
A declared screenshot is absentpublish ERROR (build)Declared screenshot media/1.png is missing from the zip.
readme is the wrong shapepublish ERROR (build)manifest "readme" must be a "media/<file>.md" (or "media/<dir>/<file>.md") path.
A declared README is absentpublish ERROR (build)Declared manifest "readme" media/README.md is missing from the zip.
A README exceeds 64 KBpublish ERROR (build)manifest "readme" media/README.md is 71 KB — the limit is 64 KB.

Central re-checks the same shapes at submit and returns 422 INVALID_MANIFEST with its own wording, e.g. Manifest "icon" must be a "media/<file>" path or an absolute http(s) URL (max 2048 characters).

Three consequences worth internalising:

  • media/ is authored content. Nothing generates it, and the reviewer only runs your build. A declared icon, screenshot or README that is not committed to your source zip makes the publish fail after you were told the submission looked fine. See packaging-and-publishing.md.
  • A media/ reference resolves only after your first publish. It is resolved with new URL(ref, manifest_url), and an unpublished plugin has no manifest_url — so the resolver returns null and the icon falls back to a glyph. An absolute URL renders immediately, including in the review console. If you want your icon visible during first review, use a URL; if you want it version-immutable, use media/.
  • Screenshots asymmetry is deliberate. Screenshot files publish under the same immutable version prefix as your bundles, so they may not point off-platform. The plugin page additionally drops any screenshot that is not media/…, and renders none at all before the first publish.

READMEs are rendered through a markdown sanitizer — no raw HTML, no scripts.

Presets

// /Users/.../hr-plugins/pdp-suite/manifest.json (root level, beside "widgets")
"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"]
    }
  }
]

presets?: Array<{ name: string; layout: string; slots: Record<string, string[]> }> — a root field, suite shape only (widget-core 0.11.0+). Presets on a v1 manifest are neither validated nor used, because a preset must reference a declared layout widget and only suite roots declare widgets.

Referential integrity is checked at publish, in both the browser audit and central:

RuleEnforcementMessage (browser audit)
At most 8 entries, and a listpublish ERROR (audit)manifest "presets" must be a list of at most 8 entries.
Every preset has a namepublish ERROR (audit)each manifest preset needs a "name".
layout names a declared widget with category: "layout"publish ERROR (audit)preset "PDP Starter" must reference a declared layout widget.
slots is an object of arrays of stringspublish ERROR (audit)preset "PDP Starter" needs a "slots" object. / preset "PDP Starter" has an invalid slot binding shape.
Every slot child is one of this plugin’s own non-layout widget slugs, or a bare system widget keyword /^[a-z][a-z0-9-]{0,63}$/publish ERROR (audit)preset "PDP Starter" references "plugin:acme:hero" which is neither a declared content widget nor a system widget keyword.

A plugin: reference is never accepted as a slot child — name your own widget by its bare slug, and a system widget by its bare keyword.

Not supported yet. Preset slot keys are never checked against the layout’s declared slot names. A typo ("sidebar" vs "side-bar") passes every validator and creates a binding the layout never renders. Copy the slot names from your own slots[] and diff them by eye.

What one click on a preset actually creates — which children are created versus reused, the naming, the rollback on failure — is in layouts.md and recipes/composing-a-page.md.

Suite roots and v1 roots are not mixable

Mutual exclusivity is convention, not enforcement. If a manifest carries both widgets[] and the v1 root fields, resolution takes the multi branch, silently ignores root widgetType / ssr / assets / configSchema, and the bare plugin:{id} keyword stops resolving — every existing v1 placement breaks. Conversely, a widget slug resolved against a v1 manifest returns null.

Both failures are fail-closed: the widget is removed from the page and the renderer records the reason keyword does not resolve against the plugin manifest. The resolution table and the failure node it produces are in concepts/manifest-shapes.md.

The shape is frozen at first publish. Central refuses a manifest that flips shape with 422 MANIFEST_SHAPE_CHANGED:

This manifest changes the plugin between single-widget and multi-widget shapes. Existing
widget placements and embed URLs would stop resolving — publish the new shape under a new
plugin slug instead.

Note when this fires: submit and review do not check the shape. The gate runs at publish and at rollback only, and the dashboard mirrors it just before the reviewer uploads your built zip with:

This build changes {slug} between single-widget and multi-widget shapes. Existing
placements and embed URLs would stop resolving — publish the new shape under a new plugin
slug.

So a shape flip will pass your upload and pass review, and die at publish. The only remedy is a new plugin slug. If you might ever ship a second widget, start as a suite — a suite of one is legal.

How the manifest reaches the renderer

  1. At publish, central stores your root manifest in manifest_cache and records manifest_url (the CDN URL of that exact manifest.json) and current_version.
  2. The renderer asks central for a feed’s widgets. For every widget row whose keyword starts with plugin:, the join attaches two fields:
// what the renderer receives per widget row (suite keyword form)
{
  "id": "…",
  "keyword": "plugin:pdp-suite:stay-hero",
  "plugin_manifest": { /* your ROOT manifest.json, verbatim */ },
  "plugin_manifest_url": "https://plugins.homerunner.io/prod/pdp-suite/1.3.3/manifest.json"
}

The v1 keyword form is plugin:{id} with no widget segment.

  1. The renderer parses the keyword, resolves it against plugin_manifest, and reads the resolved widget’s ssr.url and assets.*.

Four properties of that pipeline you should design around:

  • Only the ROOT manifest is cached and shipped. A suite root deliberately carries no configSchema/uiSchema — those live in per-widget sub-manifests fetched on demand by the dashboard alone. Keep the root small; it rides along with every render.
  • The manifest attached is the kill-switch-filtered one. Suspended plugins get no manifest at all, and widget summaries switched off platform-wide or on that feed are stripped before the renderer ever sees them. See ship/install-and-kill-switches.md.
  • Path-only asset values resolve against plugin_manifest_url, preserving subpaths. dist/stay-hero/stay-hero.iife.js against https://…/prod/pdp-suite/1.3.3/manifest.json becomes https://…/prod/pdp-suite/1.3.3/dist/stay-hero/stay-hero.iife.js. Ship path-only values; absolute URLs bypass the whole mechanism and are a legacy shape.
  • The hashed build manifest is preferred over the proxy. For a pipeline-published plugin the renderer reads your dist/manifest.json and emits the content-addressed file directly, so a cached page always loads the exact bundle its HTML was rendered with. The version-less /p/{slug}/{file} proxy is the fallback. Both are documented in assets-and-urls.md.

Full example — a real suite root

// /Users/.../hr-plugins/pdp-suite/manifest.json (published v1.3.3), widgets[] trimmed
{
  "id": "pdp-suite",                       // the slug; permanent, globally unique
  "version": "1.3.3",                      // MAJOR.MINOR.PATCH, strictly increasing
  "name": "PDP Suite",
  "description": "A remix of the platform's property-details layout…",
  "readme": "media/README.md",             // one nesting level allowed; <= 64 KB
  "author": { "name": "HomeRunner QA", "url": "https://homerunner.io" },
  "license": "MIT",
  "tags": ["pdp", "layout", "property"],   // first 12 shown
  "runtime": {
    "react": "^19.0.0",                    // metadata only — nothing reads it
    "widgetCore": "0.12.0"                 // STAMPED BY THE BUILD. Never hand-write.
  },
  "shadowDOM": true,                       // whole-plugin; must match every mount() call

  "widgets": [                             // presence => suite shape. Max 24.
    { "slug": "pdp-frame",  "name": "PDP Frame",  "category": "layout", "…": "…" },
    { "slug": "stay-hero",  "name": "Stay Hero",  "…": "…" },
    { "slug": "stay-facts", "name": "Stay Facts", "ssr": false, "…": "…" },
    { "slug": "booking-cta","name": "Booking CTA","ssr": false, "…": "…" }
  ],

  // Absolute URLs: these render in the marketplace and the review console BEFORE the
  // first publish. `media/…` values would resolve to null until manifest_url exists.
  "icon":  "https://picsum.photos/seed/pdp-suite-icon/256/256",
  "cover": "https://picsum.photos/seed/pdp-suite-cover/1200/400",

  "presets": [                             // max 8; slot children are own slugs or
    {                                      // bare system keywords, never plugin: refs
      "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"]
      }
    }
  ]
}

Every field inside widgets[] is specified in widget-summary.md.

Full example — a v1 root

// packages/create-hr-plugin/template/manifest.json (placeholders substituted)
{
  "id": "my-widget",
  "version": "1.0.0",
  "name": "My Widget",
  "description": "A HomeRunner widget plugin",
  "author": { "name": "Plugin Author" },
  "widgetType": "my-widget",               // required; its VALUE is never read
  "ssr":    { "url": "dist/my-widget-ssr.umd.js" },
  "assets": {
    "js":  "dist/my-widget.iife.js",
    "css": "dist/my-widget.css"            // optional
  },
  "runtime": { "react": "^19.0.0" }        // build adds "widgetCore"
  // build also injects "configSchema" and "uiSchema" here
}

See also

Manifest: widget summary

A widget summary is one object in the root manifest’s widgets[] array. It is the entire per-widget contract the platform stores: the slug that forms the keyword, the bundle paths, whether the widget server-renders, where its config schema lives, and how the dashboard presents it. Central caches the ROOT manifest and ships it with every render — your summary is the only per-widget record the renderer ever sees.

Shape: Suite only. widgets[] exists in the multi-widget (suite) manifest shape and nowhere else. In the single-widget (v1) shape the same information lives on root fields; see The two manifest shapes for the discriminator and Manifest: root fields for the root table. Requires @homerunner-next/widget-core 0.11.0+ (see Install and versions).

Suite summary fieldv1 root equivalent
widgets[].slugwidgetType (and the plugin id)
widgets[].ssrssr (required; never false)
widgets[].assetsassets
widgets[].manifest → sub-manifestinline root configSchema / uiSchema
widgets[].category / slots / pagesno equivalent — v1 plugins are always content

A summary’s keyword is plugin:{pluginSlug}:{widgetSlug}. Slugs only need to be unique inside your plugin. Keyword grammar and asset URL resolution live in Keywords, assets and URLs.

Enforcement levels used below — publish ERROR, publish warning, silent runtime truncation, advisory (unvalidated) — are defined once in Limits and error index.

Field index

Error strings are quoted verbatim from the dashboard’s submission audit (src/lib/plugin-zip.ts); {slug} is your widget slug and widgets[N] the array index.

FieldTypeRequired (suite entry)RuleOn violation
slugstringRequired/^[a-z0-9][a-z0-9-]*$/, unique in the plugin, not dist / widgets / mediapublish ERROR — manifest widgets[N] needs a lowercase-alphanumeric-with-hyphens "slug". · manifest widget slug "{slug}" is reserved. · manifest declares widget slug "{slug}" more than once.
namestringRequirednon-empty; shown in the picker and every admin surfacepublish ERROR — manifest widget "{slug}" is missing "name".
descriptionstringOptionalfree text, shown under the name in the pickeradvisory (unvalidated) — no length cap, no format check
category"content" | "layout"Optional (default content)exactly one of the two literalspublish ERROR — manifest widget "{slug}" has an unknown "category" (content | layout).
ssr{ url: string } | falseRequiredssr.url a confined relative path, or the literal false for a CSR-only widgetpublish ERROR — manifest widget "{slug}" needs "ssr.url" (relative path) or an explicit "ssr": false for CSR-only widgets.
assets.jsstringRequiredconfined relative path to the client IIFEpublish ERROR — manifest widget "{slug}" needs a relative "assets.js" path inside the version prefix.
assets.cssstringOptionalconfined relative pathpublish ERROR — manifest widget "{slug}" has an invalid "assets.css" path.
assets.fontsstring[]Optional (0.12.0+)≤ 8 entries, each an absolute http(s) URL or a confined relative pathpublish ERROR — manifest widget "{slug}" "assets.fonts" must be a list of at most 8 stylesheets. · … entries must be absolute http(s) URLs or relative paths inside the version prefix. Extra/invalid entries are also dropped by silent runtime truncation
manifeststringOptional, in practice requiredconfined relative path to widgets/{slug}.manifest.json; the file must ship in the built zippublish ERROR (shape) — manifest widget "{slug}" has an invalid sub-manifest path. · publish ERROR (missing at publish) — widgets/{slug}.manifest.json (widget "{slug}"'s sub-manifest) is missing from the zip or invalid JSON.
iconstringOptional (0.12.0+)media/<file> (FLAT, no subdirectory) or an absolute http(s) URL, ≤ 2048 chars; a media/ file must ship in the built zippublish ERROR — widget "{slug}" icon must be a "media/<file>" path or an absolute http(s) URL. · Declared widget "{slug}" icon {ref} is missing from the zip.
readmestringOptional (0.12.0+)media/<file>.md or media/<dir>/<file>.md (exactly one optional directory), ≤ 255 chars, file ≤ 64 KB, must ship in the built zippublish ERROR — widget "{slug}" readme must be a "media/<file>.md" (or "media/<dir>/<file>.md") path. · Declared widget "{slug}" readme {ref} is missing from the zip. · widget "{slug}" readme {ref} is {n} KB — the limit is 64 KB.
expectedHeightnumberOptional0 <= n <= 10000 (px)publish ERROR — manifest widget "{slug}" has an invalid "expectedHeight" (number, 0-10000 px).
mediaSafeAutobooleanOptionalstrict true only; opts into parse-time auto scheme stamping and carries a CSS obligationadvisory (unvalidated) at publish; anything other than true is read as false
externalFetchstring[]Optionalhosts the SSR sandbox will allow; non-string entries are dropped at resolveadvisory (unvalidated) at publish — enforced only at SSR runtime
slots{name, accepts?, prefill?}[]Layouts onlyrequires category: "layout"; inner shape rules in Layout widgetspublish ERROR — manifest widget "{slug}" declares "slots" but is not a layout. · manifest widget "{slug}" needs a "slots" list.
pagesPluginPageKey[]Layouts only (0.12.0+)requires category: "layout", non-empty, values from the six page keys; see Layout widgetspublish ERROR — manifest widget "{slug}" declares "pages" but is not a layout. · manifest widget "{slug}" needs a non-empty "pages" list. · manifest widget "{slug}" has unknown page(s) in "pages": … (pdp | listings | checkout | confirmation | collection | misc).
deprecatedbooleanOptionaltruthy hides the widget from the picker; existing placements keep renderingadvisory (unvalidated) at publish
replacedBystringOptionaldeclared in the SDK type onlyadvisory (unvalidated) — read by nothing (see Known gaps)

Maximum 24 widgets per plugin: manifest declares {n} widgets — the limit is 24 per plugin. (publish ERROR). Every hard number in the book is collected in Limits and error index.

Confined relative paths

assets.js, assets.css, relative assets.fonts entries, ssr.url and manifest must all be confined relative paths — paths that cannot escape the published version prefix {env}/{slug}/{version}/.

// src/lib/plugin-zip.ts — isConfinedRelativePath (the submission audit's rule)
function isConfinedRelativePath(value: unknown): value is string {
  if (typeof value !== "string" || value === "") return false;
  if (value.startsWith("/") || /^[a-z]+:/i.test(value)) return false;
  // WHATWG URL parsing treats "\\" as "/" for http(s) — a backslash segment
  // would otherwise slip past the "/"-only traversal check.
  if (value.includes("\\")) return false;
  return !value.split("/").some((segment) => segment === "..");
}

Four rejections, in order: an absolute path (/dist/hero.js), any scheme: prefix (https://cdn.example/hero.js, file:hero.js), any backslash, and any .. segment. An absolute http(s) URL is therefore not a legal value for these fields — only assets.fonts, icon and cover accept absolute URLs. Central applies the identical rule server-side.

Field notes

slug

The slug is a serving contract: it forms the keyword customers store on their widget rows, the dist/{slug}/ directory the build emits, and the /p/{plugin}/{slug}/{slug}.iife.js proxy path. dist, widgets and media are reserved because they are the published directory names under the version prefix.

When a summary’s slug fails the regex the audit stops checking that entry and moves to the next one — fix the slug first, then re-run to see the rest of that widget’s errors.

hr-widget-build enforces the same three rules before it runs a single Vite pass, so you hit them at build time, not at submission; the build’s own strings are in Build a suite.

name and description

name is required and non-empty at publish. Everything downstream still defends against an absent value: resolvePluginWidget falls back to the slug, and the picker falls back to the plugin name.

description is validated by nothing. When you omit it the widget picker substitutes the literal string Third-party plugin, and a layout gets (layout — hosts other widgets in its slots) appended to whatever description it shows.

// src/components/widget/CreateWidgetDialog.tsx — picker entry for a plugin widget
description:
  (entry.description ?? 'Third-party plugin') +
  (entry.category === 'layout' ? ' (layout — hosts other widgets in its slots)' : ''),
icon: entry.category === 'layout' ? Layers : Puzzle,

category

Only the two literals content and layout pass publish. At resolve time the check is one-sided: summary.category === "layout" ? "layout" : "content" — so an absent value, and any value that somehow reached the runtime without passing publish, is treated as content.

Choosing layout changes almost everything about how the widget renders and adds a mandatory slots field to your zod config. Read Layout widgets before you set it.

ssr

Three legal states, and the third one is a publish error:

ValueMeaning
{ "url": "dist/hero/hero-ssr.umd.js" }Server-rendered. You must ship an ssr-entry.ts for this widget.
falseCSR-only. The renderer emits an empty host div; your client IIFE renders it. No ssr-entry.ts exists for this widget at all.
absent / anything elsepublish ERROR. Omission is not the same as false — say false explicitly.

A category: "layout" widget must declare ssr.url. A layout with ssr: false is accepted by the publish audit but fails at render time:

// packages/homerunner-renderer/lib/render-utils.tsx — layout SSR path
plugin layout widgets require an SSR bundle (`ssr: false` is content-only)

See Layout widgets.

For a v1 (single-widget) plugin ssr.url is unconditionally required — a v1 manifest that resolves to ssr: false produces the renderer failure reason no plugin SSR URL in manifest. Only suite summaries may opt out.

What the SSR bundle must export, and the sandbox it runs in, are in Component and SSR module.

assets

assets.js is the only required member. Point each path at the build’s logical filename — dist/{widget}/{widget}.iife.js — not at a content-hashed twin; the platform resolves the hashed file itself from dist/manifest.json. See Keywords, assets and URLs for the derived filenames and the two serving paths.

assets.fonts (widget-core 0.12.0+) is the only supported way to load web fonts, because @font-face declared inside a shadow root is ignored by the browser. Up to 8 stylesheets per widget; the client bundle injects them into document.head and the renderer appends them to the server-rendered page’s stylesheet list. At publish, more than 8 or a malformed entry is an ERROR; at resolve time the platform silently filters invalid entries and truncates to 8 (silent runtime truncation), so a manifest that reached the fleet another way degrades rather than breaking.

Not validated before upload. The dashboard’s browser audit checks assets.fonts only inside widgets[]. A v1 root-level assets.fonts is honoured by the resolver and validated by central on submit, but nothing checks it in your browser first.

The publish audit also emits size warnings (never blocking) per widget: over 1.5 MB for the client bundle, 512 KB for the stylesheet, and 1 MB for the SSR bundle — the last reading dist/{name} is {n} MB — over the renderer's 1 MB cache threshold (slower cold SSR).

manifest — the sub-manifest path

A suite root manifest carries no schemas. Each widget’s configSchema and uiSchema live in a separate file that the summary points at, conventionally widgets/{slug}.manifest.json:

// widgets/pdp-frame.manifest.json — written by your build:manifest step
{ "slug": "pdp-frame", "configSchema": { /* … */ }, "uiSchema": { /* … */ } }

Only the dashboard ever fetches this file, and only when it needs to draw a config form. The renderer never reads it. Consequences you should design around:

  • Referenced but absent from the built zip → publish ERROR (see the table above).
  • Present but with no configSchema → publish warning: widgets/{slug}.manifest.json has no configSchema — widget "{slug}"'s dashboard form will be empty.
  • Omit the manifest field entirely and the widget is legal, installable and renderable — the customer just gets a schemaless config panel. That is a supported (if unhelpful) state, not an error.

Generate this file; never hand-write it. The generator, the round-trip rules and the publish warnings about extending widgetSchema are in Config schema and UI schema.

icon and readme

Both are marketplace surface, both (widget-core 0.12.0+), and their path rules differ:

FieldAccepted formsLength cap
iconmedia/<file>flat, no subdirectory — or an absolute http(s) URL2048 chars
readmemedia/<file>.md or media/<dir>/<file>.md — exactly one optional directory level; no absolute URL255 chars, file ≤ 64 KB

media/ is authored content that nothing generates. It must be committed and present in your SOURCE zip, because the reviewer only runs your build — a declared icon missing from your source fails the PUBLISH, long after you were told the submission looked fine. See Packaging and publishing rules.

Two traps:

  • A media/ reference resolves only after the first publish. It is resolved against the plugin’s published manifest_url; an unpublished submission has none, so media/ icons render as a fallback glyph in the review console while absolute-URL icons render immediately.
  • Filename charset. The publishable surface allows only [A-Za-z0-9._-] per path segment. media/my icon.svg passes the icon-shape check but is dropped from the upload with the warning "media/my icon.svg" won't be published — file names may only use letters, digits, ".", "_" and "-". and then fails with Declared widget "{slug}" icon media/my icon.svg is missing from the zip.

When no per-widget icon resolves, the surface falls back to the plugin’s root icon, and then to a glyph — Layers for a layout, Puzzle for content.

expectedHeight

An anti-CLS hint in CSS pixels, 0 <= n <= 10000. It must be a JSON number"140" as a string fails the browser audit even though central’s numeric check would accept it. It is used in exactly two places:

  1. CSR-only widgets on server-rendered pages. The renderer emits data-hr-min-height="{n}" on the empty host div, and only when n > 0. A widget with ssr.url never gets the attribute from the renderer — its server markup is the reservation.
  2. The embed snippet. The dashboard’s Embed Code dialog bakes the same value into the copied HTML for any plugin widget, SSR or not, and it takes precedence over the platform’s own keyword-based hint.
<!-- src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/EmbedCodeDialog.tsx -->
<div data-hr-widget-container>
  <div id="{widgetId}" data-hr-widget="plugin:pdp-suite:stay-facts" data-hr-min-height="140"></div>
</div>

Set it on every ssr: false widget. Omitting it on a CSR-only widget means the page reserves nothing and shifts when your bundle paints. What consumes the attribute is described in Runtime, mount and the DOM contract.

mediaSafeAuto

Strict true only — the resolver reads summary.mediaSafeAuto === true, so "true", 1 and any other truthy value mean false. Declaring it opts the widget into parse-time auto colour-scheme resolution: on a Declarative-Shadow-DOM-capable request the renderer stamps data-hr-scheme="auto" on your host, so dark mode resolves at first paint with no JavaScript.

The stamp is applied only when all three hold: the widget’s resolved colorScheme is auto, the request emitted DSD, and this flag is true.

This is a promise about your CSS, and nothing validates it. The stamp assumes your stylesheet dual-emits every dark rule under both standard prefixes. Declaring it without the dual emission paints a dark host with light chrome. The exact selectors, and the two footguns in writing them, are in Styling and theming.

externalFetch

A list of hosts your SSR bundle is allowed to fetch from. The resolver keeps only the string entries and hands the list to the SSR bundle loader; the sandbox then permits HomeRunner’s public API plus these hosts and blocks everything else.

Nothing validates this field at publish — neither the browser audit nor central. A typo is not an error you will see at submission; it is a blocked fetch at render time. Host-vs-host:port matching, the redirect cap, the private-address denylist and the exact block messages are in Component and SSR module.

Client-side fetches are unaffected: externalFetch constrains the server sandbox only.

slots, accepts, pages

Layout-only fields. Declaring either slots or pages on a widget whose category is not layout is a publish ERROR with the strings in the field index. Everything about their meaning — slot-name rules, accepts filtering, prefill, page assignment, the mandatory slots field in your zod config — belongs to Layout widgets.

deprecated and replacedBy

deprecated: true is a soft retirement, and it is how you remove a widget from a suite. It:

  • removes the widget from the dashboard’s widget picker for every feed,
  • disables Create widget for it on the plugin page and shows a deprecated badge,
  • leaves every existing placement rendering exactly as before, and
  • is diffed at review, so the reviewer sees deprecated / un-deprecated on the summary.

Deleting a slug instead breaks every customer widget that stores that keyword. Prefer deprecated. This is not the same as a kill switch, which stops the assets serving — see Install, customers and kill switches.

Not supported yet. replacedBy is declared in the SDK type and read by nothing. Setting it is harmless and invisible — see Known gaps.

What the platform fills in for you

Every consumer normalizes a summary through one function before using it. These defaults apply at runtime whatever the manifest says:

Summary valueResolved value
name absentthe widget slug
category anything but "layout""content"
ssr absent, or {} with no urlfalse (CSR-only)
mediaSafeAuto anything but strict truefalse
icon failing the media-ref checkdropped (falls back to the plugin icon, then a glyph)
readme failing the readme-ref checkdropped
pages on a content widgetundefined — content widgets can never serve a page
pages empty or all-unknown on a layout["misc"]
assets.fonts with invalid entries or more than 8filtered, then truncated to 8
externalFetch with non-string entriesfiltered to strings
assets.js absentthe whole widget fails to resolve (see below)
// packages/homerunner-widget-core/src/manifest.ts — resolvePluginWidget, suite branch
const summary = manifest.widgets.find((w) => w.slug === widgetSlug);
if (!summary || !summary.assets?.js) return null;
return {
  pluginSlug: manifest.id,
  widgetSlug,
  name: summary.name ?? widgetSlug,
  category: summary.category === "layout" ? "layout" : "content",
  ssr: summary.ssr === false ? false : summary.ssr?.url ? { url: summary.ssr.url } : false,
  // …
  mediaSafeAuto: summary.mediaSafeAuto === true,
  icon: isPluginMediaRef(summary.icon) ? summary.icon : undefined,
  readme: isPluginReadmeRef(summary.readme) ? summary.readme : undefined,
};

Resolution fails closed. A null result — unknown slug, missing assets.js, or a keyword whose shape disagrees with the manifest’s — makes the renderer emit a hidden failure breadcrumb with the reason keyword does not resolve against the plugin manifest instead of rendering anything. The shape-mismatch table is in The two manifest shapes.

Where each rule is checked

Your summary passes three gates at three different moments. Knowing which is which saves a review cycle.

GateWhenScopeFailure looks like
Dashboard source-zip auditin your browser, before a single byte uploadsevery rule in the field index above, plus the 24-widget cap and preset integritya list of messages in the upload dialog; all errors at once
Central’s manifest validatoron submit, after the uploadthe same rules, re-checked server-side, plus widgets[].icon and widgets[].readme shapeHTTP 422 INVALID_MANIFEST with one message — the first failure only
Built-zip auditat publish, in the reviewer’s browserfile presence: every declared bundle, hashed twin, sub-manifest, icon and readme; sizes; the HRPlugins registration markerthe publish is blocked; you are asked to resubmit with a bumped version

Two consequences worth internalising:

  • widgets[].icon and widgets[].readme shapes are checked by central but not by the browser audit. A malformed per-widget media ref sails through the local audit and comes back as 422 INVALID_MANIFEST with, e.g., Manifest widget "{slug}" has an invalid "icon" (a "media/<file>" path or an absolute http(s) URL, max 2048 characters).
  • File presence is only checked at publish. Everything in the field index that says “must ship in the built zip” is invisible until an admin builds your source. Commit media/ and widgets/*.manifest.json. See Preflight and submit.

Central also applies its own root-level caps that the browser audit does not: name and id ≤ 255 chars, version ≤ 64, description ≤ 4000, author.name / author.url ≤ 255. Those are root fields — see Manifest: root fields.

A complete summary, both kinds

// hr-plugins/pdp-suite/manifest.json — the shipped v1.3.3 manifest, widgets[] excerpt
"widgets": [
  {
    "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"
  },
  {
    "slug": "stay-facts",
    "name": "Stay Facts",
    "ssr": false,
    "assets": {
      "js": "dist/stay-facts/stay-facts.iife.js",
      "css": "dist/stay-facts/stay-facts.css"
    },
    "expectedHeight": 140,
    "manifest": "widgets/stay-facts.manifest.json"
  }
]

Read the two entries against the rules: the layout declares ssr.url because it must, pages because it wants to be assignable, and no expectedHeight because it server-renders. The CSR-only widget declares ssr: false explicitly, ships no ssr-entry.ts, and reserves 140 px.

Known gaps

One field of a summary does nothing at all:

Not supported yet. replacedBy is stored in the manifest, accepted by every validator and read by no runtime, UI or review code — not the renderer, not the dashboard, not the review diff. It surfaces nowhere. Name the successor in the widget’s readme instead.

Three more are honoured at runtime but never checked before you ship, so a mistake in them costs you a released version rather than a failed upload:

Not validated. externalFetch is checked by neither audit nor by central. A host you typo’d is not an error at submission; it is a blocked fetch at render time.

Not validated. mediaSafeAuto is a promise about your CSS that nothing can verify. Nothing inspects your stylesheet for the dual-emitted dark rules the flag requires, so a wrong declaration ships silently and paints a dark host with light chrome.

Not validated before publish. widgets[].icon and widgets[].readme shapes are checked by central on submit but not by the browser audit, and whether the declared media/ file actually exists is checked only by the built-zip audit at publish. A submission can be accepted and then fail to publish on media alone.

Layout widgets

A layout widget renders no content of its own. It declares named slots, the customer binds other widgets into them, and the platform hands your component the already-rendered children as renderedSlots. A layout can also replace the platform’s own page layout on a server-rendered page.

Layouts exist only in the suite (multi-widget) manifest shape. resolvePluginWidget hardcodes category: "content" for every v1 single-widget manifest (packages/homerunner-widget-core/src/manifest.ts), so a single-widget plugin can never ship a layout — and the shape is frozen at first publish. See The two manifest shapes.

Everything on this page needs widget-core 0.11.0+, except pages (0.12.0+) and slots[].prefill (0.12.1+).

Enforcement levels used below — publish ERROR, publish warning, silent runtime truncation, advisory (unvalidated) — are defined once in Limits and error index.

Requirements at a glance

RequirementShapeRuleOn violation
category: "layout" on the widget summarySuiteExactly the string layout. Any other value normalizes to content at resolve timeUnknown string → publish ERROR manifest widget "{slug}" has an unknown "category" (content | layout).
ssr: { "url": … } on the widget summarySuite (layout)An SSR bundle is mandatory. A layout composes its children server-side"ssr": false passes the publish audit (advisory), then fails at render — see SSR
slots[] on the widget summarySuite (layout)Declares the named regionsSee slots[]
A slots object in your zod configSuite (layout)The dashboard has nowhere to store bindings without itSilent: the slot editor renders, edits are dropped on save. advisory
default export in the SSR bundleSuite (layout)The only required SSR exportRuntime failure node, page degraded
registerPluginWidget(…, { category: "layout" })Suite (layout)Records the category for the client pathSilent: the shell renders with no renderedSlots, and on a server-rendered page the client render replaces the server’s slot children. advisory

A layout is not allowed to nest inside another layout, and its shell can never be interactive on a server-rendered page. Both are covered below.

Manifest: category

FieldTypeRequiredRuleOn violation
category"content" | "layout"Optional (Suite) — defaults to contentExactly one of the two literalspublish ERROR: manifest widget "{slug}" has an unknown "category" (content | layout).

At resolve time the rule is summary.category === "layout" ? "layout" : "content" — anything that is not the literal layout silently becomes a content widget, taking slots and pages with it (both resolve to undefined on content widgets).

Rendering a layout through the content path is refused:

// packages/homerunner-renderer/lib/render-utils.tsx — console.warn, then a failure node
[plugin] "plugin:my-suite:frame" is a layout widget — it must render as a layout entry (with slots), not content. Skipping.
// failure-node reason:
plugin layout widget rendered as content — needs a layout entry with slots

Manifest: slots[]

slots is layouts-only. Declare one entry per region your component renders.

// pdp-suite 1.3.3 (the reference suite) — manifest.json, the pdp-frame summary
{
  "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"
}
FieldTypeRequiredRuleOn violation
slotsArray<{name, accepts?, prefill?}>Optional (Suite, layouts only)Must be an array; only on category: "layout"publish ERROR: manifest widget "{slug}" declares "slots" but is not a layout. / manifest widget "{slug}" needs a "slots" list.
slots[].namestringRequired when the entry existsNon-empty, ≤ 64 characters, unique within the widgetpublish ERROR: manifest widget "{slug}" has an invalid or duplicate slot name.
slots[].acceptsstring[]OptionalFilters the dashboard slot picker only. Omitted = ["content"]Never validated — advisory (unvalidated)
slots[].prefillstring[]Optional (0.12.1+)System widget keywords, ≤ 8 per slotpublish ERROR — see prefill

The name check breaks out of the slot loop on the first bad entry, so a manifest with two problems reports one error per audit run — fix and re-run.

A slot the manifest declares is not automatically a slot that renders. What renders is derived from the customer’s stored bindings; see settings.slots and Page assignment.

slots[].accepts

accepts is read by exactly one consumer: the playground’s slot editor. A candidate widget is offered when accepts contains its category (content / layout) or its exact keyword (gallery, plugin:acme:hero). The layout’s own keyword is always excluded, so a layout can never bind another instance of itself.

// src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/PlayGround.tsx
const accepts = slot.accepts?.length ? slot.accepts : ["content"];
const filterFn = (keyword: string) =>
  typeof keyword !== "string" ||
  (keyword !== widget.keyword &&
    (accepts.includes(categoryOf(keyword)) || accepts.includes(keyword)));

Not supported yet. accepts: ["layout"] can never admit anything. The same picker is always rendered with excludeLayouts, which drops every layout-category row before the accepts predicate runs — and even a hand-crafted binding is skipped by both render paths, because layouts cannot nest.

accepts is not validated at publish and is not read by the renderer. Treat it as a hint to the customer, never as a guarantee about what your slot will contain.

slots[].prefill

(widget-core 0.12.1+) prefill lists the system widget keywords a slot expects. When a customer creates the layout, the dashboard binds the feed’s existing widget for each keyword so the layout opens populated instead of empty.

RuleEnforcement
Each entry matches /^[a-z][a-z0-9-]{0,63}$/publish ERROR: manifest widget "{slug}" slot "{name}" has invalid prefill keyword(s): {list} (system widget keywords only, e.g. gallery).
At most 8 entries per slotpublish ERROR: manifest widget "{slug}" slot "{name}" needs a "prefill" list of at most 8 keywords.
plugin: references are rejected by the same regexpublish ERROR (same message). Compose your own widgets with presets instead
At resolve time: malformed entries dropped, duplicates removed, list sliced to 8silent runtime truncation (normalizeLayoutSlots)

What the dashboard does at creation time, in order:

  1. Union every slot’s prefill keywords, in declared order.
  2. Create the ones the feed has no active widget for, with the platform’s own defaults. A keyword this platform cannot create is reported in a toast as {keyword}: not a widget this platform can create and skipped.
  3. Bind the feed’s oldest active widget per keyword (status === false widgets are skipped), in declared order, deduped within a slot.
  4. Merge the result into the new widget’s settings.slots. Slots that resolved to no ids are omitted entirely — the key never appears.

Prefill is creation-time only. It is never re-applied to an existing layout, and the renderer never reads it.

Not supported yet. prefill keywords are shape-validated only. Nothing checks them against the real system-widget keyword list, so a typo publishes cleanly and silently prefills nothing.

The mandatory slots config field

The dashboard’s slot editor is injected into your own config form. For each manifest slot it writes a SlotWidgetArrayContainer of WidgetSelect rows under the form’s slots object field. If your zod config does not declare that field, there is nowhere to store bindings and the customer’s edits are dropped on save.

// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/config.ts
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"),
  // Keys MUST match the manifest summary's slot names, exactly.
  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 const configSchema = zodToManifestSchema(configZod);

Rules:

  • The object keys must equal the manifest slot names, character for character. A key with no matching manifest slot gets no editor; a manifest slot with no key has nowhere to save.
  • Use z.array(z.string()).default([]) per slot and .default({}) on the object. Both defaults matter: the field must survive configZod.parse({}) at creation time.
  • Do not add a uiSchema entry for slots. The dashboard replaces the whole slots key with its own panel: label Slot Configuration, description “Assign the widgets that render inside this layout’s slots”, order: -50 (it sorts to the top). Each row is titled {slot} slot with the description accepts: {list}. See Config schema and UI schema for why a plugin entry replaces a top-level key wholesale.

settings.slots: the stored bindings

Bindings are stored on the layout widget instance, not in the manifest:

// Read by packages/homerunner-widget-core/src/runtime/layout-csr-renderer.tsx (CSR)
// and packages/homerunner-renderer/lib/render-utils.tsx (SSR), out of widget.settings
"slots": {
  "hero":    ["cuid-of-a-widget", "cuid-of-another"],
  "sidebar": ["cuid-of-a-third"]
}

Record<slotName, widgetId[]>. Array order is render order.

  • CSR reads it structurally out of the widget’s settings and resolves each id to its keyword.
  • SSR converts the ids to slot bindings and drops ids whose widget no longer exists on the feed (nothing prunes a slot when a widget is deleted).
  • On an assigned page, the slot names the PDP renders are the keys present in the stored settings.slots — a slot the customer never bound simply does not appear. See Page assignment.

Settings arrive at your component raw — never zod-parsed for plugins, on either path. Parse them yourself; see Settings and options.

The component contract

Import the real type rather than hand-rolling it:

// packages/homerunner-widget-core/src/contracts.ts
export interface LayoutWidgetProps<
  T extends WidgetSchemaType = WidgetSchemaType,
  D extends Record<string, any> | undefined = undefined,
> extends WidgetProps<T, D> {
  /** Pre-rendered React nodes for each slot. Keys are slot names. */
  renderedSlots: Record<string, ReactNode>;
}
// import type { LayoutWidgetProps } from "@homerunner-next/widget-core/contracts";

What each path actually passes:

PropSSR (server-rendered page)CSR (single-snippet embed)
optionsRAW merged settingsRAW merged settings
renderedSlotsRecord<slotName, ReactNode>Record<slotName, ReactNode>
feedIdyesyes
widgetIdyesyes
resolvedThemecookie-derived, effectively always "light" in productionresolved on the client
dataresult of getInitialData, else undefinedalways undefined
widgetTypeabsentpresent
targetabsentabsent

LayoutWidgetProps declares widgetType as required, but the SSR path does not supply it. Reading it during render is a hydration-mismatch trap — treat both widgetType and target as optional in a layout.

renderedSlots differs between the paths for an empty slot:

  • SSR: the key is present only when at least one child actually rendered. An unbound slot, or one whose every binding was dropped, is undefined.
  • CSR: every key of the stored settings.slots is present; an empty slot is [] — truthy, and renders nothing.

Write renderedSlots?.[name] ?? null and never assume a key exists.

// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/widget.tsx
import React, { type ReactNode } from "react";
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { configZod } from "./config";
import "./pdp-frame.css";

export default function PdpFrame(props: {
  options?: Record<string, unknown>;
  renderedSlots?: Record<string, ReactNode>;
}) {
  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>
  );
}

The SSR entry is one line — a layout needs no getInitialData, dehydrateState or getStaticAssets:

// 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";

Those three hooks are still supported on a layout and run before the shell renders, inside the same try/catch. A throw in any of them replaces the whole layout with a failure node. See Component and SSR module.

Registration and mount

// pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/index.tsx
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" });

function doMount() {
  document
    .querySelectorAll<HTMLElement>("[data-hr-widget-container]")
    .forEach((container) => {
      if (container.querySelector('[data-hr-widget="plugin:pdp-suite:pdp-frame"]')) {
        mount(container, {
          widget: PdpFrame,
          widgetType: "plugin:pdp-suite:pdp-frame",
          category: "layout",
        });
      }
    });
}

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", doMount);
} else {
  doMount();
}

registerPluginWidget(plugin, widget, Component, { category: "layout" }) writes

// packages/homerunner-widget-core/src/globals.ts
window.HRPlugins["{pluginSlug}:{widgetSlug}"] = { component, category: "layout" };

category is omitted from the registry entry when you do not pass meta.category.

mount derives an effective category for plugin keywords as config.category ?? getRegisteredWidget(widgetType)?.category. The registry write sits in the same IIFE and therefore always precedes the mount, so a bootstrap that forgets category on the mount() call still routes correctly. Pass it anyway — it is the documented contract and it makes the file readable. See Runtime, mount and the DOM contract for the full MountConfig.

Omitting it in both places is the one genuinely destructive mistake here. Without an effective category, mount treats the shell as a content widget: the SSR no-op guard never fires, and because a layout shell carries no data-hr-ssr-id and no props-registry entry there is nothing to hydrate — so it takes the client-render path and replaces the server’s shell, slot children and all, with an empty client render.

How a layout renders

SSR: the server composes your children

On a server-rendered page the renderer:

  1. Collects every binding across every slot. A binding whose widget is missing from the feed is logged [layout] Slot "{name}" references missing widget {id} and skipped.
  2. Renders all slot children concurrently. Each child is a full island with its own SSR id, its own Declarative Shadow DOM template, its own props-registry entry and its own assets.
  3. Reassembles each slot in binding order into renderedSlots[slotName] = <>{nodes}</>.
  4. Loads your layout’s SSR bundle last, runs the optional data hooks, renders the shell, and unshifts the layout’s CSS and JS in front of every child’s assets.

The shell is emitted as static light DOM:

// packages/homerunner-renderer/lib/render-utils.tsx
<div
  data-hr-widget={widgetConfig.type}
  data-hr-widget-category="layout"
  id={`${widgetConfig.id}:${widgetConfig.feed.id}`}
  {...layoutHostThemeAttrs(/* data-hr-scheme="auto" or data-hr-theme="light|dark" */)}
>
  <LayoutComponent {...props} />   {/* no <template shadowrootmode>, no shadow root */}
</div>

Three consequences that look like bugs and are not:

  • No shadow root, no DSD template. Your layout stylesheet is loaded document-level, at page scope. See Layout CSS.
  • No data-hr-ssr-id and no props-registry entry. Only slot children get those.
  • The client mount is a deliberate no-op. mount returns early when the effective category is layout and the root already has server-rendered children. Running the CSR path there would discard the server’s slots and re-fetch everything client-side.

Not supported yet. Because of the three points above, a plugin layout shell can never be interactive on a server-rendered page: no React tree is ever attached to it. Its slot children each hydrate themselves normally. Put interactivity in a content widget and bind it into a slot.

CSR: the layout composes its own children

In a single-snippet embed the shell does get a shadow root, and LayoutCsrRenderer rebuilds the slot tree at runtime:

  1. Fetch the layout widget + feed, read settings.slots.
  2. Per child id: fetch its widget row to learn its keyword, skip nested layouts, load the child’s IIFE so it self-registers, then wait for registration (hard 10 s timeout, per child: [widget-core] widget "{keyword}" did not register within 10000ms → that slot child stays empty, the rest render).
  3. Render your component with renderedSlots = arrays of childless host divs ([data-hr-widget-container] > [data-hr-widget={keyword}][data-hr-options]).
  4. After commit, imperatively mount each child into its host.

Child script URLs come from config.resolveChildJsUrl or, by default, window.HRWidgetRuntime.resolveChildJsUrl — supplied by the host runtime bundle. Plugin builds carry no URL rules and you never script-tag children yourself.

The layout pushes two things onto every child: the reserved __parentColorScheme key (which only affects children whose own colorScheme is global) and, when the layout resolves one, filter.property. Reading the pushed property is covered in Composing a page.

Failure semantics

What throwsResult
A data hook (getInitialData / dehydrateState / getStaticAssets) on SSRThe layout is replaced by a hidden <div data-hr-widget-error="{keyword}" hidden> breadcrumb with reason layout SSR data prep failed: {message}; the page still responds — an assigned page layout failing this way leaves the page empty — and is marked degraded (60 s TTL). Already-rendered slot children are discarded
The layout component during SSR renderThe whole page render 500s. The shell element is returned as a React node and rendered later in the page-level renderToString, which has no try/catch
The layout component on CSRCaught by WidgetRenderBoundary: data-hr-error="render-failed" on the host plus the shared error state

Parse defensively and default every slot. A content widget that throws costs one widget; a layout that throws costs the customer’s whole page.

Every failure reason a layout can produce, verbatim:

// packages/homerunner-renderer/lib/render-utils.tsx
// Content path — a layout keyword rendered as a plain content entry.
keyword does not resolve against the plugin manifest
plugin layout widget rendered as content — needs a layout entry with slots

// Layout path — every throw is wrapped as `layout SSR data prep failed: {message}`.
layout SSR data prep failed: keyword does not resolve against the plugin manifest
layout SSR data prep failed: "plugin:{slug}:{widget}" is not a layout widget (category: content)
layout SSR data prep failed: plugin layout widgets require an SSR bundle (`ssr: false` is content-only)
layout SSR data prep failed: SSR bundle failed to load from {url}

// A slot child that is itself a SYSTEM layout.
layout-in-layout nesting not allowed

A slot child that is a plugin layout produces no breadcrumb at all — it is dropped with a console line and the rest of the slot renders.

Layouts cannot nest

Refused on both paths, before anything renders:

// packages/homerunner-renderer/lib/render-utils.tsx — SSR, checked against the summary's category
[layout] Plugin layout widgets are not supported in slots yet. Slot "{name}" references "{keyword}". Skipping.

// packages/homerunner-widget-core/src/runtime/layout-csr-renderer.tsx — CSR, checked against the registration
[layout-csr] Skipping nested plugin layout child "{keyword}" — layouts cannot nest.

System layouts are caught separately by their -layout keyword suffix. Nothing in the manifest expresses “this layout may nest”.

Layout CSS

A layout stylesheet must work in two environments:

  • Document level, unisolated, on every server-rendered page (the shell is light DOM and your CSS is appended to the page <head>).
  • Inside a shadow root, on a CSR embed.

The rules that follow from that:

RuleWhy
Namespace every selector with a class prefix (.pdpf, .pdpf-region)On an SSR page your rules apply to the customer’s whole document
Never write a :host-only rule for the shell:host matches nothing in light DOM, so the rule is inert on every server-rendered page
Never paint a background on the shellProduct rule: the host page owns the page canvas. The platform’s own stylesheet excludes [data-hr-widget-category="layout"] from its background paint on both paths

Your layout’s CSS is unshifted ahead of the slot children’s stylesheets in the page’s CSS list, so a child’s rules win on source order at equal specificity.

/* pdp-suite 1.3.3 (the reference suite) — src/widgets/pdp-frame/pdp-frame.css */
/* 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; }

@media (max-width: 860px) {
  .pdpf {
    grid-template-columns: 1fr;
    grid-template-areas: "hero" "sidebar" "main" "bottom";
  }
  .pdpf-sticky-rail .pdpf-sidebar { position: static; }
}

Plugin layouts do not inherit the platform’s slotSpacing / gap settings — declare your own spacing fields. See Styling and theming.

pages and page assignment

(widget-core 0.12.0+) pages declares the server-rendered pages a layout may be assigned to. Being assignable is an explicit declaration, never an accident.

FieldTypeRequiredRuleOn violation
pagesPluginPageKey[]Optional (Suite, layouts only)Non-empty; every entry one of the six keys below; only on category: "layout"publish ERROR: manifest widget "{slug}" declares "pages" but is not a layout. · manifest widget "{slug}" needs a non-empty "pages" list. · manifest widget "{slug}" has unknown page(s) in "pages": {list} (pdp | listings | checkout | confirmation | collection | misc).
// packages/homerunner-widget-core/src/manifest.ts
export const PLUGIN_PAGE_KEYS = ["pdp", "listings", "checkout", "confirmation", "collection", "misc"] as const;
KeyMeaningAssignable today
pdpProperty detail pageYes
listingsListings pageYes
checkoutCheckout pageYes
confirmationConfirmation pageYes
collectionA collection route’s layoutNo — reserved vocabulary
miscStandalone embeds onlyNo, by definition

At resolve time unknown keys are dropped and an absent or empty list becomes ["misc"] — silent, not an error. Content widgets always resolve pages: undefined.

Not supported yet. collection is declared vocabulary with no assignment surface. The collection-route picker only offers instances of the system listings-layout keyword, so a plugin layout declaring collection cannot be chosen anywhere.

What the customer does

Feed → Page URLsPage layouts: one row per server-rendered page, each a picker of Automatic plus every eligible layout on the feed. Your layout appears there when its plugin is approved and enabled on the feed, the widget is not kill-switched, and its summary declares that page. The assignment is stored on the feed at:

// src/lib/homerunner/reverse-proxy-routes.ts + packages/homerunner-renderer/lib/page-layouts.ts
feeds.additional_info.reverseProxy.pageLayouts = { pdp?, listings?, checkout?, confirmation? }

Absent or null means Automatic (the feed’s base system layout for that page).

What the renderer does with an assignment

An assigned plugin layout renders with backfill: falsestored bindings only. Unlike a system layout, nothing is backfilled from the platform’s default slot map, so a slot the customer never bound simply does not appear. Bindings pointing at deleted widgets are dropped first.

The slot names the page renders differ by page, and this is the sharpest edge in the whole contract:

PageSlot names renderedExtra rule
pdpThe keys of the stored settings.slots — your own vocabularyRenders every binding in every slot
listings, checkout, confirmationFixed to ["content"]Only slots.content is passed to your layout, and the page 404s when it resolves to no live widget

So a layout that declares listings, checkout or confirmation must name one of its slots exactly content and the customer must bind at least one widget into it. Every other slot you declare receives nothing on those three pages. Their handlers return {"error":"Explorer widget not found for this feed"}, {"error":"Checkout widget not found for this feed"} and {"error":"Confirmation widget not found for this feed"} respectively, with HTTP 404 — the page does not degrade, it fails.

For the property page, mirror the system layout’s slot vocabulary — hero, main, sidebar, bottom — so the platform’s own widgets drop straight in.

Degrading to Automatic

Anything the renderer cannot honour falls back to Automatic and never 404s the page. Each reason is logged once:

// packages/homerunner-renderer/lib/page-layouts.ts
[page-layouts] feed {feedId} {page}: {reason} — using Automatic

// reasons:
assigned layout {id} is not on the feed (deleted or paused)
assigned widget {id} ({keyword}) is not a layout for "{page}"
assigned plugin layout {id} ({keyword}) is not live (plugin suspended, widget disabled, or not enabled on the feed)
assigned plugin widget {id} ({keyword}) does not declare the "{page}" page

Not supported yet. The dashboard and the renderer disagree about deprecated. The dashboard drops a deprecated layout from the picker and flags a stored assignment as an error (“{display name}” cannot render the property page.), but the renderer does not check deprecated at all — an already-assigned deprecated layout keeps being server-rendered. Deprecating a layout does not take it off a page; ask the customer to switch back to Automatic.

Presets

(widget-core 0.11.0+) A preset is a root-level, one-click composition: the widget picker offers it as its own entry that creates the layout, its children, and the slot bindings — atomically.

// 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"]
    }
  }
]
FieldTypeRequiredRuleOn violation
presetsArray<{name, layout, slots}>Optional (Suite)Array, at most 8 entriespublish ERROR: manifest "presets" must be a list of at most 8 entries.
presets[].namestringRequiredNon-emptypublish ERROR: each manifest preset needs a "name".
presets[].layoutstringRequiredA declared widget slug of this plugin with category: "layout"publish ERROR: preset "{name}" must reference a declared layout widget.
presets[].slotsRecord<string, string[]>RequiredAn object of arrayspublish ERROR: preset "{name}" needs a "slots" object. / preset "{name}" has an invalid slot binding shape.
presets[].slots[*][]stringEither a declared non-layout widget slug of this plugin, or a system widget keyword (/^[a-z][a-z0-9-]{0,63}$/)publish ERROR: preset "{name}" references "{child}" which is neither a declared content widget nor a system widget keyword.

A single slot array may freely mix your own slugs with system keywords, as PDP Starter does.

What one click creates, in order:

  1. System-keyword children: the feed’s oldest active widget per keyword, creating the missing ones with platform defaults first. Never duplicated per preset.
  2. One instance of each referenced own content slug, display-named {typed name} — {widget name} (the name the customer entered for the preset, then the widget’s own name), with settings = schema defaults + the feed’s branding + filter.feed_id.
  3. The layout, with settings = { ...layoutDefaults, slots } mapping each preset slot name to the created/ensured ids.
  4. A redirect to the new layout’s playground.

A failure part-way through best-effort deletes the children it just created, then surfaces the original error.

In the picker the entry is labelled {plugin name}: {preset name}, described “One click creates the “{layout}” layout with its slot widgets bound.“, and carries a chip listing the layout and its children. See Composing a page.

Not supported yet. Preset slot keys are never validated against the layout’s declared slot names. A typo (sidbar) publishes cleanly and creates a binding your layout never renders — the widget is created, bound, and invisible. Check your keys by hand against slots[].name.

The customer-facing surface

  • The widget picker lists every non-deprecated widget of an enabled plugin, layouts included. A layout’s description gets (layout — hosts other widgets in its slots) appended and it takes a Layers glyph instead of the Puzzle glyph. Per-widget icon falls back to the plugin icon, then to the glyph.
  • deprecated: true removes the widget from the picker and disables Create widget on the plugin page. Existing placements keep rendering — see Manifest: widget summary.
  • Slot bindings are edited in the layout’s playground under Slot Configuration.

Known gaps, collected

Not supported yet. Each of these is repeated in context above.

  • accepts: ["layout"] can never match: the slot picker excludes layouts first, and both render paths refuse a nested layout.
  • Preset slot keys are not validated against the layout’s declared slot names.
  • prefill keywords are shape-validated only, never checked against the real system-widget keyword list.
  • A plugin layout on a server-rendered page can never be interactive — no data-hr-ssr-id, no registry entry, and the client mount is a no-op.
  • collection is reserved vocabulary with no assignment surface.
  • The renderer keeps server-rendering an assigned layout that is deprecated, which the dashboard reports as an error.
  • No local harness composes a layout’s slots. dev:ssr -- --widget {layout} previews the shell with every slot empty, because renderPluginSSR builds the props itself and passes no renderedSlots (widget-core 0.12.2). See Previewing SSR locally.

Checklist

  • Suite manifest (widgets[]), not the v1 shape.
  • "category": "layout" on the summary.
  • "ssr": { "url": … } — never false.
  • slots[] with unique names ≤ 64 chars; prefill ≤ 8 system keywords per slot.
  • pages only if you want the layout assignable; include a slot named content if you declare listings, checkout or confirmation.
  • A slots zod object in config.ts whose keys match the manifest slot names exactly.
  • ssr-entry.ts = export { default } from "./widget";.
  • registerPluginWidget(…, { category: "layout" }) and mount(…, { category: "layout" }).
  • parseWidgetConfig(configZod, props.options ?? {}) in the component.
  • renderedSlots?.[name] ?? null everywhere — never assume a key exists.
  • Class-namespaced CSS, no :host, no background on the shell.
  • The component cannot throw. A layout throw 500s the customer’s page.

A step-by-step build of exactly this is in Add a layout widget.

Config schema and UI schema

Two JSON artifacts drive the settings form a customer sees in the dashboard:

  • configSchema — a JSON Schema object describing every setting your widget accepts.
  • uiSchema — an optional symbolic map naming which form control renders each field.

You never hand-write either. You write one zod schema that extends the shared base widgetSchema, and export zodToManifestSchema(configZod). Everything on this page is a consequence of that round-trip.

Enforcement levels used below (publish ERROR, publish warning, silent runtime truncation, advisory (unvalidated)) are defined once in limits-and-errors.md. Every rule on this page applies to both manifest shapes unless a row or heading says Single-widget or Suite.

Where the two artifacts live

FieldTypeRequiredRuleOn violation
configSchema (manifest root)JSON Schema objectSingle-widget: expectedThe dashboard reads it directly off the cached root manifest.publish warning: manifest has no configSchema — was this zipped after `npm run build`? The dashboard form will be empty.
uiSchema (manifest root)objectSingle-widget: optionalSymbolic UI for your own fields only.advisory (unvalidated)
widgets[].manifestconfined relative pathSuite: expectedPoints at widgets/{slug}.manifest.json, which carries that widget’s configSchema + uiSchema. Field rule lives in widget-summary.md.publish ERROR if declared and absent from the built zip
configSchema / uiSchema on a suite ROOTSuite: never readNothing reads them once widgets[] is non-empty. Ship them per widget.silently ignored

In a suite, every widget owns its own src/widgets/{slug}/config.ts. See manifest-shapes.md for how the platform tells the shapes apart.

The pipeline

# traced through packages/homerunner-widget-core/src/plugin-schema.ts,
# packages/create-hr-plugin/template/scripts/inject-schema.mjs,
# src/lib/homerunner/plugin-widgets.ts and src/lib/plugin-ui-adapter.tsx

src/config.ts
  widgetSchema.extend({ ...your fields })            zod, in your project
        │
        ├─ zodToManifestSchema(configZod)            widget-core /plugin-schema
        │      → JSON Schema (refs inlined)
        │
        └─ uiSchema: PluginUiSchema                  string component names
        │
  scripts/inject-schema.mjs (yours, not the SDK's)
        → manifest.configSchema / manifest.uiSchema  (single-widget)
        → widgets/{slug}.manifest.json               (suite)
        │
  dashboard: @n8n/json-schema-to-zod                 JSON Schema → zod, in the browser
        + resolvePluginUiSchema()                    names → React components
        → the same <Form> system widgets use
        │
  autosaved to central as opaque JSON
        │
  props.options at runtime — RAW, unparsed, on BOTH server and client
        → parseWidgetConfig(configZod, props.options)

The round trip through JSON Schema is lossy. zodToManifestSchema runs zodToJsonSchema(schema, { $refStrategy: "none" }) and then strips $schema, $defs, definitions, $ref and the boolean additionalProperties: false marker, while preserving schema-valued additionalProperties so z.record(...) survives the trip back to zod. It recurses only into properties, items and additionalPropertiesanyOf / allOf / oneOf subtrees pass through untouched. What survives is tabulated below.

The base schema: widgetSchema

Every widget, first-party or plugin, extends the same base. It has exactly nine top-level fields, in this order. All are defaulted, so the emitted JSON Schema carries no root required array.

FieldShapeDefaultWho renders it
spacing{margin, padding, width, maxWidth}, each side {value, unit}margin 0 auto (top/bottom 0px, left/right auto), padding all 0px, width {0,"auto"}, maxWidth {100,"%"}Dashboard: one compound Spacing control. You: apply resolveWidgetSpacingStyle(options) to your outer element.
colorScheme"light" | "dark" | "auto" | "global""global"Dashboard: labelled select (Global (inherit from feed)). Resolved before you see it.
filter{feed_id?: int, platform?: string|null, property?: string}.optional()noneDashboard: Filter Settings panel; feed_id is rendered but hidden.
lightModeColors{accent: string}#f59e0cDashboard: colour picker, hidden when colorScheme === "dark". You: apply the colour.
darkModeColors{accent: string}#f59e0cDashboard: colour picker, hidden when colorScheme === "light". You: apply the colour.
font{size: number, family: string}{size: 16, family: "Inter"}Dashboard: size input + Google-font picker. You: apply both.
language{useAutoDetectedLocale: boolean, locale: string}{true, "en_US"}Dashboard: toggle + locale picker (picker hidden while auto-detect is on).
section{show: boolean, title: string|null}{show: false, title: ""}Dashboard: Section Header panel. You: render the heading.
customCssstring | null""Dashboard: CSS editor. You: render it.

There is no width field. spacing replaced the old width: {maxWidth, unit} pair (widget-core 0.9.0+ — see ../get-started/01-install-and-versions.md for which versions you can actually install). Old saved values are lifted onto spacing automatically by parseWidgetConfig. A plugin whose own schema still declares width keeps a compatibility Width Settings panel; a plugin that does not declare width never renders that entry.

Not supported yet. The platform applies none of customCss, font.size, font.family or the accent colours for you — not on the server and not in mount(). They arrive in options and your component must render them. See ../recipes/styling-and-theming.md.

Skipping the base is a publish warning, checked on the built zip:

# src/lib/plugin-zip.ts — warnIfSchemaSkipsBase()
{label}'s configSchema doesn't extend the base widgetSchema (no colorScheme/spacing) —
standard theme + spacing controls won't appear. Build config.ts as widgetSchema.extend({...})
and export zodToManifestSchema(configSchema).

{label} is the plugin for a single-widget manifest and widget "{slug}" for each suite widget. The check is literally “does properties contain both colorScheme and spacing”.

Writing config.ts

// packages/create-hr-plugin/template/src/config.ts — the scaffold, verbatim
import { z } from "zod";
import { widgetSchema } from "@homerunner-next/widget-core/schema";
import {
  zodToManifestSchema,
  type PluginUiSchema,
} from "@homerunner-next/widget-core/plugin-schema";

export const configZod = widgetSchema.extend({
  title: z.string().default("My Widget").describe("Widget title"),
});

/** Symbolic UI for plugin-specific fields only. */
export const uiSchema: PluginUiSchema = {
  title: { component: "TextInput" },
};

/** JSON Schema shipped in `manifest.configSchema`. */
export const configSchema = zodToManifestSchema(configZod);

export type PluginConfig = z.infer<typeof configZod>;

Rules that are not enforced anywhere but that you will regret ignoring — all advisory (unvalidated):

  • .default(...) every field. A field with no default has no seed value, and the form live-validates on mount, so the customer opens the panel to a red error they did not cause.
  • .describe(...) every field. The string becomes JSON Schema description and renders as the field’s help text. On an object it also becomes the auto-generated panel’s description.
  • Use the narrowest zod type. z.number().int().min(1).max(20) emits {"type":"integer","minimum":1,"maximum":20}, which the number input honours.
  • Group with z.object({...}).default({...}). Object fields become panels automatically.

Reading options back: parseWidgetConfig

props.options is the raw merged settings blob on both the server and the client. Nothing schema-parses it for you — plugins are handed the unparsed object in the component, in getInitialData, in dehydrateState and in getStaticAssets. Parsing is your job, in every one of those places:

// packages/create-hr-plugin/template/src/widget.tsx — the scaffold, verbatim
const options = React.useMemo(
  () => parseWidgetConfig(configZod, props.options) as PluginConfig,
  [props.options],
);

parseWidgetConfig(schema, options, pre = liftLegacySpacing) does four things in order:

  1. stripNullSettingLeaves — drops a null or "" leaf only when that leaf’s own schema rejects it. Central runs Laravel’s ConvertEmptyStringsToNull, so any untouched optional field can come back null; older blobs carry "" on enum and number fields. A leaf that legitimately accepts null (.nullable() / .nullish()) keeps it, and "" stays on a plain z.string(). An empty array on a record- or object-typed field reads as {} (PHP serialises a cleared map as []). Anything else invalid — "abc" on a number — is left in place to fail loudly.
  2. coerceScalarSettingLeaves — a string leaf whose schema is z.number() or z.boolean() is coerced when it parses unambiguously (font.size: "16"16).
  3. pre — by default liftLegacySpacing, which folds a legacy width: {maxWidth, unit} (and top-level margin / padding) onto spacing without overwriting an existing value.
  4. schema.parse(...) — applies your defaults and throws on anything still invalid.

A throw here blanks the widget: on the client it is caught by the render boundary and the host gets data-hr-error="render-failed"; on the server a content widget is dropped from the page while a layout shell takes the whole page down. See component-and-ssr-module.md for the failure semantics and ../concepts/settings-and-options.md for where the raw blob comes from.

Not supported yet. There is no server-side validation of stored settings. The dashboard validates live for display only and autosaves the raw form value on every keystroke, even while invalid; central stores settings as opaque JSON and replaces it wholesale. Your parseWidgetConfig call is the only real gate.

What survives the manifest round-trip

Verified by running zodToManifestSchema@n8n/json-schema-to-zod (the exact pair the dashboard uses) over each construct. Enforcement for this whole section is advisory (unvalidated) — no audit inspects which zod constructs you used. Everything below fails in the customer’s browser, after publish, or not at all.

Safe

zod constructEmitted JSON SchemaComes back as
z.string(){"type":"string"}ZodString
z.number(), z.number().int().min(1).max(10){"type":"integer","minimum":1,"maximum":10}ZodNumber
z.coerce.number(){"type":"number"}ZodNumber (the coercion is dropped, which is fine — see step 2 of parseWidgetConfig)
z.boolean(){"type":"boolean"}ZodBoolean
z.enum([...]){"type":"string","enum":[...]}ZodEnum
z.object({...}), nested, .default({...}){"type":"object","properties":{...},"default":{...}}ZodDefault<ZodObject>
z.array(z.string()), z.array(z.object({...})){"type":"array","items":{...}}ZodArray
z.array(z.enum([...])){"type":"array","items":{"type":"string","enum":[...]}}ZodArray<ZodEnum> → multi-select
z.record(z.string(), z.string()){"type":"object","additionalProperties":{"type":"string"}}ZodRecord
.optional() / .nullable() / .nullish(){"type":["string","null"]} for nullishrenders correctly
.default(v) at any depthdefaultZodDefault
.describe(s)descriptionfield help text
.url() / .email()format: "uri" / "email"ZodString (no client-side format check)
.regex(re)patternZodString, and the pattern still rejects on validate
z.string().min(3).max(9)minLength / maxLengthZodString, still enforced

Degrades silently

Nothing warns. The form renders, the customer edits, and the result is wrong.

zod constructWhat actually happens
z.union([A, B])Emits anyOf. Both the default-seeder and the renderer take the first non-null branch, so only branch A is ever editable — while the schema still validates every branch, so a value shaped like branch B saves fine and then shows the wrong controls.
.refine() / .superRefine()Erased by zodToJsonSchema. Cross-field rules are not enforced in the dashboard at all — only in your own parseWidgetConfig call at runtime, where a failure blanks the widget instead of showing a form error.
z.record(z.enum([...]), z.string())Emits propertyNames, which the browser-side converter drops. The key constraint is lost; values are still typed.
z.record(z.string(), z.number())Round-trips as a record, but the key/value editor writes strings, so every value the customer types fails your runtime parse. Keep record values z.string().
z.date()Emits {"type":"string","format":"date-time"} and returns as ZodString — a plain text input, and your runtime parse then rejects a string where it expects a Date.
.catchall(...) / .passthrough()The open-key behaviour is lost on the way back; extra keys are dropped by the reconstructed closed object.

Breaks the whole panel

These make the dashboard’s default-value walker throw Unsupported schema type: [object Object]. That call is unguarded in three places in the plugin playground’s render, so the entire config page crashes — you do not get a broken field, you get no page.

zod constructRound-trips toWhy it throws
z.any(){}ZodOptional<ZodAny>ZodAny is not in the walker’s type list.
z.unknown(){}ZodOptional<ZodAny>same
z.literal("x"){"type":"string","const":"x"}ZodLiteralsame
z.tuple([...]){"type":"array","minItems":n,"maxItems":n,"items":{...}}ZodArray<ZodAny>the element type walks into ZodAny
z.intersection(A, B)allOfZodIntersectionsame
z.discriminatedUnion("t", [...])anyOf, first branch carrying a ZodLiteral discriminatorthe discriminator is a literal, so the first branch throws

Not supported yet. There is no guard around the walker and no error message — the panel simply does not render. Never ship any of the six constructs above. Model a variant with a z.enum() discriminator plus flat optional fields and a showIf per group.

The uiSchema

A manifest is JSON, so it cannot carry React components. You name them:

// property-picks/src/config.ts — a published plugin
export const uiSchema: PluginUiSchema = {
  title: { component: "TextInput", label: "Title" },
  limit: { component: "NumberInput" },
  showReviewQuote: { component: "Checkbox" },
  translations: {
    ui: { layout: "panel", label: "Translations" },
    viewDetails: { component: "Translations", label: "View details" },
    perNight: { component: "Translations", label: "/ night" },
  },
};

The types, from @homerunner-next/widget-core/plugin-schema:

// packages/homerunner-widget-core/src/plugin-schema.ts
export type PluginUiField =
  | {
      label?: string;
      description?: string;
      component?: /* one of the twelve tokens below */ string;
      optionLabels?: Record<string, string>;
      showIf?: { field: string; equals: unknown };
    }
  | PluginUiSchema;

export interface PluginUiSchema {
  [key: string]: PluginUiField | PluginUiLayoutSpec | undefined;
  ui?: PluginUiLayoutSpec;
}

export interface PluginUiLayoutSpec {
  layout?: "panel" | "collapsible";
  label?: string;
  description?: string;
  showIf?: { field: string; equals: unknown };
}

(component is a closed union of the twelve string literals in the real declaration; it is widened here only so the comment fits.)

PluginUiLayoutSpec has no fields key. A panel’s children are siblings of its ui key, mirroring the zod object’s shape. (A legacy {layout, label, fields: {...}} node is auto-normalised to the canonical form before the tree walk, so old manifests keep working. Do not write it in new code.)

Field keys the adapter honours

Exactly five. Everything else on a field entry is dropped or turned into an inert nested node.

KeyTypeEffect
labelstringForm label.
descriptionstringHelp text under the field; when omitted the zod .describe() text is used.
componentone of the 12 tokens belowSubstitutes the leaf renderer. An unrecognised name is silently ignored and the zod-type default renders.
optionLabelsRecord<string,string>Value → display label. Meaningful on Select and multi-selects.
showIf{field, equals}Conditional visibility, see below.

Not supported yet. order, ui:group, placeholder and every other key are ignored. pdp-suite’s shipped {"ui:group": "Content"} entries are no-ops — they produce an empty nested node and change nothing. Plugins cannot influence field order; see Ordering.

Component tokens

Twelve names resolve. Any other string is ignored.

TokenRendersPair with
TextInputsingle-line text inputz.string()
Textareamulti-line text areaz.string()
NumberInputnumeric input honouring minimum/maximumz.number()
Checkboxtogglez.boolean()
Selectdropdown built from the enum’s valuesz.enum([...]) only
ColorPickerhex colour pickerz.string()
FontSelectGoogle-font family pickerz.string()
LanguageSelectlocale pickerz.string()
PlatformSelectthe feed’s connected platforms, by UUIDz.string().nullish()
PropertySelectthe feed’s properties, by slugz.string()
WidgetSelect (widget-core 0.12.0+)the same widget combobox layout slots use; the stored value is a widget id, and a None row clears itz.string().optional()
Translationslocale-keyed string editorz.record(z.string(), z.string())

PlatformSelect, PropertySelect and WidgetSelect fetch the feed’s own data. WidgetSelect takes its feed from the form’s filter.feed_id, which the base filter block supplies and widget creation seeds — if you strip filter out of your schema, the combobox has no feed and stays empty.

Component ↔ zod pairing

The form dispatches on the zod type first and only then substitutes uiSchema.component into that type’s renderer slot. A token therefore receives the props of the branch the zod type routed to, not the props it was designed for.

MismatchResult
component: "Select" on a non-enum (z.string(), z.number())The enum renderer is handed options === undefined and throws on options.map. The panel crashes. Only the enum branch supplies options.
component: "Translations" on a non-recordThe locale editor is handed a non-object value, renders nonsense rows, and saves a shape your schema will reject.
Any token on z.object({...})Never renders — the object branch ignores Component entirely. The only effect is that the automatic panel is skipped, so the object’s children render flat instead of behind a row.
Any token on z.array(z.enum([...]))Substituted into the multi-select slot and handed options plus an array value.
Unrecognised tokenIgnored; the zod-type default renders.

Nothing validates any of this at publish time — advisory (unvalidated). Open the widget’s config panel in the dashboard before you submit.

The control you get with no uiSchema entry at all

zod typeControl
z.string()text input
z.number()number input
z.boolean()toggle
z.enum([...])dropdown
z.array(z.enum([...]))multi-select
z.array(<anything else>)repeater rows with add / remove
z.record(...)key/value rows with an explicit Save button
z.object({...})a nested panel (auto-generated)

Item-level UI for an array is declared under an element key on the array’s entry — the adapter forwards nested keys verbatim, and the array renderer reads element:

// derived from src/lib/plugin-ui-adapter.tsx + src/components/zod-form/libs/core/form.tsx
facts: { element: { component: "TextInput" } },

That is a structural passthrough rather than a declared feature — treat it as advisory (unvalidated).

Panels

Any object-typed property is wrapped in a nested panel automatically, recursively, whether or not you declare one. A panel renders as a clickable summary row; clicking it slides a stacked detail view in over the config sidebar. It is not an inline bordered fieldset and it is not a collapsible chevron.

  • Panel title = your ui.label, else the JSON Schema title, else the raw key. zod never emits title, so an undeclared panel is titled translations, display, slots.
  • Panel description = your ui.description, else the object’s .describe() text.
  • A field with an explicit component is never auto-wrapped, which is how Translations on a record (also type: "object") keeps its own editor.
  • The ui block is only read as a layout spec when it contains the layout key. A ui: { label: "Translations" } with no layout is discarded entirely — you lose the label and any showIf on it. Always write ui: { layout: "panel", label: "..." }.

Not supported yet. layout: "panel" and layout: "collapsible" are interchangeable. The value is only used as a marker that the node is a layout spec; it is never read, and both build the identical slide-in panel.

showIf

showIf: {field, equals} compiles to data => data?.[field] === equals, and that predicate is always invoked with the whole root form value.

RuleBehaviour
field must be a top-level key of your config. A dotted path like language.useAutoDetectedLocale is a plain property lookup, always undefined, so the field never shows.fails silently
Comparison is strict ===. It cannot match an object or an array. Compare against a string, number or boolean.fails silently
The same shape works on a panel’s ui block to hide the whole group.works
Hidden values are preserved: stripped from a draft before validation (so an invisible field cannot block the form with an unfixable error), grafted back after a successful parse, and never stripped from the autosave at all.works
// pattern verified against src/lib/plugin-ui-adapter.tsx + resolve-ui-schema-conds.ts
export const uiSchema: PluginUiSchema = {
  variant: { component: "Select", optionLabels: { grid: "Grid view", carousel: "Carousel" } },
  columns: { component: "NumberInput", showIf: { field: "variant", equals: "grid" } },
  advanced: {
    ui: { layout: "panel", label: "Advanced", showIf: { field: "mode", equals: "expert" } },
    retries: { component: "NumberInput", label: "Retries" },
  },
};

Ordering

Field order is the configSchema property order, stable-sorted by a numeric order that only the dashboard’s own base entries carry. widgetSchema.extend({...}) appends, so your fields always land after the base chrome:

# order values from src/app/(protected)/feeds/[id]/widgets/config/common-ui-schema.tsx
Section Header  (-55)
Filter Settings (-30)
spacing, colorScheme, lightModeColors, darkModeColors, font, language   (0, schema order)
…your fields…                                                          (0, schema order)
Custom CSS      (100)

Not supported yet. Plugins cannot reorder anything. PluginUiField has no order key and the adapter drops one if you add it, so uiSchema key order has no effect whatsoever. To move a field, move it in the zod schema.

The default label is the full dotted path

Every leaf renders label = uiSchema.label ?? name, where name is the full dotted form path. A field inside a panel with no label shows translations.viewDetails, not viewDetails. There is no humanisation. Declare label on every field you nest.

Never re-declare a base key

The adapter builds base = { ...commonWidgetUiSchema, width: <legacy panel> } and then, for each top-level key of your uiSchema, does base[key] = convert(value) — a wholesale replacement, not a deep merge. Re-declaring any of these destroys that panel’s layout, its components, its conditions and its order:

spacing · colorScheme · lightModeColors · darkModeColors · font · language · section · customCss · filter · width

Re-declaring filter, for example, loses PlatformSelect, PropertySelect and the hidden feed_id input. Re-declaring spacing loses the compound spacing control. If you genuinely need a custom leaf beside the built-ins, you must re-declare the entire panel and rebuild every entry yourself. A root ui key in a plugin uiSchema is skipped entirely.

Enforcement: advisory (unvalidated) — nothing warns, in the dashboard or at publish.

Layout widgets: the mandatory slots field

A layout widget must declare a slots object in its zod schema, with keys that match the manifest’s declared slot names exactly:

// pdp-suite/src/widgets/pdp-frame/config.ts
export const configZod = widgetSchema.extend({
  stickyRail: z.boolean().default(true).describe("Keep the sidebar rail pinned"),
  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"),
});

Do not write a uiSchema entry for slots. When the resolved widget has category: "layout" and a non-empty manifest slots[], the dashboard discards whatever you declared and injects its own panel — label Slot Configuration, order: -50, one row group per declared slot titled {name} slot and described accepts: {list}, each row a widget combobox filtered by that slot’s accepts. Empty rows are stripped on save.

Without the zod field there is nowhere to store the bindings, so the customer’s slot picks silently vanish. The stored shape is settings.slots: Record<slotName, widgetId[]>. Everything else about slots, accepts, prefill, presets and page assignment lives in layouts.md.

Not supported yet. Plugin layouts do not inherit slotSpacing or gap — those live on a private internal schema. Declare your own if you want per-slot spacing; the builders in sdk-exports.md (marginBoxSchema, paddingBoxSchema, widthSchema, maxWidthSchema, DEFAULT_SLOT_SPACING) let you rebuild the same shape.

Translations

Declare each translatable string as z.record(z.string(), z.string()) — locale code → text — and pair it with component: "Translations".

// property-picks/src/config.ts — a published plugin
translations: z
  .object({
    viewDetails: z
      .record(z.string(), z.string())
      .default({ en_US: "View details", fr_FR: "Voir les détails", es_ES: "Ver detalles" })
      .describe("View-details CTA"),
    perNight: z
      .record(z.string(), z.string())
      .default({ en_US: "/ night", fr_FR: "/ nuit", es_ES: "/ noche" })
      .describe("Price unit"),
  })
  .default({
    viewDetails: { en_US: "View details", fr_FR: "Voir les détails", es_ES: "Ver detalles" },
    perNight: { en_US: "/ night", fr_FR: "/ nuit", es_ES: "/ noche" },
  })
  .describe("Translations"),

Editor behaviour the customer sees, and you must design around:

  • Edits are held in local state and only reach the form when the customer presses the editor’s own Save button. A customer who edits and navigates away loses the edit.
  • Removing a locale row raises a browser confirm() dialog.
  • The editor derives its i18n key from the last segment of the field path, so translations.viewDetails keys on viewDetails.

At runtime:

// property-picks/src/widget.tsx — a published plugin
const { detectedLocale } = useLocaleDetection(
  options.language.useAutoDetectedLocale,
  options.language.locale,
);
const { _t } = useTranslation(options.translations ?? {}, detectedLocale);
// …
_t("viewDetails", "View details")

_t(key, defaultValue = key) resolves store[key][currentLocale] || store[key]["en_US"] || defaultValue — an empty string falls through, so a blank row is treated as missing. useLocaleDetection seeds state with the configured locale so the server and the client’s first render agree, then switches to the browser locale one tick after mount when auto-detect is on. Both come from @homerunner-next/widget-core/utils.

Not supported yet. The plugin save path runs only the empty-slot stripper, not the blank-row strippers system widgets get — so a locale row the customer blanks is persisted forever as "". It is harmless as long as you always pass a defaultValue to _t and always keep a non-empty en_US entry in your schema default. Never rely on _t(key) alone.

Per-widget sub-manifests

(widget-core 0.11.0+, suites only.) Your project’s scripts/inject-schema.mjs writes one JSON file per widget and stamps the reference back onto the summary:

// packages/create-hr-plugin/template/scripts/inject-schema.mjs — the multi-widget branch
const { configSchema, uiSchema } = await import(pathToFileURL(configPath).href);
const sub = { slug, configSchema };
if (uiSchema) sub.uiSchema = uiSchema;
fs.writeFileSync(subPath, JSON.stringify(sub, null, 2) + "\n");
widget.manifest = `widgets/${slug}.manifest.json`;

That script lives in your project, not in the SDK. Only configSchema and uiSchema are ever read; slug is decorative (pdp-suite’s own copy omits it).

RuleEnforcement
Sub-manifests are committed to git and shipped in both zips. Nothing regenerates them for the reviewer beyond your own build.publish ERROR if referenced and absent
Only the dashboard fetches them, from the CDN, resolved against the plugin’s manifest_url. The renderer never does — it caches the root manifest only.
The fetch is bounded by AbortSignal.timeout(5000) and cached per URL for the tab’s life; a rejected fetch is evicted so the next open retries.silent degrade
A fetch failure, a missing manifest reference, or a sub-manifest with no configSchema all degrade to the schemaless state — the widget still previews from its raw settings blob, but there is no form.silent degrade
A declared sub-manifest missing from the built zip.publish ERROR: {path} ({label}'s sub-manifest) is missing from the zip or invalid JSON.
A sub-manifest present but carrying no configSchema.publish warning: {path} has no configSchema — {label}'s dashboard form will be empty.

Operator-visible strings, so you can recognise a screenshot:

# src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/PlayGround.tsx
Plugin "{slug}" not enabled for this feed.
Widget "{keyword}" is not declared by plugin "{slug}".
Loading plugin manifest...
No configSchema for this widget. Ask the plugin developer to ship one via
zodToManifestSchema(widgetSchema.extend({...})) in the widget's config.ts.

If your JSON Schema is present but the browser cannot rebuild a z.ZodObject from it, the failure is caught, logged as [plugin-playground] jsonSchemaToZod failed: and the same amber “No configSchema” banner appears.

Publish-time schema checks

All four run against the built zip, at publish, and are quoted verbatim above:

CheckEnforcement
Single-widget manifest has no configSchemapublish warning
Suite sub-manifest has no configSchemapublish warning
properties lacks colorScheme or spacingpublish warning
Declared sub-manifest missing or invalid JSONpublish ERROR

The audit also records each widget’s configSchema property keys so the reviewer can diff them against your previous version.

Not supported yet. The built-zip audit runs at publish, on the reviewer’s side. Its warnings are shown to the reviewer, never to you — you can be told your submission is fine and only learn about a schema warning if the reviewer relays it. Nothing in the source-zip audit you run in your browser looks at configSchema at all. See packaging-and-publishing.md and ../ship/preflight-and-submit.md.

Defaults at widget creation

When a customer creates a widget from your plugin, the seed settings are:

  1. Every top-level default in your configSchema, recursing into object-typed properties that have no default of their own.
  2. The feed’s branding, minus colorScheme — so the widget stays on the global inherit placeholder and follows the feed live.
  3. filter.feed_id = <feed id>, always, merged over whatever your schema’s filter default held.

A sub-manifest fetch failure at this point degrades step 1 to {} — the widget is created carrying only the branding and filter.feed_id, and your schema’s own defaults are what parseWidgetConfig supplies at render time.

In the editor the form is seeded with your defaults merged under the stored settings. Because the whole seeded object is autosaved on the first edit, your defaults become explicitly stored values the moment a customer touches anything — a later change to a schema default will not reach widgets that already exist.

See also

Component and SSR module

Everything your widget module exports, and everything it receives — on the renderer’s server sandbox and in the browser.

Two files carry the contract:

FileShapeExists whenConsumed by
src/widget.tsxBothAlwaysThe client IIFE (src/index.tsx registers it) and the SSR bundle
src/ssr-entry.tsSingle-widgetThe manifest declares ssr.urlThe build’s SSR pass only
src/widgets/{slug}/ssr-entry.tsSuiteThat widget’s summary declares ssr.urlThe build’s SSR pass only

ssr-entry.ts exists if and only if the widget’s summary declares ssr.url. A widget declaring "ssr": false is CSR-only and has no SSR entry file at all — hr-widget-build skips its SSR pass entirely, so the file would never be compiled. See Manifest: widget summary for the ssr field.

How rules on this page are enforced. Almost all of them are enforced at runtime by the renderer, not at publish. The dashboard’s built-zip audit checks that your SSR file exists and has a hashed twin; it never opens the bundle or inspects its exports. Where a publish gate does exist it is named with the standard tags defined in Limits and error index. Runtime outcomes use these terms:

TagMeaning
runtime — widget removedYour widget’s markup is replaced by a hidden breadcrumb node and the page is marked degraded (60 s cache TTL). Every other widget still renders.
runtime — page 500The render endpoint throws. The customer’s page has no HTML at all.
runtime — throws into your codeYou get an ordinary JS exception you can catch.

Props

Import the prop types; do not hand-write them. The scaffold’s inline WidgetProps interface is a simplification and omits fields.

// @homerunner-next/widget-core/src/contracts.ts — import from
// "@homerunner-next/widget-core/contracts"
export interface WidgetProps<
  T extends WidgetSchemaType = WidgetSchemaType,
  D extends Record<string, any> | undefined = undefined,
> {
  options: T;
  target?: HTMLElement;
  widgetId: string;
  feedId: number;
  widgetType: string;
  resolvedTheme?: "light" | "dark";
  data: D;
}

export interface LayoutWidgetProps<
  T extends WidgetSchemaType = WidgetSchemaType,
  D extends Record<string, any> | undefined = undefined,
> extends WidgetProps<T, D> {
  /** Pre-rendered React nodes for each slot. Keys are slot names. */
  renderedSlots: Record<string, ReactNode>;
}

Type your component WidgetProps<PluginConfig> (or WidgetProps<PluginConfig, GetAwaitedFuncReturnType<typeof getInitialData>> when you export one) and a layout LayoutWidgetProps<PluginConfig>. Both types are exported from /contracts; WidgetSchemaType comes from /schema. See SDK reference.

PropTypeShapeRuleOn violation
optionsT (declared) — RAW at runtimeBothAlways present. Never read a field off it directly; run parseWidgetConfig first.A stored null/'' throws inside your render → widget removed (server) or data-hr-error="render-failed" (client)
widgetIdstringBothThe widget instance id. Stable across server and client.
feedIdnumberBothThe feed this instance belongs to.
widgetTypestringBothThe keyword, e.g. plugin:pdp-suite:stay-hero. Not supplied on the SSR/hydrate path.Reading it during render produces different output on server and client → hydration mismatch
resolvedTheme"light" | "dark"BothOptional. Effectively always "light" in production. Never branch first paint on it.Dark-mode content flashes light, or mismatches on hydrate
dataDBothWhatever getInitialData returned. undefined on every client-only path. Must be JSON-serialisable.A Date/Map/function silently becomes a string, {} or is dropped
targetHTMLElementBothDeclared in the type. Never supplied.
renderedSlotsRecord<string, ReactNode>Suite (layouts)Layout widgets only. Keys are the slot names present in the stored bindings. A slot that was never bound is absent, not empty — always ?? {} and default each region.slots.hero is undefined; rendering it is fine, destructuring it is not

Not supported yet. target is part of the exported WidgetProps type but no render path in the platform passes it — not the renderer, not mount()’s hydrate path, not its CSR path. Use useShadowHost() from @homerunner-next/widget-core/runtime if you need the host element. See SDK reference.

What each path actually passes

PathoptionswidgetIdfeedIdwidgetTyperesolvedThemedatarenderedSlots
Server render (content, ssr.url)RAW merged settingsabsentcookie-derived, else "light"getInitialData result
Client hydrate of that markupRAW, identical bytesabsentreplayed from the props registryreplayed from the props registry
Server render (content, ssr: false)your component is never invoked — the renderer emits an empty host divn/an/an/an/an/an/a
Client CSR (content)resolved settings from the config fetchlive matchMedia resultundefined
Server render (layout)RAW merged settingsabsentcookie-derived, else "light"getInitialData resultserver-rendered child nodes
Client CSR (layout)resolved settingsliveundefinedchildless host divs it mounts into after commit

Three consequences worth internalising:

  1. widgetType is a hydration trap. The server props object is {resolvedTheme, options, feedId, widgetId, data} — it has no widgetType key. The browser replays that same object verbatim when hydrating. Only the pure-CSR path adds widgetType. If your render output depends on it, the SSR HTML and the hydrated tree disagree and React discards the server DOM.
  2. resolvedTheme is effectively always "light" in production. It derives from an hr:widget:system_theme cookie, and the Cloudflare worker in front of the renderer never forwards cookies. Dark-at-first-paint must come from CSS keyed off host attributes — see Styling and theming.
  3. The hydrate path replays the server’s data. It travels as JSON in a page-level inline script. Non-JSON values do not survive.

Options arrive RAW

Plugin widgets deliberately receive unparsed merged settings on both the server and the client. The renderer parses settings only for first-party widgets:

// Platform source: homerunner-renderer/lib/render-utils.tsx
const parsedSettings = widgetConfig.type.startsWith('plugin:')
    ? finalSettings                                   // plugins: RAW
    : await parseSettingsForSsr(widgetConfig.type, finalSettings);

Raw settings contain artifacts your schema rejects: a cleared dashboard field arrives as null, an emptied map as [], numbers may arrive stringified, and pre-0.9.0 saves carry a legacy width object instead of spacing. Calling .parse() on that throws.

The rule (Both): call parseWidgetConfig(configZod, props.options ?? {}) in your component and in every SSR export before reading a single field. Enforcement: runtime — throws into your code, which then becomes widget removed on the server or data-hr-error="render-failed" in the browser.

// pdp-suite/src/widgets/stay-hero/widget.tsx (real, published)
import React from "react";
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { configZod } from "./config";
import heroBg from "./hero-bg.png";
import "./stay-hero.css";

/** 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;
}

export default function StayHero(props: { options?: Record<string, unknown> }) {
  const cfg = parseWidgetConfig(configZod, props.options ?? {});
  const property = injectedProperty(props.options);
  // …
}

parseWidgetConfig(schema, options, pre?) strips null/'' leaves the schema rejects, coerces stringified scalars, lifts legacy width onto spacing, then parses. The full merge chain that produces those raw settings is described in Settings and options; the base schema fields are in Config schema and UI schema.

Memoise it in a component that re-renders (useMemo on props.options) — it is a full zod parse, not a cheap read.


The SSR module

The renderer fetches your built SSR bundle, evaluates it in a node:vm context, and reads exactly four exports off the resulting module:

// Platform source: homerunner-renderer/lib/plugin-federation.ts:317-322
interface PluginSSRModule {
  default: React.ComponentType<any>;
  getInitialData?: (ctx: any) => Promise<any>;
  dehydrateState?: (qc: any, ctx: any) => Promise<void>;
  getStaticAssets?: (settings: any) => Promise<{ css: string[]; js: string[] }>;
}
ExportTypeRequiredRuleOn violation
defaultReact.ComponentTypeYes (Both, including layouts)The loader takes module.exports if it has .default, else exports if it has .default, else module.exports.Logs [plugin] SSR bundle has no default export: <url>, the loader returns null → runtime — widget removed
getInitialData(ctx) => Promise<any>No (Both)Return value becomes props.data. Must be JSON-serialisable.A throw → runtime — widget removed
dehydrateState(queryClient, ctx) => Promise<void>No (Both)Prefetch into the page’s shared React Query client.A throw → runtime — widget removed. A throw inside a queryFn is swallowed — see below
getStaticAssets(settings) => Promise<{css: string[]; js: string[]}>No (Both)Omitting it is the recommended default.A throw → runtime — widget removed

Nothing about these exports is checked before publish — see Packaging and publishing rules for what the audit does check. A bundle with no default export publishes cleanly and fails on the first customer page render.

The entry file

ssr-entry.ts is a re-export barrel. The build compiles exactly this path — nothing else — into the SSR bundle:

// pdp-suite/src/widgets/pdp-frame/ssr-entry.ts (real, published — a layout)
// SSR entry — the renderer hands `renderedSlots` to the default export.
export { default } from "./widget";
// create-hr-plugin template: src/ssr-entry.ts (single-widget shape, content widget)
export { default, getInitialData } from "./widget";
export { dehydrateState, getStaticAssets } from "./ssr";

Suite widgets in practice export only default — pdp-suite’s stay-hero and pdp-frame both do. Add the data hooks only when you need server-side data.

Call order and context

For every server-rendered widget the renderer runs, in this order, inside one try:

# Platform source: homerunner-renderer/lib/render-utils.tsx (getWidgetMarkup)
getInitialData(ctx) → dehydrateState(queryClient, ctx) → getStaticAssets(settings)
  → renderToString(<WidgetHydrationTree><Default {...props} /></WidgetHydrationTree>)

For a layout, all slot children are rendered first (concurrently, each a complete island), and only then does the layout’s own bundle load and run the same four steps. The whole pipeline, end to end, is drawn in How a widget renders; the slot rules are in Layout widgets.

ctx — for getInitialData and dehydrateState — is exactly three keys:

KeyTypeValue
optionsRecord<string, unknown>RAW merged settings. Parse them.
feedIdnumberwidget.feed_id
widgetIdstringThe widget instance id

getStaticAssets receives only the settings object as its first positional argument — there is no ctx, no feedId, no widgetId.

Not supported yet. GetInitialDataCtx in @homerunner-next/widget-core/contracts declares widgetType, isSSR, cookies, headers and query in addition to the three keys above, and types options as parsed. For plugins the renderer populates none of the extra fields and never parses options. Type your ctx as the three-key object shown above; reading ctx.cookies or ctx.headers gets you undefined.

The shared query client

dehydrateState receives the page’s QueryClient — one instance shared by every widget on the page, first-party and plugin alike. The dehydrated cache is serialised into the HTML and rehydrated into one shared browser-side client.

  • Namespace your query keys. A colliding key silently serves another widget’s data. Prefix with your plugin slug: ["pdp-suite", "stay-hero", feedId, …].
  • Two keys are reserved and pre-seeded by the renderer before your hook runs: ["widget", <widgetId>] and ["widget", <feedId>, <widgetId>] (the widget + feed config pair that useFetchWidget reads). Do not write to them.
  • Prefetch errors are swallowed by design; see Failure semantics.

Data-fetching patterns, the error taxonomy and retry helpers live in Fetching data; endpoint shapes in Public API.

getStaticAssets — prefer omitting it

When you do not export it, the renderer builds the asset list from your manifest instead: assets.js, assets.css and assets.fonts. That is almost always what you want.

When you do export it, every URL you return is rewritten. The renderer reduces each entry to its dist-relative name (https://cdn.example.com/dist/hero/hero.csshero/hero.css, anything else → the basename) and re-resolves it against the published version’s build manifest, falling back to the /p/ proxy. Absolute URLs are not passed through.

RuleLevelOn violation
Returned URLs are reduced to a dist-relative name and re-resolvedruntime, alwaysYou cannot self-host assets from getStaticAssets
Declared assets.fonts are appended only on the manifest pathruntime, silentExporting getStaticAssets drops your fonts from the server-rendered <head>; the client IIFE still injects them, so they arrive after first paint instead of with it
Multi-widget paths keep the widget directory: hero/hero.css, not hero.cssruntimeThe build-manifest lookup misses and you fall back to the /p/ proxy

The full resolution algorithm, the /p/ proxy and font rules belong to Keywords, assets and URLs.

// create-hr-plugin template: src/ssr.ts — corrected for the suite layout.
// A suite emits dist/{widget}/{widget}.css, not dist/{slug}.css.
export async function getStaticAssets(
  _options: Record<string, unknown>,
): Promise<{ css: string[]; js: string[] }> {
  return {
    css: ["dist/stay-hero/stay-hero.css"],
    js: ["dist/stay-hero/stay-hero.iife.js"],
  };
}

The server sandbox

Your SSR bundle is a CommonJS file (formats: ["cjs"], exports: "named", inlineDynamicImports: true) named dist/{widget}/{widget}-ssr.umd.js in a suite or dist/{slug}-ssr.umd.js in the single-widget shape, despite the .umd.js suffix. Every dynamic import() is inlined at build time — there is no module loader in the sandbox at runtime.

The context is a fresh V8 realm. ECMAScript intrinsics (RegExp, Function, Reflect, parseInt, Intl, globalThis, …) exist because the realm creates them. Every Web or Node API must be explicitly seeded, and only the following are.

Shared modules (7)

require() resolves exactly seven specifiers:

// Platform source: homerunner-renderer/lib/plugin-federation.ts:25-38
const SHARED_MODULES: Record<string, unknown> = {
  react: React,
  "react/jsx-runtime": ReactJsxRuntime,
  "react-dom": ReactDOM,
  "react-dom/client": ReactDOMClient,
  "react-dom/server": ReactDOMServer,
  "@tanstack/react-query": ReactQuery,
  // SDK >= 0.10.0 externalizes widget-core's runtime in the SSR umd too.
  "@homerunner-next/widget-core/runtime": WidgetCoreRuntime,
};

Anything else throws, verbatim:

Plugin cannot require "<mod>" (not in shared modules whitelist)

Enforcement: runtime — widget removed (the throw happens during module evaluation, so the bundle never loads).

@homerunner-next/widget-core/runtime is the evergreen entry (widget-core 0.10.0+): the build externalises it, and the renderer supplies its own current copy. Your server renders therefore use the platform’s live runtime helpers, not a copy frozen at your npm install. The other widget-core subpaths — /globals, /schema, /plugin-schema, /utils, /urls, /spacing, /contracts — stay bundled into your SSR file, so they are whatever version you built with.

Everything else you import — React libraries, date utilities, your own code — is bundled. Keep the bundle small: it is fetched, evaluated and cached per renderer instance.

Globals

PresentNotes
ReactAlso reachable via require("react")
require, module, exportsThe CJS shim above
processOnly process.env, and only three keys (below)
__HR_PLUGIN_ASSET_BASE__The bundle’s own …/dist/ base — how bundled-asset imports resolve server-side
fetchWrapped by the externalFetch allowlist (below)
consoleThe renderer’s console. Your console.log lands in renderer stdout
setTimeout, clearTimeout, setInterval, clearInterval
URL, URLSearchParams, AbortController, AbortSignal
Promise, Date, Math, JSON, Object, Array, String, Number, Boolean, Error, TypeError, RangeError, Symbol, Map, Set, WeakMap, WeakSetSeeded from the host realm
window, document, self, navigator, location, localStorage, sessionStorageInert Proxy stubs — see below
Absent — any reference is a ReferenceError
crypto (no Web Crypto, no crypto.randomUUID), TextEncoder, TextDecoder, Buffer
Response, Request, Headers, FormData, Blob, atob, btoa
structuredClone, queueMicrotask, performance, setImmediate
Every Node builtin: fs, path, os, child_process, node:crypto, …
The rest of process: no argv, cwd(), version, platform

globalThis does exist and resolves to the sandbox object itself, not the host realm’s global. The build’s bundled-asset banner relies on that (globalThis.__HR_PLUGIN_ASSET_BASE__ is its server-side fallback).

fetch returns a Response created in the host realm. Its methods work; res instanceof Response is a ReferenceError because Response is not bound in the sandbox.

process.env exposes exactly three keys, and nothing else — reading any other name gives undefined:

KeyValue
NODE_ENVThe renderer’s own
NEXT_PUBLIC_HOMERUNNER_BASE_URLThe resolved Central base URL
NEXT_PUBLIC_WIDGET_ASSET_BASE_URLThe asset base, when configured

Browser-global stubs, and the guard that actually works

The seven browser globals are new Proxy(function(){}, {get, set, has, apply, construct}) where get returns another stub. That stub is an object, so it is truthy:

// Platform source: homerunner-renderer/lib/plugin-federation.ts:306-315
function makeBrowserGlobalStub(): unknown {
  const handler: ProxyHandler<object> = {
    get: () => makeBrowserGlobalStub(),   // truthy!
    set: () => true,                      // writes silently dropped
    has: () => false,                     // `"x" in window` is false
    apply: () => makeBrowserGlobalStub(),
    construct: () => ({}),
  };
  return new Proxy(function () {}, handler);
}

Consequences:

GuardServer behaviourVerdict
if (document) { … }Passes — document is a truthy stubBroken
if (window.matchMedia) { … }Passes — the property read returns a stubBroken
if ("matchMedia" in window)Fails — the has trap returns falseWorks, but by accident
if (typeof window === "undefined") return;The SSR build rewrites typeof window to the literal "undefined" before bundling, so the branch is dead-code-eliminatedCorrect

The SSR build rewrites typeof on window, document, self, navigator, localStorage and sessionStorage to "undefined". Only that form is tree-shaken; the proxy is a safety net for whatever escapes, not a substitute.

// widget-core README.md — the canonical browser-only side effect
useEffect(() => {
  if (typeof window === "undefined") return;
  import("webfontloader").then(({ load }) => load(opts));
}, [opts]);
// The SSR build rewrites `typeof window` to "undefined", esbuild DCEs everything
// after the unconditional return, and the dependency never lands in the SSR umd.

useEffect never runs on the server anyway, but the import would still be bundled without the guard.

Execution bounds

BoundValueRuleOn violation
Bundle fetch redirectsredirect: "manual"The SSR URL must serve the bytes directlyAny 3xx throws HTTP <status> redirect refused — SSR bundles must be served directly from their published URLruntime — widget removed
Bundle fetch statusmust be 2xxHTTP <status>runtime — widget removed
Module-scope evaluation5000 ms default (PLUGIN_SSR_EXEC_TIMEOUT_MS, floor 250 ms)Bounds synchronous top-level work onlyThe vm throws and the bundle never loads → runtime — widget removed
Async workunboundedTimers, microtasks and everything inside getInitialData/dehydrateState are outside the vm timeoutA hanging fetch stalls the whole page render
Top-level declarationswrapped as (function(){ … })() before compilingPrevents a minified top-level const gc colliding with the Lambda runtime’s non-configurable globals

Give every outbound request in your data hooks an explicit AbortSignal.timeout(…). The sandbox provides AbortController/AbortSignal for exactly this reason, and nothing else will stop a slow upstream from holding the page.

The vm is not a security boundary

The context is seeded with host-realm objects, so a determined bundle can walk back into the renderer’s realm. The real gate is human source review plus an admin-side build — see From your laptop to a customer page. Write as if the sandbox were airtight anyway; a reviewer reads your source.


externalFetch

The sandbox’s fetch is wrapped in an allowlist derived from the widget’s externalFetch manifest field.

RuleLevelOn violation
Allowed hosts = HomeRunner Central’s host ∪ your declared entriesruntime[plugin] fetch to "<host>" blocked — not declared in the widget's externalFetch manifest field.
An entry is host (any port) or host:port (that port only) — hostname only, no scheme, no pathruntimeSame message as above; the comparison is a lowercased exact match
Private, loopback, link-local and metadata hosts are refused even when declaredruntime[plugin] fetch to "<host>" blocked — private/metadata hosts are never allowed.
Redirects are followed manually and every hop is re-validated; max 5runtime[plugin] fetch blocked — more than 5 redirects.
The URL must parseruntime[plugin] fetch blocked — unparseable URL: <raw>
externalFetch is never validated at publishadvisory (unvalidated)A typo’d host is discovered only when the widget renders on a real page

The refused-host set is textual (no DNS resolution): localhost, *.localhost, *.local, 0.0.0.0, ::1, 127.*, 10.*, 192.168.*, 172.16–31.*, 169.254.* and metadata.google.internal.

// Platform source: homerunner-renderer/lib/plugin-federation.ts:386-399
const validate = (u: URL): void => {
  const host = u.hostname.toLowerCase();
  const hostPort = u.port ? `${host}:${u.port}` : host;
  const allowed = host === centralHost() || declared.has(host) || declared.has(hostPort);
  if (!allowed) {
    throw new Error(
      `[plugin] fetch to "${host}" blocked — not declared in the widget's externalFetch manifest field.`,
    );
  }
  if (!platformIsLocalRig() && host !== centralHost() && isForbiddenHost(host)) {
    throw new Error(`[plugin] fetch to "${host}" blocked — private/metadata hosts are never allowed.`);
  }
};

The throw surfaces inside your hook, so it is runtime — throws into your code. Let it propagate and the widget is removed; catch it and you can degrade gracefully. Catching is usually right for optional enrichment data, wrong for the widget’s primary content.

Declaration lives on the widget summary in a suite, or at the manifest root in the single-widget shape:

// manifest.json — suite shape, inside widgets[]
{ "slug": "stay-facts", "externalFetch": ["api.weather.example", "cdn.example.com:8443"] }

The root-level form is read by the resolver for single-widget manifests, but it is not declared on the published PluginManifest type and is not covered by any publish validator. Treat it as working but undocumented, and say so in your submission notes so the reviewer knows to look for it.

Two behaviours that surprise people

A 60-second server-side GET cache. Outside development mode, ok GET responses are cached for 60 s across renders (LRU, 50 entries), so dehydrateState may legitimately see data up to a minute stale. The key is the URL plus your request headers, so two calls that differ by a single header never share a response — and a request that carries authorization, proxy-authorization or cookie, uses credentials: "include", sets cache: "no-store"/"reload", sends a body, or is not a GET is never cached at all. Null-body answers (204, 205) are passed straight through and not cached either. This exists because Central’s public API is rate-limited per IP and a purge fan-out multiplies renders. Development renders bypass the cache but stay enforced — you see a block locally, not in review.

The allowlist is frozen into the cached module. The evaluated bundle is cached with its enforced-fetch closure already bound, so the first load’s allowlist is the one that sticks for that module’s lifetime. For pipeline-published plugins this never bites: a new version is a new URL, hence a new module. For a legacy author-hosted plugin, editing externalFetch in a mutable manifest takes up to 5 minutes to apply.


Failure semantics

Where it throwsWidget kindOutcomeBreadcrumb reason
Module evaluation (top-level)anyruntime — widget removedSSR bundle failed to load from <url>
No default exportanyruntime — widget removedSSR bundle failed to load from <url>
getInitialDatacontentruntime — widget removedSSR data prep failed: <message>
dehydrateStatecontentruntime — widget removedSSR data prep failed: <message>
getStaticAssetscontentruntime — widget removedSSR data prep failed: <message>
Component render on the servercontentruntime — widget removed (the render is inside the same try)SSR data prep failed: <message>
Any of the three hookslayoutruntime — widget removed; the layout and every already-rendered slot child are droppedlayout SSR data prep failed: <message>
Layout component renderlayoutruntime — page 500none — there is no HTML
Inside a queryFn you prefetchanySwallowed by TanStack Query. Warned, page degraded, widget ships its loading markupnone
Component render in the browseranyWidgetRenderBoundary catches itdata-hr-error="render-failed" on the host

A removed widget leaves this in the page source, so you can find it with view-source or a curl:

<!-- Emitted by widgetFailureNode — homerunner-renderer/lib/render-utils.tsx:132-145 -->
<div data-hr-widget-error="plugin:pdp-suite:stay-hero" hidden>
  <!-- HR widget failed — type="plugin:pdp-suite:stay-hero" id="42": SSR data prep failed: Cannot read properties of null -->
</div>

Any failure node (or any errored prefetch) marks the whole page degraded, which drops its cache TTL from 24 hours to 60 seconds at every layer. A degraded page is not a broken page — it is a page that will re-render a minute later.

Your console.* calls and the renderer’s own lines go to renderer stdout. The ones worth grepping when an SSR module misbehaves:

# Platform source: homerunner-renderer/lib/plugin-federation.ts + lib/render-utils.tsx
[plugin] Fetching SSR bundle: <url>
[plugin] Failed to load SSR bundle from <url>: <message>
[plugin] SSR bundle has no default export: <url>
[plugin] Loaded "<keyword>" (<id>) via SSR bundle
[plugin] No manifest entry for "<keyword>". Skipping.
[widget] Failed to prepare "<keyword>" (<id>): <message>
[layout] Failed to prepare "<keyword>" (<id>): <message>
[widget] SSR prefetch FAILED — shipping loading markup. key=<queryHash> error=<message> (caught during …)

More diagnosis routes are in Troubleshooting.

The layout asymmetry

A content widget’s server render happens inside getWidgetMarkup’s try. A layout’s component is returned as an un-rendered React element and rendered later, in the page’s single renderToString, which has no try. A layout component that throws during render takes the entire customer page down with a 500.

Layout authors: parse defensively, default every slot, and never index into renderedSlots without a fallback.

// pdp-suite/src/widgets/pdp-frame/widget.tsx (real, published)
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>
);

Prefetch errors only warn

queryClient.prefetchQuery swallows failures by design. The renderer diffs the shared cache’s error set around your dehydrateState and logs:

[widget] SSR prefetch FAILED — shipping loading markup. key=<queryHash> error=<message>

Your widget still renders — with whatever it renders when the query has no data. If that is a spinner, crawlers see a spinner. Prefer returning a real empty state.

In the browser

Every render site in mount() wraps your component in WidgetRenderBoundary, inside the providers. On catch it logs

[hr-widget] "<widgetType>" render failed (widget <id>, feed <id>) — rendering the error state instead of a blank node.

sets data-hr-error="render-failed" on the host element, best-effort stamps a data-hr-theme if nothing else did, and renders the shared neutral error state. A latched failure clears when resetKey changes — which is how a bad live-edit payload in the dashboard preview recovers without a reload. The complete data-hr-error vocabulary is in Runtime, mount and the DOM contract.


The hydration contract

The renderer renders each content widget as an isolated React root:

// Platform source: homerunner-renderer/lib/render-utils.tsx:715, 733-744
const ssrId = `${widgetConfig.id}:${widgetConfig.feed.id}:${widgetConfig.instanceKey ?? '0'}`;

const widgetHtml = renderToString(
  <WidgetHydrationTree queryClient={queryClient}>
    <WidgetComponent {...props} />
  </WidgetHydrationTree>,
  { identifierPrefix: ssrId },
);

mount() hydrates with the identical wrapper shape and the identical identifierPrefix, read back from the host’s data-hr-ssr-id attribute. React derives useId values from a component’s position in the tree plus that prefix, so any drift — an extra provider, a different prefix, a hand-rolled hydrateRoot — makes every useId consumer (Radix Dialog/Popover aria attributes, form ids) mismatch. React’s response is to regenerate the whole tree and silently discard your server DOM.

This is why you must mount through mount() from @homerunner-next/widget-core/runtime and never call hydrateRoot yourself. The wrapper is PortalContainerProvider → ShadowHostProvider → QueryClientProvider; provider values do not affect useId, only the shape does, which is why the server can pass undefined and the client the live shadow elements. See Runtime, mount and the DOM contract.

Two more hydration rules:

  • Render deterministically. No Math.random(), no Date.now(), no reading props.widgetType (absent on the server), no reading window. Anything that differs between the two passes costs you the SSR DOM.
  • renderToString does not await. A suspended boundary ships its fallback into the HTML. Fetch in getInitialData/dehydrateState, not by suspending during render.

Layout modules

Plugin layout widgets require widget-core 0.11.0+ and the suite manifest shape.

A layout widget’s SSR module follows the same contract with these differences:

ContentLayout
SSR bundleOptional — "ssr": false is legalMandatory — a layout declaring "ssr": false fails at render; the exact message is in Layout widgets
Extra proprenderedSlots: Record<string, ReactNode>
Server DOMIsolated root, usually inside a declarative shadow rootStatic light DOM, no shadow root, no DSD template
Client mountHydratesDeliberate no-op on server-rendered pages
Render throwWidget removedPage 500

Because the shell is light DOM on server-rendered pages, its stylesheet is loaded document-level and applies to the whole customer page. Namespace every class (pdp-suite uses .pdpf-…), never rely on :host, and never paint a background. The same layout in a single-snippet CSR embed does get a shadow root, so the CSS must work both ways.

Not supported yet. A plugin layout on a server-rendered page can never be interactive: the renderer emits no data-hr-ssr-id and no registry entry for the shell, and mount() returns early for it. Its slot children hydrate themselves normally. Put interactivity in a content widget bound into a slot, not in the shell.

Layouts may still export getInitialData, dehydrateState and getStaticAssets — the renderer calls all three. pdp-suite’s layout exports none. Slot mechanics, slots, accepts, prefill, presets and pages are all in Layout widgets.


Where the local SSR preview differs from production

The local harness (renderPluginSSR / createSSRDevServer, see Previewing SSR locally) is a faithful port of the renderer’s sandbox. The sandbox itself now matches (widget-core 0.12.2+): @homerunner-next/widget-core/runtime resolves as a shared module, TextEncoder / TextDecoder are absent from both, __HR_PLUGIN_ASSET_BASE__ is defined, externalFetch is enforced from the manifest, module scope runs under the same 5000 ms budget, and the render goes through WidgetHydrationTree with identifierPrefix set — so useId drift reproduces locally instead of first appearing on a customer’s page. On an SDK below 0.12.2 all six of those diverge, and code that passes locally fails on the renderer.

What still differs is the environment around the sandbox, not the sandbox. This is the summary; Previewing SSR locally works through what each row does to you.

Local previewRenderer
__HR_PLUGIN_ASSET_BASE__http://localhost:{port}/dist/the bundle’s CDN base
Private/loopback hosts in externalFetchrefused, unless apiBaseUrl is itself loopbackalways refused
props.optionsseeded from configZod.parse({}) by the scaffold — fully shapedthe customer’s raw stored settings
Hydration envelopedata-hr-ssr-props on the host, never a declarative shadow roota page-level props registry, DSD
renderedSlots on a layoutalways emptythe layout’s bound children
Server-side fetchyour msw fixtures; unmatched requests reach the real networkthe live feed

Not supported yet. No local harness composes a layout’s slots. renderPluginSSR builds the props itself and passes no renderedSlots, so a layout previews as its shell with every slot empty. That still proves the shell evaluates and renders without throwing — the failure that 500s a whole customer page — but it is not a preview of the composed result.

A suite widget previews like any other: createSSRDevServer takes the widget you name and resolves dist/{widget}/{widget}-ssr.umd.js under plugin:{slug}:{widget} (widget-core 0.12.2+; the suite scaffold’s dev:ssr script needs create-hr-plugin 0.8.1+).


Checklist

  • parseWidgetConfig(configZod, props.options ?? {}) in widget.tsx and in every SSR hook.
  • Types imported from @homerunner-next/widget-core/contracts, not hand-written.
  • No props.widgetType, Math.random(), Date.now() or window read during render.
  • Only typeof window === "undefined" guards for browser-only code.
  • Query keys prefixed with your plugin slug; ["widget", …] left alone.
  • Every declared externalFetch host spelled exactly, with an explicit request timeout.
  • getStaticAssets omitted unless you genuinely need to vary assets by settings.
  • Layout components that cannot throw: defaults for every slot and every config field.

Runtime, mount and the DOM contract

Normative reference for what mount() does, every DOM attribute it reads and writes, and how your component reaches the browser. Almost everything here is runtime behaviour — observable on a customer page, not checked before you ship — so the failure modes are silent by default. Publish-time rules live in packaging-and-publishing.md; the canonical index of every number and message is limits-and-errors.md.

Enforcement levels used below follow the legend in limits-and-errors.md. Runtime rules are additionally tagged:

  • runtime throw (isolated) — throws, is caught by mount, marks that one embed, other embeds continue.
  • silent runtime — no error, the widget just behaves differently than you expected.
  • advisory — nothing checks it; getting it wrong is a bug in your plugin only.

Boot order

Two scripts, in this order, per page:

<!-- src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/EmbedCodeDialog.tsx:63-86 -->
<script src="https://assets.homerunner.io/w/runtime.iife.js" crossorigin="anonymous"></script>
<!-- single-widget plugin -->
<script src="https://assets.homerunner.io/p/my-plugin/my-plugin.iife.js"></script>
<!-- one widget of a suite -->
<script src="https://assets.homerunner.io/p/pdp-suite/stay-hero/stay-hero.iife.js"></script>

Never add crossorigin="anonymous" to a /p/ script tag — see assets-and-urls.md.

runtime.iife.js is the host page’s responsibility. On platform-composed pages it is emitted for you, in this order, by getDefaultJsUrls(). In a hand-written embed you must load it yourself, first.

Both shapes. Since widget-core 0.10.0 your client IIFE does not bundle React, React DOM, React Query or the widget-core runtime. The Vite preset externalizes them onto the runtime global:

// packages/homerunner-widget-core/src/vite/index.ts:141-153 — RUNTIME_GLOBALS
{
  react:                                "HRWidgetRuntime.React",
  "react/jsx-runtime":                  "HRWidgetRuntime.jsxRuntime",
  "react-dom":                          "HRWidgetRuntime.ReactDOM",
  "react-dom/client":                   "HRWidgetRuntime",
  "@tanstack/react-query":              "HRWidgetRuntime",
  "@homerunner-next/widget-core/runtime": "HRWidgetRuntime.widgetCore",
}

Consequences you must design around:

FactConsequenceEnforcement
mount is HRWidgetRuntime.widgetCore.mount, not your copyMount fixes reach your published bundle without a rebuild. The behaviour on this page is the fleet’s, not the version in your node_modules.silent runtime
The runtime script must execute firstWithout it every externalized symbol is undefined. getRuntime() throws [widget-core] window.HRWidgetRuntime is not available. Make sure the runtime.iife.js <script> tag loads before your widget entry.runtime throw
Only /runtime is externalized/globals, /schema, /plugin-schema, /urls, /spacing, /utils stay bundled in your IIFE.
react-dom/server is not on the runtimeServer APIs exist only inside the SSR sandbox (component-and-ssr-module.md).runtime throw
Externalization is a build-only transformvite dev resolves all of it from node_modules, so dev cannot catch a runtime-version skew.silent runtime

Not supported yet. The HRWidgetRuntime TypeScript interface in packages/homerunner-widget-core/src/globals.ts:15-69 does not declare widgetCore (nor useQueries / keepPreviousData, which the live runtime IIFE does export). The build maps them regardless — the type is behind the runtime. Cast if you must read them directly.


Registration

Registration writes a page-global registry. It is what the dashboard playground, the CSR layout child loader and mount’s category fallback all read.

// packages/homerunner-widget-core/src/globals.ts:149-177
registerPlugin(key: string, component: ComponentType<any>, meta?: { category?: "content" | "layout" }): void
registerPluginWidget(pluginSlug: string, widgetSlug: string, component: ComponentType<any>, meta?): void

registerPluginWidget(p, w, C, meta) is exactly registerPlugin("{p}:{w}", C, meta) — same registry, different spelling.

ShapeKeywordRegistry keyCall
Single-widget (v1)plugin:my-pluginmy-pluginregisterPlugin("my-plugin", W)
Suite (0.11.0+)plugin:pdp-suite:stay-heropdp-suite:stay-heroregisterPluginWidget("pdp-suite", "stay-hero", W)

The registry key is everything after the plugin: prefix — there is no separate namespace. Both write window.HRPlugins[key] = { component, category? }; category is omitted entirely when meta.category is falsy. Registration is idempotent (last write wins) and is a no-op when window is undefined, so importing your entry in a Node test is safe.

RuleShapeEnforcement
Every client bundle must registerBothpublish ERROR — the built-zip audit greps each assets.js bundle for the literal string HRPlugins and refuses a bundle that never registers (packaging-and-publishing.md)
The registry key must equal the keyword minus plugin:, or nothing resolves your componentBothsilent runtime
Layout widgets must pass { category: "layout" }Suitesilent runtime — a layout with no recorded category still routes correctly if the bootstrap passes category to mount; get both wrong and the CSR path renders a content widget with no slots
One widget per entry file, per IIFESuiteadvisory — hr-widget-build builds one entry per widgets[] slug, so a second registerPlugin in the same file has no manifest entry and no bundle of its own

The renderer itself never reads HRPlugins — it loads your SSR bundle directly. The registry is what the dashboard playground preview and a CSR layout’s slot-child loader read, and it is what the publish audit proves is present. Note that the audit only checks the string: a bundle that registers under the wrong key passes publish and fails silently at runtime.


mount(container, config)

// packages/homerunner-widget-core/src/runtime/mount.tsx:251
export function mount(container: HTMLElement, config: MountConfig): void

Returns void. There is no unmount return value — the handle is on the DOM element (see The unmount handle).

mount selects container.querySelectorAll('[data-hr-widget="${config.widgetType}"]') and processes each match independently. container itself is never a match, so you always pass the wrapper ([data-hr-widget-container]), not the host div.

MountConfig — every field

// packages/homerunner-widget-core/src/runtime/mount.tsx:42-88
interface MountConfig {
  widget: ComponentType<any>;
  widgetType: string;
  category?: "content" | "layout";
  resolveChildJsUrl?: (keyword: string) => string;
  cssUrls?: string[];
  extraCssUrls?: string[];
  resolveDefaultCssUrls?: (widgetType: string) => string[];
  shadowDOM?: boolean;
}
FieldTypeRequired (shape)RuleOn violation
widgetComponentType<any>Required — BothYour default export. Receives the props described in component-and-ssr-module.md.React throws during render → caught by the render boundary, host gets data-hr-error="render-failed"
widgetTypestringRequired — BothThe full keyword: plugin:{slug} or plugin:{slug}:{widget}. Doubles as the mount selector and the default CSS-URL seed.A wrong value simply matches nothing — mount returns silently having done nothing (silent runtime)
category"content" | "layout"Optional — Suite (0.11.0+)"layout" routes the CSR path through LayoutCsrRenderer and makes the SSR path a no-op. Falls back to the registry’s category for plugin: keywords.Omitted on both mount and registerPluginWidget → a layout takes the content path and renders with no renderedSlots (silent runtime)
resolveChildJsUrl(keyword) => stringOptional — Suite (layouts only)How a CSR layout finds each slot child’s IIFE. Defaults to window.HRWidgetRuntime.resolveChildJsUrl.Unresolvable → that child logs and its slot stays empty
cssUrlsstring[]Optional — BothFull override of the shadow-root stylesheet list. Beats the page-supplied URLs.Overriding on an SSR page replaces the page’s content-hashed links with your guesses and forces a re-download (silent runtime)
extraCssUrlsstring[]Optional — BothPrepended to whichever base wins; use this instead of cssUrls for an extra sheet. Deduped against the base by stylesheet identity.
resolveDefaultCssUrls(widgetType) => string[]Optional — BothConsulted only when neither cssUrls nor the page provided any CSS. Defaults to the plugin resolver below.
shadowDOMbooleanOptional — Both, default truefalse renders straight into the host, no shadow root, no portal container.Must mirror the manifest’s shadowDOM — see When shadowDOM disagrees

The bootstrap

Verbatim from the published suite. Copy it per widget; the only per-widget edits are the imports, the two keyword strings, and category on a layout.

// pdp-suite (the published reference suite) — src/widgets/pdp-frame/index.tsx
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" });

function doMount() {
  document
    .querySelectorAll<HTMLElement>("[data-hr-widget-container]")
    .forEach((container) => {
      if (container.querySelector('[data-hr-widget="plugin:pdp-suite:pdp-frame"]')) {
        mount(container, {
          widget: PdpFrame,
          widgetType: "plugin:pdp-suite:pdp-frame",
          category: "layout",
        });
      }
    });
}

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", doMount);
} else {
  doMount();
}

A content widget of the same suite drops category from both calls:

// pdp-suite (the published reference suite) — src/widgets/stay-hero/index.tsx
registerPluginWidget("pdp-suite", "stay-hero", StayHero);
// …
mount(container, { widget: StayHero, widgetType: "plugin:pdp-suite:stay-hero" });

Scoping the scan to [data-hr-widget-container] matters. A server-rendered page emits exactly one <div data-hr-page="…" data-hr-widget-container> wrapping every widget node (packages/homerunner-renderer/pages/api/render/[feedId]/property/[slug].tsx:462), and a CSR embed emits one per widget. Scanning document.body instead would work but breaks the containment the platform relies on for slot children.


The DOM contract

The embed shape

<!-- src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/EmbedCodeDialog.tsx:45-47 -->
<div data-hr-widget-container>
  <div id="kua7xey91zqkpfjr8uveq1z1:90" data-hr-widget="plugin:pdp-suite:stay-facts"
       data-hr-min-height="140"></div>
</div>

Attributes mount reads

AttributeOnRequired (shape)RuleOn violation
data-hr-widgethostRequired — BothMust equal config.widgetType exactly. It is the selector.No match — mount does nothing, no log (silent runtime)
idhostRequired — BothColon-joined "{widgetId}:{feedId}". Split by extractWidgetId; feedId goes through Number().Missing → throws Widget ID is required (runtime throw, isolated). Present but not colon-joined → feedId is NaN, so the CSR feed fetch 404s and the host lands on data-hr-error="widget-fetch-failed"
data-hr-ssr-idhostServer-written — BothKey into window.HRWidget.__WIDGET_PROPS__, and the identifierPrefix passed to hydrateRoot.Absent on a legacy cached page → hydration falls back to React’s default prefix
data-hr-ssr-propshostLegacy fallback — BothJSON props, used only when the registry has no entry.Malformed JSON yields {} — which is truthy, so the widget still takes the hydrate path and hydrates against empty props
data-hr-optionshostOptional — BothJSON per-embed options override, merged at top priority by getFinalSettings. Also how a CSR layout pushes state onto its children.Malformed JSON yields {} (silent runtime)
data-hr-csshostLegacy fallback — BothJSON array of stylesheet URLs.Non-array or malformed → [], so the default resolver wins
data-hr-min-heighthostOptional — BothAnti-CLS reservation. Bare number = px; anything else = a raw CSS length; 0/off/none/false opts out.Unparseable values are passed to CSS verbatim and simply do not apply (silent runtime)
data-hr-mountedhostWritten by mount — BothPresence means “already mounted”; mount returns immediately.See Idempotency
template[shadowrootmode]direct child of hostServer-written — BothThe Declarative Shadow DOM payload. Adopted before anything else.

Resolution order for props, options and CSS is registry first, attributes second:

<!-- packages/homerunner-renderer/lib/render-utils.tsx:334-343 — one script per page -->
<script>(window.HRWidget = window.HRWidget || {}).__WIDGET_PROPS__ =
  Object.assign(window.HRWidget.__WIDGET_PROPS__ || {},
    {"{widgetId}:{feedId}:{instanceKey}": {props: {…}, options: {…}, css: ["…"]}});</script>

If that inline script is blocked (CSP, an ad blocker, an HTML-rewriting CDN) the markup is present but the props are not, so mount cannot hydrate and falls through to the CSR path — which is why the visibility gate is cleared on both paths.

Attributes and properties mount writes

WrittenOnWhen
data-hr-mounted="1"hostSynchronously, before any async work
data-hr-errorhostOn failure — see the vocabulary
data-hr-theme="light|dark"shadow host, else hostOnce the colour scheme is known; kept live across OS flips for auto/global
removes data-hr-schemeshadow host, else hostThe moment JS takes over theming, so the server’s parse-time media-query fallback cannot fight the React tree
data-hr-widget-category="layout"shadow host, else hostCSR layout embeds only; the SSR shell already carries it from the server
data-hr-preloading="1", data-hr-skeletonhostWhile a CSR reservation is held
inline min-height, transitionhostWhile a reservation is held; both restored on release
el.__hrWidgetUnmounthost (JS property)Always
div.hr-react-root, div.hr-portal-container, <link rel="stylesheet">inside the shadow rootWhen shadow DOM is on
<style id="hr-widget-preload-style">document.headOnce per document, when any reservation is applied

The shadow host is normally the [data-hr-widget] element itself. If that element’s tag cannot take a shadow root, setupShadowDOM inserts a wrapper <div> around it and the wrapper becomes the host — so data-hr-theme lands on the wrapper, not on [data-hr-widget]. Allowed tags: ARTICLE ASIDE BLOCKQUOTE BODY DIV FOOTER H1–H6 HEADER MAIN NAV P SECTION SPAN, plus any hyphenated custom element.

data-hr-error (runtime, on the host) is a different attribute from data-hr-widget-error (server, on a hidden failure breadcrumb the renderer emits in place of a widget). Grep for both — see ../troubleshooting.md.


Idempotency and per-embed isolation

Both shapes. mount guards on the data-hr-mounted DOM attribute, not a module-level Set. A module Set cannot work: your IIFE, a system widget’s IIFE and a layout’s IIFE each hold their own module instances, and only a shared DOM marker dedupes across them. The attribute is set synchronously so two near-simultaneous callers cannot both pass.

Each element is mounted inside its own try. A throw is caught, logged, and marks that element:

// packages/homerunner-widget-core/src/runtime/mount.tsx:258-271
allRootEls.forEach((rootEl) => {
  try {
    mountOne(rootEl, container, config, resolveDefaults);
  } catch (error) {
    console.error(
      `[hr-widget] "${config.widgetType}" mount failed for one embed — other embeds continue.`,
      error,
    );
    rootEl.setAttribute("data-hr-error", "mount-failed");
  }
});

Nothing propagates to your caller — not even a missing id. mount never rejects and never rethrows, so wrapping the call in your own try tells you nothing. Read the console and the data-hr-error attribute instead.

One consequence to know: data-hr-mounted is set before the id check, so a failed embed keeps the marker. Calling mount again after fixing the DOM will skip it. Remove data-hr-mounted (or call el.__hrWidgetUnmount()) first.

Asynchronous failures after the CSS gate — a rejected cssReady, a throw inside hydrateRoot — are caught separately and produce the same marker with a different line:

[hr-widget] "<widgetType>" deferred mount step failed (widget <widgetId>, feed <feedId>).

Shadow DOM lifecycle

Both shapes, when config.shadowDOM !== false and HTMLElement.prototype.attachShadow exists. mount tries three things in order.

  1. Adopt a declarative shadow root. If the host already has a shadowRoot (native DSD — the browser attached and styled it at parse), adoption returns immediately with cssPending: false: no hide, no migrate, no CSS wait, no mount blink. If instead an inert template[shadowrootmode] sits in the light DOM, the ponyfill attaches the root itself and moves the template content in; its <link>s only connect now, so cssPending: true and the react-root is hidden until they apply.
  2. Otherwise setupShadowDOM(rootEl, cssUrls), which attaches { mode: "open" }, appends one <link rel="stylesheet"> per URL, then div.hr-react-root and div.hr-portal-container.
  3. Then migrate, if there was server markup: migrateSSRContent moves the host’s child nodes into the react-root with appendChild. It never re-serialises through innerHTML — that would destroy the identity hydration matches against.

Adoption looks for the class names hr-react-root and hr-portal-container, not data attributes. If hr-react-root is absent, adoption returns null and mount falls through to step 2. A missing hr-portal-container is created on the fly.

The visibility gate is shadowSetup.reactRoot.style.visibility = "hidden" — on the react-root, never on the host. It is cleared by both the hydrate path and the CSR render path, so a widget that looked server-rendered and then fell through to CSR still becomes visible.

cssReady resolves when every shadow <link> has a .sheet, on load/error, or after a hard 3000 ms timeout (it polls every 16 ms, because cached sheets often never fire load). A failed sheet logs [HRWidget] Failed to load CSS in shadow DOM: <href> and resolves anyway — CSS never blocks hydration.

Not supported yet. adoptDeclarativeShadowRoot, waitForShadowLinks and hasRenderedChildren exist in source but are not exported from @homerunner-next/widget-core/runtime. You cannot reimplement this lifecycle; use mount. setupShadowDOM, migrateSSRContent, getCSSUrlsFromElement and isShadowDOMSupported are exported — see sdk-exports.md.

Not supported yet. A widget whose entire server output is a hoistable tag (<style>, <link>, <script>, <meta>, <title>, <base>) reads as no server markup and client-renders instead of hydrating. Always nest server output inside a real element.

Why you must not call hydrateRoot yourself

The server renders each widget as an isolated root inside WidgetHydrationTree with identifierPrefix set to the data-hr-ssr-id. useId values derive from tree position plus that prefix, so any drift makes React regenerate the tree and discard every byte of your SSR DOM. mount mirrors the wrapper shape and the prefix exactly:

// packages/homerunner-widget-core/src/runtime/mount.tsx:507-525
<WidgetHydrationTree queryClient={getSharedQueryClient()}
                     portalContainer={portalContainer} shadowHost={shadowHost}>
  {withRenderBoundary(<Widget {...ssrProps} />)}
</WidgetHydrationTree>
// hydrateRoot(renderTarget, ssrNode, { identifierPrefix: ssrIdAttr })

When shadowDOM disagrees

Both shapes. The root manifest’s shadowDOM flag (manifest-root.md) gates whether the renderer emits a DSD template at all. It must mirror what every one of your bootstraps passes to mount.

Manifestmount()Result
true (default)default / trueCorrect. DSD adopted, hydrated in place.
falsefalseCorrect. Light-DOM SSR, no isolation.
truefalseBroken on any DSD-capable request. The browser attaches the server shadow root at parse; mount renders into the light DOM, which a host with a shadow root never displays. hasRenderedChildren reads false, so it takes the CSR path. Symptom: visible server content that never hydrates, sitting under an invisible second render — and it looks fine on a browser that missed the DSD bucket, which is what makes it so hard to spot.
falsetrueA shadow root is created around light-DOM SSR markup, which is then migrated into it. Works, but you lose the parse-time styling DSD buys.

Enforcement: silent runtime. Nothing at publish compares the two.


CSS URL resolution

// packages/homerunner-widget-core/src/runtime/mount.tsx:202-213
const base = config.cssUrls ?? pageProvided;                       // registry css, else data-hr-css
const resolvedBase = base.length > 0 ? base : resolveDefaults(widgetType);
const extras = (config.extraCssUrls ?? []).filter((u) => !baseKeys.has(cssIdentityKey(u)));
return [...extras, ...resolvedBase];

Precedence: cssUrls → page-provided (registry css, else data-hr-css) → resolveDefaultCssUrls. extraCssUrls are prepended to whichever base wins, so build-time extras keep their cascade position without displacing the page’s content-hashed URLs.

Dedupe is by stylesheet identity, not string equality: the pathname with a leading /w/ or /dist/ stripped and an 8-hex content hash removed. /w/stay-hero/stay-hero.css, /dist/stay-hero/stay-hero-1a2b3c4d.css and /dist/stay-hero/stay-hero.css are one sheet.

The default resolver for a plugin: keyword:

EnvironmentSingle-widgetSuite
Vite dev (__HR_WIDGET_DEV__ === true)/dist/{slug}/{slug}.css/dist/{slug}/{widget}/{widget}.css
Production{proxy}/p/{slug}/{slug}.css{proxy}/p/{slug}/{widget}/{widget}.css

{proxy} is window.HRWidgetRuntime.proxyBaseUrl when the host page sets it, else https://assets.homerunner.io. Non-plugin keywords default to []. Full URL rules: assets-and-urls.md.


Layout widgets

Suite only (widget-core 0.11.0+). The complete layout contract — slots, prefill, presets, page assignment — is layouts.md. What mount itself does:

// packages/homerunner-widget-core/src/runtime/mount.tsx:297-337
const effectiveCategory =
  config.category ??
  (config.widgetType.startsWith("plugin:")
    ? getRegisteredWidget(config.widgetType)?.category
    : undefined);
// …
if (effectiveCategory === "layout" && hasSSR) {
  return;   // deliberate no-op
}
  1. Category fallback. config.category wins; for a plugin: keyword an omitted value falls back to what registerPluginWidget(..., { category: "layout" }) recorded. The registry write is in the same IIFE, so it always precedes the mount.
  2. SSR pages: the mount is a deliberate no-op. The shell is static server HTML and each slot child self-hydrates through its own IIFE. Running the CSR path here would throw away the server-rendered slots and re-fetch everything. This is correct behaviour, not a bug.
  3. CSR embeds: LayoutCsrRenderer. An empty layout host (no server markup) falls through and the layout rebuilds its own slot tree: fetch the layout widget, read settings.slots as Record<slotName, childWidgetId[]>, fetch each child row, load each child IIFE via resolveChildJsUrl + loadScriptOnce, wait for registration, render childless host nodes, and mount each child imperatively after commit.

What a CSR layout pushes onto every slot child, as a data-hr-options JSON string:

// packages/homerunner-widget-core/src/runtime/layout-csr-renderer.tsx:87-102
{
  __parentColorScheme: layoutScheme ?? "light",
  // only when the layout resolved a property:
  filter: { feed_id, property, platform: null },
}

platform: null is load-bearing, not tidiness: a child’s stored platform would AND-filter it to zero results when the pushed property belongs to a different connection, and undefined would be skipped by the merge.

Slot-child host nodes are keyed by ${childId}:${optionsAttr}, so a live colour-scheme edit recreates the node — children read __parentColorScheme once, at mount, and data-hr-mounted blocks a re-mount.

Per-child failures are contained and logged; the layout still renders:

Console lineCause
[layout-csr] Slot "<name>" child <id> failed to resolve; skipping.The child widget row could not be fetched
[layout-csr] Child widget "<keyword>" failed to load/register; its slots stay empty.IIFE load failed, or registration timed out after 10 000 ms ([widget-core] widget "<type>" did not register within 10000ms)
[layout-csr] Skipping nested layout child "<keyword>" (<id>) in slot "<slot>".A system layout (keyword ends -layout) bound into a slot
[layout-csr] Skipping nested plugin layout child "<keyword>" — layouts cannot nest.A plugin layout, caught by its registered category
[layout-csr] Could not verify property "<slug>"; rendering the layout anyway.The property check failed with a 5xx or a network blip — never a reason to blank a working layout
[layout-csr] Property "<slug>" was not found in feed <n>. Check the layout's Property setting, or the data-hr-options filter.property on the embed.The one whole-layout failure. A permanent 4xx on the property check renders data-hr-error="property-not-found" instead of the slots, so a bad slug is diagnosed once rather than 404ing in every slot at the same time
[widget-core] failed to load script: <src>loadScriptOnce could not fetch a child IIFE

Not supported yet. A plugin layout on a server-rendered page can never be interactive. The renderer emits its shell with no data-hr-ssr-id and no props-registry entry, and the client mount no-ops — so the shell is static HTML forever. Its slot children hydrate normally and are fully interactive. Put interactivity in a content widget, not the shell.


Live preview: the options-updated event

Both shapes. A CustomEvent named options-updated, dispatched on the host element, whose detail is the complete options object:

// packages/create-hr-plugin/template/src/dev/DevPanel.ts:25-29
el.dispatchEvent(new CustomEvent("options-updated", { detail: settings }));
PathListenerEffect
CSR content widgetClientWrapperForWidgetRe-runs getFinalSettings with the payload as the widget’s settings, so colorScheme: "global" still resolves against feed branding. Re-themes the host. Also resets a latched render failure.
CSR layoutLayoutCsrRendererRe-resolves slot bindings and layout settings live
Hydrated SSR widgetnoneNothing happens

Not supported yet. The hydrated SSR path installs no options-updated listener. A widget on a server-rendered page cannot be live-edited through this channel.

This is also the reason the unmount handle clears leftover hoistable tags: React does not claim them during hydration, so an unmount → re-mount cycle would otherwise still read as “has server markup”, re-hydrate, and silently have no live-preview listener.


The unmount handle

mount sets rootEl.__hrWidgetUnmount — a plain JS property, because mount may be a different module instance in each IIFE and a DOM property is the only shared channel. Calling it:

  1. cancels any pending CSS-deferred first render,
  2. disposes the preload reservation and the theme controllers,
  3. root.unmount(),
  4. empties the shadow react-root (removing React’s unclaimed hoistables),
  5. resets visibility — mandatory, or a mount torn down during the CSS window leaves a permanently hidden root,
  6. removes data-hr-mounted and deletes itself.

LayoutCsrRenderer calls it on slot children it discards; if a child has no handle (a stale bundle or a hand-rolled mount) it warns:

[HRWidget] discarded slot child "<type>" (id <id>) has no unmount handle
(stale bundle or custom mount) — its widget tree may stay orphaned.

The type UnmountableEl is not exported from /runtime; declare it yourself if you need it.


Anti-CLS preload reservation

Both shapes. Applied only when the embed has no server content and is not a layout — i.e. CSR embeds and ssr: false widgets. Layout shells defer to their children’s own reservations.

Height source, in order:

  1. data-hr-min-height on the host. Bare number → px; anything else → raw CSS length; 0 / off / none / false → opt out.
  2. preloadMinHeightHint(widgetType, options).
// packages/homerunner-widget-core/src/utils/preload-hints.ts:65-89
const DEFAULT_MIN_HEIGHTS = { explorer: …, "search-bar": …, booking: …, calendar: …,
  "multi-calendar": …, "property-card": …, "related-properties": …, gallery: … };

Plugin widget types have no entry in that table. preloadMinHeightHint returns null for every plugin:* keyword, so a plugin reserves nothing unless you supply a height. Two channels:

ChannelShapeEffectEnforcement
expectedHeight on the widget summary (widget-summary.md)SuiteThe renderer stamps data-hr-min-height="{n}" on a CSR-only host, and the dashboard bakes it into the generated embed snippetadvisory — nothing warns if you omit it
data-hr-min-height on the embedBothDirect, wins over everythingadvisory

While reserved, the host carries data-hr-preloading="1" (and data-hr-skeleton when a shaped silhouette exists) and an inline min-height. It is released the moment real content lands in the render target, via a MutationObserver, with a 250 ms min-height ease; a hard 10 000 ms max-hold collapses it honestly if the widget never paints. A host that already carries its own inline min-height keeps it — the reservation manages the attribute only.


The data-hr-error vocabulary

Two places carry this attribute: the host element (a QA/support marker) and the rendered div.hr-widget-error-state (the visible fallback).

ValueOnMeaningCleared when
mount-failedhostA throw inside mountOne (classically a missing id), or a rejection in the CSS-deferred hydrate/render stepNever automatically
render-failedhost + error elementWidgetRenderBoundary caught a synchronous render throw — most often configZod.parse() on a stored null/'' because the component skipped parseWidgetConfigA changed resetKey (the next options-updated payload)
rate-limitedhost + error elementThe widget-config fetch returned 429A successful retry
widget-fetch-failedhost + error elementAny other widget-config fetch failureA successful retry
property-not-founderror element onlyA CSR layout was pointed at a property slug that does not exist in the feed

WidgetRenderBoundary also logs, and best-effort stamps data-hr-theme from prefers-color-scheme when nothing else has, so the error card is not a glaring white box on a dark page:

[hr-widget] "<widgetType>" render failed (widget <widgetId>, feed <feedId>)
— rendering the error state instead of a blank node.

The error element also carries role="alert", class="hr-widget-error-state", data-hr-feed-id and data-hr-widget-id.


Runtime bounds this page depends on

Canonical index: limits-and-errors.md.

BoundValueWhat it governs
Shadow CSS gate3000 mscssReady resolves regardless; CSS never blocks hydration
Slot-child registration10 000 mswhenWidgetRegistered rejects; that slot child is skipped
Preload max hold10 000 msThe reservation collapses even if the widget never paints
Preload release ease250 msmin-height transition, skipped under prefers-reduced-motion

Keywords, assets and URLs

Everything your plugin ships — bundles, stylesheets, images, fonts — is addressed twice: once by the keyword a customer’s widget row stores, and once by a URL the platform derives from your manifest. This page is the normative reference for both derivations, on every path.

Enforcement levels used below are defined once in limits-and-errors.md: publish ERROR (the upload or the publish is refused), publish warning (the reviewer sees it, you do not), silent runtime truncation (the value is dropped or capped with no message) and advisory (unvalidated) (nothing checks it; you find out in production).


1. The keyword grammar

A plugin widget is addressed everywhere — stored settings, embed snippets, the renderer, the registry on window.HRPlugins — by a single string.

ShapeKeywordRegistry key
Single-widget (v1)plugin:{pluginSlug}{pluginSlug}
Suiteplugin:{pluginSlug}:{widgetSlug}{pluginSlug}:{widgetSlug}

registryKey is everything after the plugin: prefix, unchanged. It is the key your client bundle registers under and the key mount() looks up — see runtime-and-mount.md.

// Source: packages/homerunner-widget-core/src/urls.ts:34-46
export function parsePluginKeyword(keyword: string): PluginKeywordParts | null {
  if (!keyword.startsWith(PLUGIN_WIDGET_PREFIX)) return null;   // "plugin:"
  const registryKey = keyword.slice(PLUGIN_WIDGET_PREFIX.length);
  if (!registryKey) return null;
  const sep = registryKey.indexOf(":");
  if (sep === -1) {
    return { pluginSlug: registryKey, widgetSlug: null, registryKey };
  }
  const pluginSlug = registryKey.slice(0, sep);
  const widgetSlug = registryKey.slice(sep + 1);
  if (!pluginSlug || !widgetSlug) return null;
  return { pluginSlug, widgetSlug, registryKey };
}

Parsing rules — Both shapes:

InputResultWhy
plugin:acme{acme, null, "acme"}No separator → single-widget form
plugin:acme:hero{acme, hero, "acme:hero"}Split at the first colon
gallerynullNot a plugin keyword — a system widget
plugin:nullEmpty registry key
plugin::heronullEmpty plugin slug
plugin:acme:nullEmpty widget slug

A null parse is fail-closed: nothing resolves, nothing mounts, and on a server-rendered page the renderer logs [plugin] No manifest entry for "<keyword>". Skipping. and replaces the widget with a hidden error breadcrumb. Both slugs are constrained to ^[a-z0-9][a-z0-9-]*$ at publish (publish ERROR), so a published keyword can never contain a third colon — see manifest-root.md and widget-summary.md.


2. Derived file names

You never invent bundle names. The SDK’s Vite preset emits fixed names, and every consumer — the renderer, the /p/ proxy, the client runtime — recomputes the same names from the keyword.

// Source: packages/homerunner-widget-core/src/urls.ts:55-64
export function pluginWidgetDistFile(parts, kind: "iife" | "css" | "ssr"): string {
  const base = parts.widgetSlug ?? parts.pluginSlug;
  const dir = parts.widgetSlug ? `${parts.widgetSlug}/` : "";
  const file =
    kind === "iife" ? `${base}.iife.js` : kind === "css" ? `${base}.css` : `${base}-ssr.umd.js`;
  return `${dir}${file}`;
}
ArtifactSingle-widget (v1)Suite
Client IIFEdist/{slug}.iife.jsdist/{widget}/{widget}.iife.js
Stylesheetdist/{slug}.cssdist/{widget}/{widget}.css
SSR bundle (CJS)dist/{slug}-ssr.umd.jsdist/{widget}/{widget}-ssr.umd.js
Bundled assetdist/{name}-{vitehash}.{ext}dist/{widget}/{name}-{vitehash}.{ext}
Source map…{file}.map…{file}.map

The -ssr.umd.js suffix is historical: the file is CommonJS, not UMD.

Rule (Both, publish ERROR). Your manifest’s assets.js, assets.css and ssr.url must point at files the build actually emitted. The built-zip audit checks each one and fails with dist/<name> (<label>'s <kind>) is missing from the zip. Do not rename outputs.

dist/manifest.json — the build manifest

After the last build pass the SDK walks dist/ recursively, writes a content-hashed twin next to every file, and records the mapping.

// Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/dist/manifest.json (real, v1.3.3)
{
  "buildHash": "77328e0a",
  "generatedAt": "2026-09-07T06:25:25.567Z",
  "files": {
    "booking-cta/booking-cta.css": "booking-cta/booking-cta-a830c68f.css",
    "booking-cta/booking-cta.iife.js": "booking-cta/booking-cta.iife-ca74009c.js",
    "pdp-frame/pdp-frame-ssr.umd.js": "pdp-frame/pdp-frame-ssr.umd-5872d9b8.js",
    "pdp-frame/pdp-frame.css": "pdp-frame/pdp-frame-9992937d.css",
    "pdp-frame/pdp-frame.iife.js": "pdp-frame/pdp-frame.iife-be6fddd4.js",
    "stay-hero/hero-bg-Cr1v4Zys.png": "stay-hero/hero-bg-Cr1v4Zys-1cc265f5.png",
    "stay-hero/stay-hero-ssr.umd.js": "stay-hero/stay-hero-ssr.umd-0b04d970.js",
    "stay-hero/stay-hero.css": "stay-hero/stay-hero-f4a2cffb.css",
    "stay-hero/stay-hero.iife.js": "stay-hero/stay-hero.iife-f2a3fe21.js"
  }
}
PropertyRuleEnforcement
Keysdist-relative path — hero/hero.iife.js for a suite, a bare filename for v1generated
Values{base}-{sha8}{ext}, first 8 hex of the file’s SHA-256generated
manifest.json itselfexcluded from filesgenerated
*.mapexcluded from files — no hashed twin, not /p/-servable, but still uploaded to the CDN and reachable directlygenerated
buildHashSHA-256 (8 hex) of the sorted files mappublish forensics only — no serving path reads it
Presence in the built ziprequiredpublish ERRORdist/manifest.json (build manifest with {buildHash, files}) is missing — run the widget-core build.
Hashed twin for every declared assetrequiredpublish ERRORThe build manifest has no hashed twin for <name>. / Hashed twin dist/<hashed> is missing from the zip.

Three consumers read this file: the /p/ proxy (to pick a 302 target), the renderer (to emit a direct content-addressed URL) and the built-zip audit. See packaging-and-publishing.md.

Path normalisation: dist-relative name

Every consumer reduces a manifest asset reference — absolute URL or path — the same way before looking it up.

// Source: packages/homerunner-widget-core/src/urls.ts:107-118 (mirrored verbatim in
// packages/homerunner-embeddables/api/plugin-asset.ts:194-204 and
// packages/homerunner-renderer/lib/render-utils.tsx:177-188)
export function distRelativeAssetName(urlOrPath: string): string {
  const clean = urlOrPath.split("#")[0]!.split("?")[0]!;
  const marker = clean.lastIndexOf("dist/");
  if (marker !== -1 && (marker === 0 || clean[marker - 1] === "/")) {
    const rel = clean.slice(marker + "dist/".length);
    if (!rel.split("/").some((segment) => /^\.+$/.test(segment))) return rel;
  }
  return extractAssetFilename(clean);           // last path segment
}

Consequences you can rely on (Both): everything after the last dist/ survives, including the widget directory; a path with no dist/ segment collapses to its basename; a path containing a dot-only segment (dist/../evil.js) also collapses to its basename, so traversal can never escape the version prefix.


3. Where published files live

Published objects are keyed {env}/{slug}/{version}/{path} in one shared bucket, served from the plugin CDN (R2_PUBLIC_BASE_URL; https://plugins.homerunner.io in production).

// Source: src/lib/r2.ts:64-80 + src/lib/plugin-zip.ts:532-533,904-911
{env}/{slug}/{version}/manifest.json                    public, max-age=300
{env}/{slug}/{version}/dist/manifest.json               public, max-age=300
{env}/{slug}/{version}/dist/<file>                      public, max-age=31536000, immutable
{env}/{slug}/{version}/dist/{widget}/<file>             immutable  (bundles, css, images, fonts, .map)
{env}/{slug}/{version}/widgets/{widget}.manifest.json   immutable
{env}/{slug}/{version}/media/<file>                     immutable  (icon, cover, screenshots, README)
{env}/{slug}/{version}/media/{dir}/<file>.md            immutable  (per-widget READMEs)

# {env} = prod (production dashboard) | dev (preview deployments) | local (a laptop)

Exactly two objects are not immutable: the root manifest.json and dist/manifest.json, both public, max-age=300. Everything else — including media/ and .map files — is public, max-age=31536000, immutable.

The {env} segment is mandatory in every URL. Verifying a publish:

# Source: src/lib/r2.ts:70-80 (cdnPluginUrl) — the {env} prefix is never optional
BASE=https://plugins.homerunner.io/prod/pdp-suite/1.3.3
curl -s  $BASE/manifest.json      | jq .version
curl -s  $BASE/dist/manifest.json | jq .buildHash
curl -sI https://assets.homerunner.io/p/pdp-suite/stay-hero/stay-hero.iife.js | head -1  # 302

Version bumping, immutability and rollback are owned by publish-versions-and-rollback.md.


4. Two serving paths

There is no single origin and no single resolution rule. Which URL a browser sees depends on how the page was built.

PathWho uses itURL emittedRedirectCached
Direct content-addressed CDNServer-rendered / composed customer pages, for pipeline-published plugins{cdn}/{env}/{slug}/{version}/dist/[{widget}/]{file}-{sha8}.{ext}none1 year, immutable
/p/ proxyCSR embed snippets, the playground, the client runtime’s default CSS resolution, legacy author-hosted plugins, and any lookup failure{proxy}/p/{slug}/{distRelativeFile}one 302 to the CDN60 s + SWR 30 s on the 302; the target is immutable

Because the direct path has no pointer indirection, a cached page always loads the exact bundle its HTML was rendered with — there is no window in which new HTML meets an old bundle.

How the renderer resolves an asset

// Source: packages/homerunner-renderer/lib/render-utils.tsx:196-273
1. file      = distRelativeName(url)                    // "hero/hero.iife.js"
2. anchor    = resolvePluginAssetUrl(manifest_url, widget.assets.js)
3. if anchor does NOT match /\/\d+\.\d+\.\d+\/dist\/(?:[^/]+\/)?[^/]+$/
   or its host is localhost/127.0.0.1  ->  go to step 7
4. distBase  = anchor up to and including "/dist"
5. GET {distBase}/manifest.json  (3 s timeout; cached for the life of the
   process, failures negative-cached for the life of the process)
6. if files[file] exists  ->  {distBase}/{files[file]}      DONE
7. fallback: {proxyBase}/p/{slug}/{file}
   proxyBase = NEXT_PUBLIC_WIDGET_ASSET_BASE_URL
             ?? NEXT_PUBLIC_PROXY_BASE_URL
             ?? "https://assets.homerunner.io"

Two rules follow, and both surprise authors:

  • Anything getStaticAssets() returns is rewritten (Both). Absolute URLs are not passed through — they are reduced to a dist-relative name by step 1 and re-resolved. You cannot self-host an asset from getStaticAssets. The only pass-through channel for a third-party stylesheet URL is assets.fonts (§9). See component-and-ssr-module.md.

  • Pre-release versions lose the direct path. 1.0.0-rc.1 publishes, but every immutability detector requires a bare X.Y.Z path segment, so a pre-release prefix falls back to the /p/ proxy and to TTL caching everywhere.

    Not supported yet. Nothing warns you about this. Ship plain MAJOR.MINOR.PATCH versions — enforcement level today is advisory (unvalidated).

Neither the worker nor the dashboard puts crossorigin on a plugin script tag on either path (buildScriptsHtml, src/lib/homerunner/cf-worker.ts:2485-2493). See §6.


5. The /p/ plugin asset proxy

GET https://assets.homerunner.io/p/{slug}/{file}

There is no version segment and there never was one. {file} is the dist-relative name: flat (acme.iife.js) for a v1 plugin, widget-nested (stay-hero/stay-hero.iife.js) for a suite. Bumping your version does not change this URL — it changes the 302 target.

ConditionStatusBody / headers
No / after the slug400{"error":"Missing path: /p/{slug}/{file}"}
Slug unknown, not approved, or suspended404{"error":"Plugin not found"}
Manifest has no usable assets.js to anchor dist/500{"error":"Could not resolve plugin dist location"}
File not on the allow-list404{"error":"Asset not in plugin manifest"}
Served302Location: {distOrigin}{distBase}/{hashedFile}, Cache-Control: public, max-age=60, stale-while-revalidate=30, Access-Control-Allow-Origin: *

The allow-list

A file serves only if either condition holds (Both):

  1. it is a manifest-declared asset — root assets.js / assets.css / ssr.url, or any widget summary’s assets.js / assets.css / ssr.url — compared after dist-relative normalisation; or
  2. it is a key of your own dist/manifest.json files map. This is how SDK-emitted images, font files and ?url imports reach the browser.

Hashed twins are map values, never keys, so a hashed filename is only reachable through the 302 — not by requesting it from /p/ directly.

assets.fonts entries are not in the declared list (manifestKnownFilename does not read them). A dist-relative font file therefore serves only because rule 2 covers it — which it does, because everything under dist/ except manifest.json and *.map lands in the files map.

Suite-only rule. For a manifest with widgets[], a file admitted by rule 2 must sit under a first-level directory whose name equals a widget slug still present in the manifest central serves:

// Source: packages/homerunner-embeddables/api/plugin-asset.ts:276-287
const slash = filePath.indexOf('/');
if (slash <= 0) return false;
const dir = filePath.slice(0, slash);
return widgets.some((w) => w?.slug === dir);

Two consequences:

  • A suite asset emitted at the top of dist/ (no widget directory) can never serve through /p/. Keep bundled assets inside the widget’s own folder — which the SDK’s assetFileNames does for you (§8).
  • Disabling a suite widget with the platform kill switch 404s that widget’s entire dist/{widget}/ directory — bundles, images and fonts — not just its picker entry, within the proxy’s 60 s TTL. See install-and-kill-switches.md.

Proxy caching internals

CacheTTLNotes
Plugin lookup (central /public-api/v1/plugins/{slug})60 s per edge instanceOnly 403/404/410 are negative-cached; 429, 5xx and timeouts keep serving the last good entry
Build manifest, immutable prefix (/{X.Y.Z}/dist on a non-loopback host)life of the edge instanceThe prefix can never change, so neither can the map
Build manifest, legacy/author-hosted60 sFiles can be overwritten in place
Upstream fetches3 s timeoutA failed refresh serves the last good map (stale-on-error)
No build manifest at allThe 302 falls back to the logical filename, so a plugin that shipped none still serves

Legacy alias

{dashboard}/api/plugin-assets/{slug}[@{version}]/{file} still 302s onto /p/{slug}/{file}, stripping any @version. It exists only for IIFEs cached at customer edges before the proxy moved, it sends no Access-Control-Allow-Origin, and you must never emit it.


6. The crossorigin trap

Never put crossorigin="anonymous" on a /p/ script tag.

/p/ answers with ACAO: *, but it 302s off-host to the plugin CDN, and the redirect target sends no Access-Control-Allow-Origin. In CORS mode the browser therefore rejects the final response, the script never executes, and your plugin never registers on window.HRPlugins — with no error you can attribute to the cause.

<!-- Source: src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/EmbedCodeDialog.tsx:63-85 -->
<!-- runtime: same-host redirect chain, ACAO on every hop → crossorigin is safe -->
<script src="https://assets.homerunner.io/w/runtime.iife.js" crossorigin="anonymous"></script>

<!-- plugin bundle: NO crossorigin attribute, deliberately -->
<script src="https://assets.homerunner.io/p/pdp-suite/stay-hero/stay-hero.iife.js"></script>

<div data-hr-widget-container>
  <div id="{widgetId}" data-hr-widget="plugin:pdp-suite:stay-hero" data-hr-min-height="160"></div>
</div>

The cost of leaving it off is that cross-origin script errors from your bundle reach the host page’s window.onerror as the opaque "Script error.". That is the accepted trade.

CSP

Advisory (unvalidated) — nothing on the platform inspects a customer’s CSP.

A customer page that enforces Content-Security-Policy needs both origins, because the two serving paths use different ones:

DirectiveNeedsWhy
script-srcthe asset origin (assets.homerunner.io)runtime IIFE, /p/ entry point
script-srcthe plugin CDN origin (plugins.homerunner.io)direct URLs on composed pages, and the /p/ 302 target
style-srcboth of the abovewidget CSS follows the same two paths
style-src / font-srcevery absolute host in your assets.fonts (e.g. fonts.googleapis.com, fonts.gstatic.com)declared fonts are injected as <link rel="stylesheet">
img-src / font-srcdata:assets ≤ 4096 bytes are inlined as data URLs (§8)

Both origins are deployment-configurable (NEXT_PUBLIC_WIDGET_ASSET_BASE_URL, R2_PUBLIC_BASE_URL); confirm the live values with a curl -sI on one of your own bundles before handing a customer a CSP snippet.


7. SDK URL helpers

These live on the @homerunner-next/widget-core/urls subpath and are re-exported from the root barrel. Full export inventory: sdk-exports.md.

ExportSignatureBehaviour
PLUGIN_WIDGET_PREFIX"plugin:"
parsePluginKeyword(keyword: string) => PluginKeywordParts | null§1
pluginWidgetDistFile(parts, kind: "iife" | "css" | "ssr") => string§2
DEFAULT_PROXY_BASE_URL"https://assets.homerunner.io"The asset origin, not the dashboard
isAbsoluteUrl(url: string) => booleanMatches ^https?:// only. Protocol-relative //host/x and data: count as RELATIVE
resolvePluginAssetUrl(manifestUrl: string | undefined, urlOrPath: string) => stringManifest URL first. Absolute input passes through; relative input resolves via new URL(urlOrPath, manifestUrl); throws when relative and manifestUrl is undefined
distRelativeAssetName(urlOrPath: string) => string§2
extractAssetFilename(urlOrPath: string) => stringLast path segment, query and hash stripped
getPluginAssetUrl(slug, file, proxyBaseUrl = DEFAULT_PROXY_BASE_URL) => string{base}/p/{slug}/{file}, trailing slash stripped

The throw, verbatim:

// Source: packages/homerunner-widget-core/src/urls.ts:93-96
Cannot resolve relative plugin asset "<x>": manifest URL not known. Ship absolute URLs in the
manifest, or pass the manifest URL when fetching.

On a server-rendered page that throw is caught and becomes a per-widget failure with the reason bad plugin asset URL: … — the widget disappears, the page is marked degraded, the rest of the page still renders.

Because path-only references resolve against the manifest’s own URL, a leading slash escapes your version prefix entirely (/shared/foo.js{cdn}/shared/foo.js). Never start a manifest asset path with /. In a Suite manifest the audit rejects it for you (publish ERROR — the confined-relative-path rule, see widget-summary.md); in the Single-widget shape the root assets/ssr paths are checked only for presence, so a leading slash is advisory (unvalidated) and 404s in production.


8. Bundled assets

Requires widget-core 0.12.0+.

Import images, icons and font files from your widget folder like any Vite project. The SDK rewrites the reference so it resolves against wherever the bundle was actually loaded from.

// Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/src/widgets/stay-hero/widget.tsx:4,19
import heroBg from "./hero-bg.png";
// …
<img className="shero-bg" src={heroBg} alt="" data-testid="stay-hero-bg" />
/* Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/src/widgets/stay-hero/stay-hero.css:7 */
background: linear-gradient(120deg, #4f46e5, #9333ea), url(./hero-bg.png);
RuleValueShapeEnforcement
Inline thresholda file of a listed extension ≤ 4096 bytes becomes a data: URL; above it, a real file is emittedBothbuild behaviour
Emitted name (JS-referenced)dist/{widget}/{name}-{vitehash}{ext} (suite) · dist/{name}-{vitehash}{ext} (v1)Bothbuild behaviour
Emitted name (CSS url())same; the reference stays relative (url(./hero-bg-Cr1v4Zys.png)) and the browser resolves it against the stylesheet’s response URLBothbuild behaviour
Listed extensionspng jpg jpeg gif webp avif svg ico bmp woff woff2 ttf otf eot mp3 mp4 webm ogg wav pdfBothbuild behaviour
Must live under dist/{widget}/ to serve via /p/yesSuitesee §5 — silent 404 otherwise

Vite’s library mode inlines everything unless a reference carries ?no-inline; the preset appends that suffix for you above the threshold, which is why the limit works here at all. The consequence: an asset import whose extension is not on that list is inlined as a data: URL at any size, because nothing tags it ?no-inline. Keep unusual asset types off the import graph and fetch them instead.

How the URL is computed at runtime

The build prepends a plain-ES5 banner to both the client IIFE and the SSR bundle:

// Source: packages/homerunner-widget-core/src/vite/plugin-assets.ts:57-69 (formatted;
// see the same code minified at the head of pdp-suite/dist/stay-hero/stay-hero.iife.js)
var __hrAssetBase_pdp_suite_stay_hero = (function () {
  try {
    if (typeof document !== "undefined" && document.currentScript && document.currentScript.src) {
      var u = new URL(document.currentScript.src);
      var segs = u.pathname.split("/");
      segs.splice(-2);                       // 2 for a suite bundle, 1 for a v1 bundle
      return u.origin + segs.join("/") + "/";
    }
  } catch (e) {}
  return (typeof globalThis !== "undefined" && globalThis.__HR_PLUGIN_ASSET_BASE__) || "";
})();
function __hrAsset_pdp_suite_stay_hero(f) {
  try { return new URL(f, __hrAssetBase_pdp_suite_stay_hero).href; } catch (e) { return f; }
}

The import site becomes __hrAsset_…("stay-hero/hero-bg-Cr1v4Zys.png") in both builds. The base resolves to the plugin’s …/dist/ directory:

ContextBase comes fromValue
Browserdocument.currentScript.src minus 2 path segments (suite) or 1 (v1)…/{env}/{slug}/{version}/dist/
SSR sandboxglobalThis.__HR_PLUGIN_ASSET_BASE__, set by the renderer from the SSR bundle’s own URL…/{env}/{slug}/{version}/dist/
npm run devdocument.currentScript.srcthe Vite dev origin

document.currentScript is only valid during the bundle’s synchronous top-level execution — the banner runs there and captures the base once, so a later dynamic import cannot break it.

Not supported yet. Nothing verifies that a dist/-relative reference actually shipped. Asset presence is advisory (unvalidated) at publish: only manifest-declared assets (assets.js, assets.css, ssr.url, sub-manifests, media/ refs) are checked. A bundled asset lost by a bad build 404s in production with no earlier signal.


9. Web fonts — assets.fonts

Requires widget-core 0.12.0+.

@font-face is ignored inside a shadow root. A stylesheet loaded into your widget’s shadow root can reference a font family but can never define one. That is the entire reason this field exists: declared fonts are injected at document level, where the browser honours them.

// Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/manifest.json (real, v1.3.3)
{
  "slug": "pdp-frame",
  "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"]
  }
}
FieldTypeRequiredRuleOn violation
widgets[].assets.fontsstring[]Optional (Suite)Max 8 entriespublish ERRORmanifest widget "<slug>" "assets.fonts" must be a list of at most 8 stylesheets.
widgets[].assets.fonts[]stringAbsolute http(s)://…, or a relative path with no leading /, no scheme: prefix, no \, no .. segment, ≤ 2048 charspublish ERRORmanifest widget "<slug>" "assets.fonts" entries must be absolute http(s) URLs or relative paths inside the version prefix.
root assets.fontsstring[]Optional (Single-widget)Same value rulessilent runtime truncation — the v1 root is not audited at publish; normalizeFonts drops invalid entries and slices to 8 at resolve time
File presence for a relative entrymust exist under dist/advisory (unvalidated) — a missing file 404s at runtime

normalizeFonts also applies on the suite path: anything that slips past the audit is dropped, and an over-long list is capped at 8 with no message.

The two injection paths

PathWho injectsWhereDedupe key
Client (CSR embeds, hydration)the ES5 banner compiled into your client IIFEdocument.headlink[data-hr-font="{href}"], shared across every widget and plugin on the page
Server (composed pages)the renderer appends resolved fonts to the widget’s css array; the worker turns that array into <link rel="stylesheet"> in <head><head>none — the renderer owns the list

The SSR bundle carries no font loader (loadFonts: false); the renderer does that job. This is why you must declare fonts in the manifest rather than injecting them yourself: on a server-rendered page your client code has not run at first paint.

Resolution differs per entry kind:

  • Absolute http(s) URLs pass through unchanged on both paths. This is the only channel in the whole plugin system that lets you reference a third-party stylesheet URL verbatim.
  • Relative entries go through §4’s hashed-CDN-first resolution, exactly like your bundles. The client banner strips a leading dist/ and resolves the rest against the asset base.

Two behaviours worth knowing:

  • Declared fonts share the same css array as your widget stylesheet, so they also appear as <link> tags inside the Declarative Shadow DOM template — harmless duplication, but you will see them there when reading page source. (HR_DSD=inline never inlines a fonts.googleapis URL; it stays a link.)
  • The build reads assets.fonts from manifest.json at config time and bakes the list into the banner, filtering only for non-empty strings before slicing to 8 — it does not run the reference validator. Editing manifest.json without rebuilding leaves a stale list in the client bundle, and a malformed entry can ship in the banner while the server-side resolver drops it.

Choosing families, dark-mode and font.family from the base schema are covered in styling-and-theming.md.


10. Dev-server URLs

npm run dev serves the same URL shapes so mount()’s default resolution works unchanged. The switch is the __HR_WIDGET_DEV__ define, set to true in dev and false in a build.

// Source: packages/homerunner-widget-core/src/runtime/mount.tsx:134-138
const cssFile = pluginWidgetDistFile(parts, "css");
if (isViteDev()) {
  return [`/dist/${parts.pluginSlug}/${cssFile}`];
}
return [getPluginAssetUrl(parts.pluginSlug, cssFile, getProxyBaseUrlOverride())];
RouteShapeServes
/dist/{slug}/{slug}.cssSingle-widgetEvery .css imported from src/, run through the full Vite transform (Tailwind, PostCSS), concatenated in import order
/dist/{slug}/{widget}/{widget}.cssSuiteThe same, walking from src/widgets/{widget}/index.tsx so each widget’s dev stylesheet carries only its own imports
/dist/*BothStatic files from the project’s dist/, with Access-Control-Allow-Origin: *
/w/{file}Both302 to the hashed twin from dist/manifest.json — mirrors the production proxy contract
/w/manifest.json, /manifest.jsonBothThe build manifest and the root manifest

CSS imported from src/ is redirected to a no-op virtual module in dev so Vite never injects a <style> into document.head (a shadow root would ignore it anyway). On a .css change the server emits the custom HMR event hr-widget-css-update instead of Vite’s default CSS HMR, and a small client compiled into each widget entry cache-busts the <link> tags inside the shadow root by appending ?t={timestamp}.

The production proxy origin is overridable per host page via window.HRWidgetRuntime.proxyBaseUrl; plugin builds carry no environment-specific URL. More on the dev sandbox: dev-sandbox-and-mocking.md.


Known gaps

Not supported yet. You cannot self-host any asset. getStaticAssets() output is reduced to a dist-relative name and re-resolved against your published prefix, and register-by-URL is an admin-only escape hatch with no author-facing UI. assets.fonts absolute URLs are the single exception.

Not supported yet. Pre-release versions (1.0.0-rc.1) publish successfully but silently drop out of every immutability optimisation — direct hashed URLs, the proxy’s permanent build-manifest cache, and the slug-keyed SSR bundle cache all fall back to TTLs.

Not supported yet. A suite asset emitted outside dist/{widget}/ can never serve through /p/, and nothing warns you at build or publish time.

Not supported yet. Bundled and font asset presence is unvalidated at publish. Only manifest-declared bundles, sub-manifests and media/ refs are checked.

Symptom-first diagnosis for 404s, missing fonts and non-registering bundles: troubleshooting.md. Every number on this page is also indexed in limits-and-errors.md.

Packaging and publishing rules

You ship a plugin as a source zip. You never build the artifact that customers load: a reviewer downloads your zip, runs your build, zips the result, and a second audit decides whether that build may go on the CDN. Two zips, two audits, two different sets of rules — and the second audit runs after you have been told your submission was accepted. This page is the complete rule set for both.

Enforcement levels

LevelMeaning on this page
publish ERRORBlocks. Source-zip errors block the upload in your browser; built-zip errors block the publish after review. Both are hard failures.
publish warningShown, never blocks.
silent runtime truncationAccepted, then trimmed with no message.
advisory (unvalidated)Nothing checks it.

The book-wide legend and the aggregate index of every number and message live in Limits and error index.

Error strings below are quoted verbatim as the tooling emits them. <name>, <path>, <label>, N and X/Y are runtime substitutions.

The two zips

Source zipBuilt zip
Made byYouThe reviewer, from your source
Audited byauditPluginZip (your browser, before any bytes leave)auditBuildZip (the reviewer’s browser, before any bytes reach the CDN)
Containsmanifest.json, package.json, src/, widgets/, media/, config filesYour source plus the whole dist/ tree
Must NOT containdist/, node_modules/, .git/node_modules/ (everything unrecognised is ignored)
Size ceiling20 MB60 MB
Uploaded toA private bucket, presigned PUT, never through a serverThe public plugin CDN, one presigned PUT per file
Failure meansYou fix and re-zipYour reviewed plugin does not go live; you resubmit with a bumped version

The source zip

Required root entries

(Both shapes.) Two files must sit at the zip root — or inside a single wrapper folder, see below.

EntryRequiredRuleOn violation
manifest.jsonBoth — yesMust parse as a JSON objectpublish ERROR: manifest.json is missing from the zip root or is not valid JSON.
package.jsonBoth — yesMust parse as a JSON objectpublish ERROR: package.json is missing from the zip root or is not valid JSON.
package-lock.jsonBoth — noRecommended: the reviewer build otherwise resolves freshpublish warning: No package-lock.json — the reviewer build will resolve dependencies fresh.

Only those two files are inflated by the audit, and only up to 2 MB each. A manifest.json larger than 2 MB reads as missing — split a fat single-widget configSchema out, or move to the suite shape where schemas live in widgets/*.manifest.json.

Wrapper folders and junk

(Both shapes.) If every non-junk entry shares one top-level directory, that directory is treated as the project root and every path rule below is applied relative to it. This is exactly what macOS Finder’s Compress produces, so zipping the project folder is fine.

__MACOSX/ entries and any file named .DS_Store are ignored entirely — they cannot fail an audit and they are not counted.

One consequence: a zip with two top-level directories has no detected wrapper, so manifest.json is looked for at the literal zip root and is not found.

Entry rules

(Both shapes.) Every entry in the central directory is checked. All of these are publish ERROR.

RuleScopeOn violation
No absolute pathsAny entry"<name>": absolute paths are not allowed.
No .. traversalAny segment"<name>": path traversal is not allowed.
No node_modulesAny depth (path segment match)"<name>": node_modules must not be included — submit source only.
No .gitAny depth (path segment match)"<name>": .git must not be included.
No dist/Root level only, relative to the wrapper folder"<name>": dist/ must not be included — the reviewer rebuilds it from source.
No symlinksAny entry"<name>": symlinks are not allowed.

The dist/ ban is root-scoped: src/vendor/dist/thing.js is accepted, dist/anything is not. node_modules and .git are banned at every depth.

Backslashes are normalised to / before any of these run, so a Windows-built zip is checked identically.

Repeated violations of the same rule are de-duplicated, and this entry-level list is capped at 12 lines — a zipped node_modules shows a handful of representative paths, not hundreds.

Size and count limits

(Both shapes.) All publish ERROR.

LimitValueOn violation
Zip size20 MBZip is <N.N> MB — the limit is 20 MB.
Entry count2000Zip contains too many entries (<N> > 2000).
Uncompressed total100 MBZip expands to <N> MB — the limit is 100 MB.
Compression ratio100:1Zip compression ratio exceeds 100:1.

Structural failures short-circuit the whole audit:

ConditionOn violation
No end-of-central-directory recordNot a zip archive (no end-of-central-directory record).
Zip64 archiveZip64 archives are not supported for plugin submissions.
Central directory runs past EOFCorrupt zip: central directory extends past the end of the file.
Malformed central-directory entryCorrupt zip: bad central-directory entry.

Zip64 is the one that surprises people: some archivers force the zip64 format regardless of size. Use the platform archiver or zip -r.

The API re-checks the size independently and refuses anything outside 1 byte..20 MB with The zip must be between 1 byte and 20 MB.

Manifest fields the source audit reads

The source audit checks the manifest’s shape, not your build. The full field contracts live in Manifest: root fields, Manifest: widget summary and Layout widgets; this table is only what blocks the upload.

FieldTypeRequiredRuleOn violation
idstringBoth — yesNon-empty; matches ^[a-z0-9][a-z0-9-]*$. It is the plugin slug.publish ERROR: manifest.json is missing the required "id" field. / manifest "id" ("<X>") must be lowercase alphanumeric with hyphens — it is the plugin's slug.
versionstringBoth — yesNon-empty semver (see Version rules)publish ERROR: manifest.json is missing the required "version" field. / manifest "version" ("<X>") is not valid semver (e.g. 1.2.0).
namestringBoth — yesNon-emptypublish ERROR: manifest.json is missing the required "name" field.
widgets[]arraySuite — yesA non-empty array switches the manifest to the suite shape and runs the per-widget summary auditpublish ERROR, see Manifest: widget summary
widgetTypestringSingle-widget — yesNon-emptypublish ERROR: manifest.json is missing the required "widgetType" field.
ssr.urlstringSingle-widget — yesAny stringpublish ERROR: manifest.json is missing "ssr.url" (the SSR bundle path).
assets.jsstringSingle-widget — yesAny stringpublish ERROR: manifest.json is missing "assets.js" (the client bundle path).

Which branch runs is decided by exactly one test: widgets is an array and non-empty. An empty widgets: [] is read as the single-widget shape and will demand widgetType, ssr.url and assets.js. See The two manifest shapes.

runtime.widgetCore is not checked here. It is stamped by the build, and it is only gated on the built zip — so a stale stamp in your committed manifest.json cannot fail your submission.

What must be committed

This is the rule that bites after approval. The reviewer runs your build and zips the result; nothing else creates files. Anything your manifest declares that your build does not generate must be in the source zip, or the publish fails — long after you were told the submission was fine.

DirectoryGenerated byMust be in the source zip?
media/Nothing. It is authored content: icons, cover, screenshots, README.md, CHANGELOG.md, per-widget docs.Yes, always. A declared icon, cover, screenshots[] or readme missing from media/ fails the publish.
widgets/*.manifest.jsonYour build:manifest step, if you have oneCommit them unless npm run build regenerates them. They must exist in the built zip.
dist/hr-widget-buildNo — banned. The reviewer rebuilds it.
node_modules/npm installNo — banned.

If media/ is in your .gitignore, or your .svg icons live outside media/, your submission passes and your publish does not.

A real source zip

# /Users/…/hr-plugins/pdp-suite-1.3.3-source.zip — 50 entries, 82 KB (published suite, v1.3.3)
package.json                        # required at the root
package-lock.json                   # optional; absent = warning
manifest.json                       # required at the root
tsconfig.json
vite.config.ts
scripts/inject-schema.mjs
widgets/pdp-frame.manifest.json     # per-widget config schemas — generated, committed
widgets/stay-hero.manifest.json
widgets/stay-facts.manifest.json
widgets/booking-cta.manifest.json
media/README.md                     # declared as manifest.readme
media/CHANGELOG.md                  # picked up by convention, never declared
media/pdp-frame.svg                 # per-widget icons — FLAT media/<file>
media/stay-hero.svg
media/widgets/pdp-frame.md          # per-widget READMEs — one nested level allowed
media/widgets/stay-hero.md
src/vite-env.d.ts
src/widgets/<widget>/{index.tsx,widget.tsx,config.ts,ssr-entry.ts,*.css,*.png}

# NOT present: dist/, node_modules/, .git/

media/CHANGELOG.md is fetched by filename convention when it exists. It is declared nowhere and is never required.

Media reference rules, shape-tagged, all publish ERROR when broken:

FieldAccepted shapeMax
icon, cover, widgets[].iconmedia/<file> (flat, no subdirectory) or an absolute http(s):// URL2048 chars
screenshots[]media/<file> only — no absolute URL, no subdirectoryno path cap
readme, widgets[].readmemedia/<file>.md or media/<dir>/<file>.md (one nested level)255 chars path, 64 KB file

What the API checks after the upload

The browser audit is UX. Nothing in it is authoritative. After your zip is in the bucket, the metadata leg posts your parsed manifest to the API, which re-runs everything server-side and adds checks the browser cannot make.

CodeHTTPMeaningMessage
VALIDATION_ERROR422Malformed submission envelope (bad sha256, size, notes over 5000 chars)Laravel validation text
INVALID_MANIFEST422A manifest field failed the server-side contracte.g. Manifest missing required field "widgetType"., Manifest "id" must be lowercase alphanumeric with hyphens (it is the plugin slug)., Manifest declares more than 24 widgets.
INVALID_ZIP_KEY422The uploaded object is not under your slug’s folderzip_key must match submissions/<slug>/<file>.zip
INVALID_VERSION422Not parseable semver, or not three-partManifest "version" is not valid semver: <X> / Manifest "version" must be MAJOR.MINOR.PATCH semver: <X>
VERSION_NOT_GREATER422Not strictly greater than what is publishedVersion <X> must be greater than the published <Y>.
SLUG_RESERVED409The slug exists with no owner (a legacy admin-registered row)The plugin id "<X>" is reserved. Contact an administrator to claim it.
SLUG_TAKEN409The slug belongs to another authorThe plugin id "<X>" already belongs to another author. — or …was just registered by another author. when you lose a first-submission race

Server-side manifest limits the browser does not check, all publish ERROR:

FieldLimitOn violation
id255 charsManifest "id" exceeds 255 characters.
name255 charsManifest "name" exceeds 255 characters.
version64 charsManifest "version" exceeds 64 characters.
description4000 charsManifest "description" must be a string of at most 4000 characters.
author.name, author.url255 chars eachManifest "author.<field>" must be a string of at most 255 characters.
widgets[]24 entriesManifest declares more than 24 widgets.

What happens to your zip and your submission after this point — review states, resubmission, who acts when — is in Preflight and submit.

Version rules

(Both shapes.) One version number covers the whole plugin, including every widget of a suite.

RuleEnforcementOn violation
Semver shape (X.Y.Z, optional -pre and +build)publish ERROR, browsermanifest "version" ("<X>") is not valid semver (e.g. 1.2.0).
Exactly three numeric parts — 1.0 and 1 are refusedpublish ERROR, API INVALID_VERSIONManifest "version" must be MAJOR.MINOR.PATCH semver: <X>
Strictly greater than the currently published versionpublish ERROR, API VERSION_NOT_GREATERVersion <X> must be greater than the published <Y>.
Never re-publish an existing versionpublish ERROR at publish time<slug>@<version> already exists on the CDN — published versions are immutable. A changed build needs a resubmission with a bumped version.

The greater-than check compares against the live current_version (falling back to the cached manifest’s version). A plugin that has never published has nothing to compare against, so a first submission may use any valid version.

Not supported yet. Pre-release versions such as 1.0.0-rc.1 are accepted by both the browser audit and the API, and they publish — but every immutability optimisation in the platform requires a bare X.Y.Z path segment. A pre-release publish silently falls back to short-TTL caching everywhere and to proxied instead of content-addressed asset URLs. Do not use them. There is no warning.

The manifest shape is frozen at first publish

(Both shapes.) Once a plugin is live, it can never move between the single-widget shape and the suite shape. Stored widget keywords (plugin:{slug} versus plugin:{slug}:{widget}) and embed URLs customers have already copied would stop resolving.

The reviewer’s tooling refuses the build before upload with:

This build changes <slug> between single-widget and multi-widget shapes. Existing placements and
embed URLs would stop resolving — publish the new shape under a new plugin slug.

and the API refuses it at both publish and rollback with MANIFEST_SHAPE_CHANGED:

This manifest changes the plugin between single-widget and multi-widget shapes. Existing widget
placements and embed URLs would stop resolving — publish the new shape under a new plugin slug
instead.

The escape hatch is a new plugin slug, not a new version. Because a suite may contain a single widget, starting new plugins in the suite shape costs nothing and keeps the door open — see The two manifest shapes.

The built zip

You do not produce this zip, but every rule in it is a rule about your build output, and every failure sends you back to a resubmission with a bumped version. Run npm run build, zip the project folder without node_modules/, and check the list below before you submit the source.

The publishable surface

Only four kinds of path are read and uploaded. Everything else in the zip — src/, package.json, tsconfig.json, your lockfile — is ignored and never published. Your source is not served from the CDN.

# src/lib/plugin-zip.ts — the publishable-path regex, identical in the audit and
# in the server-side presign whitelist.
^(dist\/[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)?
 |widgets\/[A-Za-z0-9._-]+\.manifest\.json
 |media\/[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)?)$

# …plus the root manifest.json, matched separately.

Read that carefully: exactly one directory level under dist/, widgets/ and media/.

PathPublished?
manifest.jsonYes
dist/acme.iife.jsYes — flat single-widget layout
dist/hero/hero.iife.jsYes — one widget directory deep
dist/hero/chunks/vendor.jsNo. Silently dropped: no error, no warning.
widgets/hero.manifest.jsonYes
media/icon.svg, media/widgets/hero.mdYes
src/**, package.json, anything elseNo

A build that emits nested chunk directories under dist/{widget}/ produces a plugin whose bundles 404 at runtime with nothing in the audit to explain it. Keep the SDK’s default output layout.

Path segments may only use letters, digits, ., _ and -. A file under dist/, widgets/ or media/ with any other character (a space, a @, a non-ASCII letter) is dropped with a publish warning:

"<path>" won't be published — file names may only use letters, digits, ".", "_" and "-".

The server re-applies the same whitelist when presigning and rejects anything else outright with Unexpected file path "<path>".

Size limits

LimitValueEnforcementOn violation
Built zip60 MBpublish ERRORZip is <N> MB — zip the built plugin WITHOUT node_modules (60 MB limit).
Per publishable file30 MBpublish ERROR"<path>" is over the 30 MB per-file limit.
Total publishable bytes150 MBpublish ERRORThe build expands to <N> MB — over the 150 MB limit.
Client bundle1.5 MBpublish warningdist/<name> is <N.NN> MB.
Stylesheet512 KBpublish warningdist/<name> is <N.NN> MB.
SSR bundle1 MBpublish warningdist/<name> is <N.NN> MB — over the renderer's 1 MB cache threshold (slower cold SSR).
README (each)64 KBpublish ERROR<label> <ref> is <N> KB — the limit is 64 KB.

The 1 MB SSR warning is the one worth acting on: past it, the renderer stops caching your bundle in its fast path and every cold server render pays the load cost.

Publish gates

All publish ERROR. <label> is the plugin for a single-widget build and widget "<slug>" for each summary of a suite.

GateShapeOn violation
Root manifest.json present and parseableBothmanifest.json not found at the zip root — zip the plugin folder AFTER building.
manifest.id matches the submissionBothmanifest id "<X>" does not match the submission's "<Y>".
manifest.version matches the submissionBothmanifest version "<X>" does not match the submitted "<Y>".
runtime.widgetCore present and semverBothmanifest has no "runtime.widgetCore" stamp — this build predates the evergreen runtime. Update @homerunner-next/widget-core to 0.10.0+ and rebuild.
runtime.widgetCore ≥ 0.10.0BothBuilt against widget-core <X> — the fleet requires 0.10.0+ (older builds bundle a stale mount that breaks on SSR pages). Update and rebuild.
dist/manifest.json present with {buildHash, files}Bothdist/manifest.json (build manifest with {buildHash, files}) is missing — run the widget-core build.
Every declared asset exists under dist/Bothdist/<name> (<label>'s <kind>) is missing from the zip.<kind> is client bundle, stylesheet or SSR bundle
Every declared asset has a hashed twin in the build manifestBothThe build manifest has no hashed twin for <name>.
The hashed twin file itself is in the zipBothHashed twin dist/<hashed> is missing from the zip.
The client bundle contains the literal HRPluginsBoth<label>'s client bundle never registers on window.HRPlugins — the widget would never mount.
Each declared sub-manifest is present and valid JSONSuitewidgets/<x>.manifest.json (<label>'s sub-manifest) is missing from the zip or invalid JSON.
assets.js declaredSingle-widgetmanifest is missing "assets.js".
ssr.url declaredSingle-widgetmanifest is missing "ssr.url".
icon / cover reference shapeBothmanifest "<field>" must be a "media/<file>" path or an absolute http(s) URL.
Declared icon / cover file presentBothDeclared <field> <ref> is missing from the zip.
widgets[].icon reference shapeSuitewidget "<slug>" icon must be a "media/<file>" path or an absolute http(s) URL.
Declared widgets[].icon file presentSuiteDeclared widget "<slug>" icon <ref> is missing from the zip.
screenshots[] reference shapeBothmanifest screenshot "<X>" must be a "media/<file>" path.
Declared screenshot presentBothDeclared screenshot <ref> is missing from the zip.
README reference shapeBoth<label> must be a "media/<file>.md" (or "media/<dir>/<file>.md") path.
Declared README presentBothDeclared <label> <ref> is missing from the zip.
README ≤ 64 KBBoth<label> <ref> is <N> KB — the limit is 64 KB.

Three of these deserve a note.

The HRPlugins marker. The audit decodes your client IIFE as UTF-8 and requires the literal string HRPlugins to appear somewhere in it. Registration is a property write on window.HRPlugins, a global name no minifier rewrites — so its absence means the bundle does not register and the widget could never mount. If you tripped this, you did not call registerPlugin/registerPluginWidget in the IIFE entry, or your bundler dropped it as dead code. See Runtime, mount and the DOM contract.

Hashed twins. The SDK’s post-build step walks dist/ recursively, writes a sha256-8 twin next to every file except manifest.json and *.map, and records the mapping in dist/manifest.json. If a twin is missing, the build was assembled by hand or dist/ was edited after the build. Rebuild — never patch dist/manifest.json.

Asset path resolution. A declared asset path is reduced to a dist-relative name before lookup: dist/hero/hero.iife.jshero/hero.iife.js, and a path with no dist/ segment falls back to its basename. That name is both the dist/manifest.json key and the lookup key, so assets.js must name the file exactly as the build emitted it.

// /Users/…/hr-plugins/pdp-suite/dist/manifest.json — a real suite build manifest
{
  "buildHash": "77328e0a",
  "generatedAt": "2026-09-07T06:25:25.567Z",
  "files": {
    "booking-cta/booking-cta.css": "booking-cta/booking-cta-a830c68f.css",
    "booking-cta/booking-cta.iife.js": "booking-cta/booking-cta.iife-ca74009c.js",
    "pdp-frame/pdp-frame-ssr.umd.js": "pdp-frame/pdp-frame-ssr.umd-5872d9b8.js",
    "pdp-frame/pdp-frame.css": "pdp-frame/pdp-frame-9992937d.css",
    "pdp-frame/pdp-frame.iife.js": "pdp-frame/pdp-frame.iife-be6fddd4.js",
    "stay-hero/hero-bg-Cr1v4Zys.png": "stay-hero/hero-bg-Cr1v4Zys-1cc265f5.png",
    "stay-hero/stay-hero.iife.js": "stay-hero/stay-hero.iife-f2a3fe21.js"
  }
}

Keys and values are both dist-relative and include the widget directory. Bundled images are keyed too. .map files are deliberately absent from this map — they are still uploaded to the CDN, but they have no hashed twin and are not reachable through the asset proxy. See Keywords, assets and URLs.

buildHash is recorded as publish forensics. No serving path reads it; it is not a cache key.

Publish warnings

Never blocking, but each one describes a plugin that will disappoint whoever installs it.

Single-widget — the root manifest never got its schema injected:

manifest has no configSchema — was this zipped after `npm run build`? The dashboard form will be
empty.

Suite — that widget’s sub-manifest is present but carries no schema:

<sub-manifest> has no configSchema — <label>'s dashboard form will be empty.

Both — your config zod does not extend the base, so the standard controls are gone:

<label>'s configSchema doesn't extend the base widgetSchema (no colorScheme/spacing) — standard
theme + spacing controls won't appear. Build config.ts as widgetSchema.extend({...}) and export
zodToManifestSchema(configSchema).

The check behind the last one is exactly this: the emitted JSON Schema has a properties object that lacks a colorScheme key or a spacing key. See Config schema and UI schema.

Not supported yet. Built-zip warnings are shown to the reviewer only. You will never see them in your dashboard, and nothing in your own tooling reproduces them. Treat the three rows above as checks to run yourself: build, then confirm manifest.json (single-widget) or every widgets/*.manifest.json (suite) contains a configSchema whose properties include colorScheme and spacing.

What lands on the CDN

Key layout

Every published object is stored at {env}/{slug}/{version}/{path}.

# src/lib/r2.ts — cdnPluginKey(), and src/lib/plugin-zip.ts — the cacheControl assignment.
{env}/{slug}/{version}/manifest.json                     public, max-age=300
{env}/{slug}/{version}/dist/manifest.json                public, max-age=300
{env}/{slug}/{version}/dist/<file>                       public, max-age=31536000, immutable
{env}/{slug}/{version}/dist/{widget}/<file>              public, max-age=31536000, immutable
{env}/{slug}/{version}/widgets/{widget}.manifest.json    public, max-age=31536000, immutable
{env}/{slug}/{version}/media/<file>                      public, max-age=31536000, immutable
{env}/{slug}/{version}/media/{dir}/<file>.md             public, max-age=31536000, immutable

# {env} is prod (production dashboard), dev (preview deployments) or local (a laptop).
# One bucket serves all three; the prefix is what stops them colliding on an immutable key.

{env} is decided by the deployment doing the publishing, never by a request. Your plugin lives under prod/. Any example URL without an {env} segment is wrong and will 404.

The cache split

Exactly two objects carry a short TTL: the root manifest.json and dist/manifest.json, both public, max-age=300. Everything else — every bundle, stylesheet, image, font, source map, sub-manifest and media/ file — carries public, max-age=31536000, immutable.

That is safe because the version is in the key. A new version is a new prefix; nothing is ever overwritten.

Content types are assigned from the file extension: .js/.mjstext/javascript; charset=utf-8, .csstext/css; charset=utf-8, .json/.mapapplication/json; charset=utf-8, .mdtext/markdown; charset=utf-8, .svgimage/svg+xml, .woff2font/woff2, .png/.jpg/.jpeg/.webp/.gif as expected, and anything unrecognised → application/octet-stream. Content-Type and Cache-Control are signed into each presigned upload, so they cannot drift.

A font or image your build emits with an unlisted extension (.woff, .avif, .ttf) publishes fine but is served as application/octet-stream. Stick to .woff2 for fonts.

Which of these URLs a customer’s page actually loads — direct content-addressed CDN URLs on server-rendered pages, the /p/ proxy for CSR embeds — is Keywords, assets and URLs.

Immutability and retry safety

Before a single byte is presigned, the publish HEADs {env}/{slug}/{version}/manifest.json. If it exists:

<slug>@<version> already exists on the CDN — published versions are immutable. A changed build
needs a resubmission with a bumped version.

If the storage check itself fails, publish fails closed rather than risk overwriting a version the fleet caches forever:

Could not verify that <slug>@<version> is unpublished (storage check failed). Nothing was
uploaded — retry in a moment.

Uploads are then ordered deliberately: everything else first, dist/manifest.json second-to-last, and the root manifest.json last. The root manifest is both the immutability sentinel and the object the publish verifies, so a publish that dies mid-upload leaves no sentinel and the same zip can be retried safely. Between 1 and 200 files may be uploaded per publish, and the set must include manifest.json.

Practical consequence for you: a version number is spent the moment it publishes. A one-line CSS fix to 1.2.0 is not a re-publish of 1.2.0; it is a submission of 1.2.1.

Where the rest of the pipeline is documented

SDK reference

@homerunner-next/widget-core is the only package you import from. This page is the inventory of its public surface: 14 subpath exports plus one bin, regenerated from packages/homerunner-widget-core/src at version 0.12.2.

Signatures and export lists are normative here. The behaviour behind most of them is normative elsewhere — each section links to the page that owns it. Hard numbers and error strings are indexed in limits-and-errors.md.

What npm serves today is older than this page describes. That gap, and how to get 0.11+, is stated once in ../get-started/01-install-and-versions.md.

Subpaths

Every subpath ships ESM + CJS + .d.ts. src/ is published alongside dist/, so go-to-definition lands on real source.

SubpathSurfaceWhere it runs
@homerunner-next/widget-coreBarrel over /manifest, /urls, /globals, /plugin-schema, /contractsanywhere
.../package.jsonThe package manifest itself (version reads from your own scripts)build scripts
.../manifestManifest types, ref validators, resolvePluginWidgetanywhere
.../urlsKeyword parsing, dist file names, plugin asset URLsanywhere
.../globalswindow.HRWidgetRuntime / window.HRPlugins contracts, registrationbrowser
.../plugin-schemazodToManifestSchema, PluginUiSchema typesbuild scripts
.../schemaBase widgetSchema, parseWidgetConfig and the stored-settings repairswidget + SSR
.../spacingThe spacing model and its CSS resolverswidget + SSR
.../contractsWidgetProps, LayoutWidgetProps, Feed, Widget<T>, page-schema typesanywhere
.../runtimemount, shadow DOM, providers, fetchers, error UIbrowser (externalized — see below)
.../utilsLocale, translations, image proxy, query factories, widget contextwidget + SSR
.../viteviteHomerunnerWidget build preset, stampWidgetCoreVersionbuild only
.../mockMSW browser mocking for npm run devdev only
.../testingnode vm SSR harness, msw/node, local SSR dev serverdev only
bin hr-widget-buildThe build loop npm run build delegates to (0.11.0+)build only

/mock and /testing must never be imported from src/index.tsx, src/widget.tsx or an SSR entry — /testing imports node:vm, node:http and msw/node, and a stray import puts MSW in your published bundle.

What the root barrel does not re-export

// packages/homerunner-widget-core/src/index.ts
export * from "./manifest";
export * from "./urls";
export * from "./globals";
export * from "./plugin-schema";
export * from "./contracts";
// `./schema` and `./runtime` are not re-exported from the root to avoid
// pulling zod / React peers into consumers that don't need them.

/schema, /spacing, /runtime, /utils, /vite, /mock and /testing are not reachable from the barrel. import { parseWidgetConfig } from "@homerunner-next/widget-core" does not compile — use the subpath.

Dependencies and version pins

Hard dependencies (installed for you): lodash-es, zod-to-json-schema.

PeerRangeOptional
react^19.2.0yes
react-dom^19.2.0yes
@tanstack/react-query^5.0.0yes
zod^3.0.0yes
vite^5.0.0 || ^6.0.0 || ^7.0.0yes
msw^2.0.0yes

Every peer is declared optional through peerDependenciesMeta, so npm installs nothing you did not ask for. Install the ones you use: a pure /urls consumer needs none; a widget needs react, react-dom, react-query and zod.

Pin the React family exactly — no caret:

PackagePin
react19.2.5
react-dom19.2.5
@tanstack/react-query5.95.2

Client bundles externalize these against the host page’s runtime IIFE, and the SSR sandbox supplies its own copies. A patch-level drift trips react-dom’s internal Incompatible React versions check at runtime. Enforcement: advisory (unvalidated) — nothing at publish reads your package.json versions, so a drift ships and fails on a customer page.

/schema

ExportKindSignature
widgetSchemavalueThe base zod object every widget extends
widgetFilterSchemavalue{feed_id?: number, platform?: string | null, property?: string}
WidgetSchemaTypetypez.infer<typeof widgetSchema>
WidgetFilterSchemaTypetypez.infer<typeof widgetFilterSchema>
parseWidgetConfigfn<S extends ZodTypeAny>(schema: S, options: unknown, pre?: (v) => v) => z.output<S>
unwrapSettingSchemafn(s: ZodTypeAny | undefined) => ZodTypeAny | undefined
stripNullSettingLeavesfn(value: unknown, schema: ZodTypeAny) => unknown
coerceScalarSettingLeavesfn(value: unknown, schema: ZodTypeAny | undefined) => voidmutates in place

The nine base fields of widgetSchema, their exact defaults, and which of them the dashboard renders for you are normative in config-and-ui-schema.md.

parseWidgetConfig

pre defaults to liftLegacySpacing from /spacing. The call runs four steps in order:

  1. stripNullSettingLeaves — drops stored null / '' leaves the schema rejects, so the schema default applies instead of a parse throw.
  2. coerceScalarSettingLeaves — repairs stringified scalars the schema declares as z.number() / z.boolean() (font.size: "16" becomes 16).
  3. pre — the legacy-spacing lift, or your own transform.
  4. schema.parse(...).

Options reach your component raw on both server and client, so this call is mandatory in widget.tsx and in every SSR export. Why, and what produces those artifacts, is normative in ../concepts/settings-and-options.md.

// pdp-suite (reference plugin): src/widgets/stay-hero/widget.tsx
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { configZod } from "./config";

export default function StayHero(props: { options?: Record<string, unknown> }) {
  const cfg = parseWidgetConfig(configZod, props.options ?? {});
  return <h1 className="shero-headline">{cfg.headline}</h1>;
}

stripNullSettingLeaves also skips __proto__ / constructor / prototype keys while rebuilding objects, so a hostile stored blob cannot swap a prototype under zod.

/spacing

The base widgetSchema.spacing field replaced the old width: {maxWidth, unit} pair in widget-core 0.9.0. This subpath is the whole model. Both shapes.

Constants

ConstantValue
SPACING_SIDES["top", "right", "bottom", "left"]
DEFAULT_MARGINtop/bottom 0px, left/right unit auto — renders margin: 0 auto
DEFAULT_PADDINGall four sides 0px
ZERO_MARGINall four sides 0px
DEFAULT_BASE_SPACING{margin: DEFAULT_MARGIN, padding: DEFAULT_PADDING, width: {0, auto}, maxWidth: {100, "%"}}
DEFAULT_SLOT_SPACING{margin: ZERO_MARGIN, padding: DEFAULT_PADDING, width: {0, auto}, maxWidth: {0, "none"}}

Functions

ExportSignatureNotes
resolveWidgetSpacingStyle(options: Record<string, unknown> | undefined) => CSSPropertiesTakes raw options; runs the legacy lift and type repair itself. The one-liner every widget uses.
resolveSpacingStyle(spacing: Partial<SpacingValue> | undefined) => CSSPropertiesTakes an already-parsed spacing object. Use for layout slots.
liftLegacySpacing<T extends Record<string, unknown>>(options: T) => TLifts legacy width / top-level margin / padding onto spacing. Non-destructive — the legacy keys stay.
mirrorLegacySpacing<T extends Record<string, unknown>>(settings: T) => TSave-path inverse. mirrorLegacyWidth is a deprecated alias of the same function.
coerceSpacingValue(raw: unknown) => Record<string, unknown> | undefinedRepairs wrong-typed members; an uncoercible member is removed so the schema default applies rather than the parse throwing.
cssLength(side: SpacingSide | undefined, allowAuto: boolean) => stringAlways returns a string; never NaNpx.
cssDimension(side: SpacingSide | undefined) => string | undefinedReturns undefined when nothing should be emitted.
clampPercentToFull(length: string | number | undefined) => string | number | undefinedCaps a % length at 100%; absolute units pass through. Opt-in.
marginBoxSchema(defaults: BoxSpacing<SpacingUnit>) => ZodTypeSchema builder
paddingBoxSchema() => ZodTypeSchema builder
widthSchema(defaults: SpacingSide<WidthUnit>) => ZodTypeSchema builder
maxWidthSchema(defaults: SpacingSide<MaxWidthUnit>) => ZodTypeSchema builder
baseSpacingSchemavalueWhat widgetSchema.spacing is

Types: SpacingUnit (px \| % \| auto), PaddingUnit (px \| %), WidthUnit (px \| % \| auto), MaxWidthUnit (px \| % \| none), SpacingSide<U>, BoxSpacing<U>, SpacingValue, SpacingSideName, BaseSpacingSchemaType.

Rendering rules

These are what resolveSpacingStyle emits. All are silent runtime behaviour — nothing warns.

InputEmitted
any marginall four margin* properties, always, plus boxSizing: "border-box"
padding with every side <= 0nothing — your stylesheet’s own padding survives
padding with any side > 0all four padding* properties (your stylesheet padding is fully overridden)
width unit autonothing
width in pxwidth: min(<n>px, 100%) — a px width can never force horizontal overflow
width in %width: <n>% verbatim (use clampPercentToFull if you want it capped)
maxWidth unit nonenothing
width / maxWidth value <= 0nothing
any magnitude > 10000, NaN or infinitemargins/padding fall back to 0 in the declared unit; width/maxWidth emit nothing
// packages/homerunner-widget-core/src/spacing.ts:465 (resolveWidgetSpacingStyle)
import { resolveWidgetSpacingStyle } from "@homerunner-next/widget-core/spacing";

// Raw options, not the parsed config — the helper does the lift and repair itself.
<div style={resolveWidgetSpacingStyle(props.options as Record<string, unknown>)}>…</div>

Not supported yet. Plugin layouts get no per-slot spacing UI. slotSpacing and gap live on the platform’s private layoutWidgetSchema, which is not published to npm, so a plugin layout inherits neither. Declare your own slot fields — you can rebuild an equivalent shape from marginBoxSchema / paddingBoxSchema / widthSchema / maxWidthSchema plus DEFAULT_SLOT_SPACING. See layouts.md.

/plugin-schema

ExportKindSignature
zodToManifestSchemafn(schema: ZodType) => JSONSchema
JSONSchematypeThe narrow JSON Schema shape the dashboard form reads
PluginUiFieldtypeOne field’s UI spec, or a nested PluginUiSchema
PluginUiSchematype{[key: string]: PluginUiField | PluginUiLayoutSpec | undefined; ui?: PluginUiLayoutSpec}
PluginUiLayoutSpectype{layout?: "panel" | "collapsible", label?, description?, showIf?} — there is no fields key

zodToManifestSchema runs zodToJsonSchema(schema, {$refStrategy: "none"}) and then strips $schema, $defs, definitions, $ref and the boolean additionalProperties marker. A schema-valued additionalProperties (what z.record(...) produces) is preserved, so records round-trip back to zod in the dashboard. All sub-schemas are inlined — the consumer walks a flat tree and never resolves a $ref.

The twelve component tokens, verbatim from the type:

// packages/homerunner-widget-core/src/plugin-schema.ts:54
export type PluginUiField =
  | {
      label?: string;
      description?: string;
      component?:
        | "TextInput" | "Textarea" | "NumberInput" | "Checkbox" | "Select"
        | "ColorPicker" | "FontSelect" | "LanguageSelect"
        | "PlatformSelect" | "PropertySelect" | "WidgetSelect" | "Translations";
      optionLabels?: Record<string, string>;
      /** Conditional visibility: show this field only when condition is met. */
      showIf?: { field: string; equals: unknown };
    }
  | PluginUiSchema;

Which zod type each token must be paired with, what round-trips and what silently degrades, and which keys are ignored are normative in config-and-ui-schema.md.

/manifest

Types and guards for the manifest itself. Your own CI can reuse them; nothing here runs in a widget.

ExportKindPurpose
PluginManifesttypeThe root manifest, both shapes
PluginWidgetSummarytypeOne widgets[] entry (0.11.0+)
PluginWidgetManifesttypeA sub-manifest — {slug, configSchema, uiSchema?} (0.11.0+)
ResolvedPluginWidgettypeThe normalized serving shape both manifest shapes resolve to
PluginLayoutSlottype{name, accepts?, prefill?} (0.11.0+, prefill 0.12.1+)
PluginPageKey / PLUGIN_PAGE_KEYStype / valueThe six page keys a layout may declare (0.12.0+)
isPluginPageKeyfn(value: unknown) => value is PluginPageKey
isMultiWidgetManifestfnThe single discriminator between the two shapes
resolvePluginWidgetfn(manifest, widgetSlug: string | null) => ResolvedPluginWidget | null
normalizeLayoutSlotsfn(slots: unknown) => PluginLayoutSlot[] | undefined
pluginLayoutServesPagefn(resolved, page) => boolean
isPluginMediaReffnmedia/<file> (no subdirectory) or an absolute http(s) URL
isPluginReadmeReffnmedia/<file>.md, at most one nested directory, no ..
isPluginFontReffnabsolute http(s), or a relative path with no leading /, no scheme:, no \, no ..
isSlotPrefillKeywordfn/^[a-z][a-z0-9-]{0,63}$/ — system keywords only
isSystemWidgetKeywordfnAlias of isSlotPrefillKeyword
MAX_SLOT_PREFILLconstPrefill entries kept per slot
MAX_PLUGIN_FONTSconstDeclared fonts kept per widget
MEDIA_REF_MAX_LENGTHconstMax characters in a media reference
resolvePluginMediaUrlfn(ref: unknown, manifestUrl: string | null | undefined) => string | null

The numeric values of the three constants, and what happens when you exceed them, are normative in limits-and-errors.md. The field-by-field rules live in manifest-root.md and widget-summary.md.

isMultiWidgetManifest is the entire shape test — there is no version flag. resolvePluginWidget fails closed: a widget slug against a v1 manifest, a bare keyword against a suite manifest, an unknown slug, or a v1 manifest missing assets.js all return null. Its defaults are name -> slug, category -> "content" (anything that is not the literal "layout"), ssr -> false, mediaSafeAuto only on a strict === true, and icon / readme dropped unless they pass their ref validators.

/contracts

ExportKindNotes
WidgetProps<T, D>typeProps every content widget receives
LayoutWidgetProps<T, D>typeWidgetProps plus renderedSlots (0.11.0+)
WidgetCategorytype"content" | "layout"
GetInitialDataCtx<T>type{options, widgetId, feedId, widgetType, isSSR?, cookies?, headers?, query?}
GetAwaitedFuncReturnType<T>typeAwaited<ReturnType<T>>
FeedtypeThe public-api feed row
Widget<T>typeA widget row, incl. plugin_manifest and plugin_manifest_url
SlotBindingtype{widgetId, ovveride?, override?}
PageWidgetDef, PageSchematypePage-schema shapes
isLayoutEntryfn(entry: {widget: PageWidgetDef}) => boolean
EXPLORER_BACKED_KEYWORDSconst["explorer", "search-bar", "search-results"]
ExplorerBackedKeywordtypeUnion of the three
isExplorerBackedKeywordfnType guard over the three
// packages/homerunner-widget-core/src/contracts.ts:27
export interface WidgetProps<T extends WidgetSchemaType = WidgetSchemaType,
                            D extends Record<string, any> | undefined = undefined> {
  options: T;
  target?: HTMLElement;
  widgetId: string;
  feedId: number;
  widgetType: string;
  resolvedTheme?: "light" | "dark";
  data: D;
}

export interface LayoutWidgetProps<T extends WidgetSchemaType = WidgetSchemaType,
                                   D extends Record<string, any> | undefined = undefined>
  extends WidgetProps<T, D> {
  /** Pre-rendered React nodes for each slot. Keys are slot names. */
  renderedSlots: Record<string, ReactNode>;
}

Import these instead of hand-writing a props interface. Which props actually arrive on which render path — and the fact that options is typed T but arrives raw for plugins — is normative in component-and-ssr-module.md.

SlotBinding.ovveride is a live legacy misspelling, kept as a read-only fallback because stored page schemas still carry it. When both keys are present, override wins per key. Write override; read both if you parse stored schemas yourself.

/urls

ExportSignatureNotes
PLUGIN_WIDGET_PREFIX"plugin:"
PluginKeywordPartstype{pluginSlug, widgetSlug: string | null, registryKey}
parsePluginKeyword(keyword: string) => PluginKeywordParts | nullregistryKey is everything after the prefix
pluginWidgetDistFile(parts: PluginKeywordParts, kind: "iife" | "css" | "ssr") => string
DEFAULT_PROXY_BASE_URL"https://assets.homerunner.io"Overridable per host page via window.HRWidgetRuntime.proxyBaseUrl
getPluginAssetUrl(slug: string, file: string, proxyBaseUrl?: string) => stringBuilds <base>/p/{slug}/{file}; a trailing slash on the base is stripped
isAbsoluteUrl(url: string) => boolean/^https?:\/\//i only
resolvePluginAssetUrl(manifestUrl: string | undefined, urlOrPath: string) => stringManifest URL first
distRelativeAssetName(urlOrPath: string) => stringKeeps the widget directory after dist/
extractAssetFilename(urlOrPath: string) => stringLast path segment, query and hash stripped

Two corrections worth stating outright, because both were previously documented wrong:

  • isAbsoluteUrl matches http:// and https:// and nothing else. A protocol-relative //cdn.example.com/x.js and a data: URI both count as relative and will be resolved against the manifest URL.
  • resolvePluginAssetUrl takes the manifest URL as its first argument, and throws when the path is relative and no manifest URL is known: Cannot resolve relative plugin asset "<x>": manifest URL not known. Ship absolute URLs in the manifest, or pass the manifest URL when fetching.

The URL each of these produces on each serving path, the /p/ proxy semantics and the CSP consequences are normative in assets-and-urls.md.

/globals

ExportKindNotes
HRWidgetRuntimetypeThe shape of window.HRWidgetRuntime
HRPluginstypeRecord<string, {component, category?}>
HRWidgetRegistrationtype{component, mount?, category?}
getRuntimefn() => HRWidgetRuntime; throws when the runtime IIFE has not loaded
registerPluginfn(key: string, component: ComponentType<any>, meta?: {category}) => void
registerPluginWidgetfn(pluginSlug, widgetSlug, component, meta?) => void (0.11.0+)

registerPluginWidget(a, b, C, m) is exactly registerPlugin(a:b, C, m). Both write window.HRPlugins[key], keyed by the keyword minus the plugin: prefix — {slug} for a single-widget plugin, {slug}:{widget} for a suite. Registration is idempotent (last write wins).

getRuntime() throws: [widget-core] window.HRWidgetRuntime is not available. Make sure the runtime.iife.js <script> tag loads before your widget entry.

The real HRWidgetRuntime carries React, ReactDOM and React Query flat — there is no ReactQuery namespace key:

// packages/homerunner-widget-core/src/globals.ts:15
export interface HRWidgetRuntime {
  React; ReactDOM; jsxRuntime;
  createRoot; hydrateRoot;
  QueryClient; QueryClientProvider;
  useQuery; useInfiniteQuery; useMutation; useQueryClient; useIsFetching; useIsMutating;
  dehydrate; hydrate;
  proxyBaseUrl?: string;
  resolveChildJsUrl?: (keyword: string) => string;
  apiBaseUrl?: string;
}

Not supported yet. The TypeScript type above is behind the live object. The deployed runtime IIFE also exposes widgetCore (the whole /runtime surface, which is how the evergreen externalization resolves), useQueries and keepPreviousData — none of which the interface declares. Reading them compiles only through a cast, and that cast is on you until the type is updated.

/runtime

Externalized at build time (0.10.0+). viteHomerunnerWidget maps @homerunner-next/widget-core/runtime to HRWidgetRuntime.widgetCore in the client IIFE, and the renderer’s SSR sandbox supplies its own copy. Your source is unchanged — you import normally and dev mode still resolves node_modules — but the code that ships to a customer page is the host fleet’s, not the copy you built against. Only /runtime is externalized; /globals, /schema, /plugin-schema, /spacing, /urls and /utils stay bundled.

Publishing a build stamped runtime.widgetCore < 0.10.0, or not stamped at all, is a publish ERROR — see packaging-and-publishing.md.

Mount and hydration

ExportSignatureAudience
mount(container: HTMLElement, config: MountConfig) => void — returns nothingauthor
MountConfigtype, eight fields (below)author
extractWidgetId(stringId: string) => {widgetId: string, feedId: number}author
ClientWrapperForWidgetcomponent — fetches feed + widget config, then render-props your widgetauthor
WidgetHydrationTreecomponent — {queryClient, portalContainer?, shadowHost?, children}platform-internal
getSharedQueryClient() => QueryClient — the window.HRWidget.__QUERY_CLIENT__ singletonauthor
// packages/homerunner-widget-core/src/runtime/mount.tsx:42
export interface MountConfig {
  widget: ComponentType<any>;
  widgetType: string;
  category?: WidgetCategory;                       // "layout" routes through LayoutCsrRenderer
  resolveChildJsUrl?: (keyword: string) => string; // layouts only
  cssUrls?: string[];
  extraCssUrls?: string[];
  resolveDefaultCssUrls?: (widgetType: string) => string[];
  shadowDOM?: boolean;                             // default true
}

WidgetHydrationTree plus an identifierPrefix is how the server and the client agree on useId output. Call mount(); do not call hydrateRoot yourself. What mount reads and writes in the DOM, its idempotency guard, per-embed failure isolation and the CSS-URL precedence rules are normative in runtime-and-mount.md.

Shadow DOM and portals

ExportSignatureAudience
setupShadowDOM(hostEl: HTMLElement, cssUrls: string[]) => ShadowDOMSetupResult | nullauthor (mount calls it)
ShadowDOMSetupResulttype {shadowRoot, shadowHost, reactRoot, portalContainer, cssReady, cssPending}author
migrateSSRContent(hostEl: HTMLElement, reactRoot: HTMLDivElement) => voidmoves nodes, never re-serializesplatform-internal
getCSSUrlsFromElement(el: HTMLElement) => string[] — reads the legacy data-hr-css attributeplatform-internal
isShadowDOMSupported() => booleanauthor
PortalContainerProvider / usePortalContainercontext provider / () => HTMLElement | undefinedauthor
ShadowHostProvider / useShadowHostcontext provider / () => HTMLElement | nullauthor
useTopLevelPortal(options?: UseTopLevelPortalOptions) => HTMLElement | undefinedauthor
UseTopLevelPortalOptionstype {cssString?: string, enabled?: boolean}enabled defaults to trueauthor

UseTopLevelPortalOptions has two fields. enabled: false skips creating the elevated portal and returns undefined, which is how an elevateDom-style switch falls back to the in-shadow portal. Dialog patterns and the token trap are in ../recipes/portals-and-dialogs.md.

Theme

ExportSignature
HOST_THEME_ATTR"data-hr-theme"
ResolvedHostThemetype "light" | "dark"
resolveHostTheme(colorScheme: string | undefined, fallback?: ResolvedHostTheme) => ResolvedHostTheme
applyHostTheme(host: Element | null | undefined, theme: ResolvedHostTheme) => void
attachHostThemeController(host, colorScheme, opts?: {fallback?}) => () => void — returns a dispose fn
mirrorHostTheme(source: Element, target: Element) => () => void

resolveHostTheme maps light/dark through unchanged, resolves auto and global against prefers-color-scheme, and returns fallback (default "light") for anything else. attachHostThemeController keeps the attribute live across an OS theme flip — the React resolvedTheme prop does not. Styling guidance is in ../recipes/styling-and-theming.md.

Registry and script loading

ExportSignatureAudience
registerWidget(type: string, registration: HRWidgetRegistration) => voidplatform-internal
getRegisteredWidget(type: string) => HRWidgetRegistration | undefinedauthor
whenWidgetRegistered(type: string) => Promise<HRWidgetRegistration> — rejects on a hard timeoutplatform-internal
HRWidgetRegistrationtype, re-exported from /globalsauthor
loadScriptOnce(src: string) => Promise<void> — deduped by srcauthor

Data and errors

ExportSignatureAudience
homerunnerApiBaseUrl() => stringauthor
fetchFeed(feedId: number) => Promise<Feed>author
fetchWidget<T>(widgetId: string) => Promise<Widget<T>>author
useFetchWidget<T>(feedId: number, widgetId: string) => UseQueryResult<{feed, widget}>author
getFinalSettings<T>(widget: Widget<T>, feed: Feed, optionsOverride?: Partial<T>) => Tauthor
WidgetFetchErrorclass (status: number, message: string), name === "WidgetFetchError"author
isRateLimitError(error: unknown) => boolean — status 429author
isPermanentError(error: unknown) => boolean — any 4xxauthor
retryTransient(max: number) => (failureCount, error) => booleanauthor
WidgetErrorStatecomponent {rateLimited, feedId?, widgetId?, notFoundProperty?, renderFailed?}author
WidgetRenderBoundaryclass component {widgetType, widgetId?, feedId?, hostEl?, themeEl?, resetKey?, children}platform-internal

getFinalSettings takes the widget first, then the feed. Its merge precedence and the reserved __parentColorScheme key are normative in ../concepts/settings-and-options.md.

homerunnerApiBaseUrl() resolves in three steps: window.HRWidgetRuntime.apiBaseUrl, then process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URL, then the hard fallback https://central.homerunner.io. There is no host sniffing.

fetchFeed and fetchWidget read res.status before parsing (a 429 gateway response may not be JSON) and throw WidgetFetchError with the messages Failed to fetch feed (HTTP <n>) and Failed to fetch widget (HTTP <n>).

There is no okOrThrow and no retryUnlessRateLimited in widget-core. Both names appear in HomeRunner’s private packages only; importing either fails to resolve. Use WidgetFetchError + retryTransient. Full fetching guidance: ../recipes/fetching-data.md.

Layout

ExportSignatureAudience
LayoutCsrRendererdefault-exported componentplatform-internal (mount uses it)
LayoutCsrRendererPropstype {feedId, widgetId, LayoutComponent, widgetType, optionsOverride?, resolveChildJsUrl?, rootElement?}author

Query-state helpers

ExportKind
dedupeDehydratedState(state, opts?: {minSharedBytes?}) => MaybeCompactDehydratedState
inflateDehydratedState(state: MaybeCompactDehydratedState) => DehydratedState — throws on a dangling ref
isCompactDehydratedStatetype guard
HR_COMPACT_REF_KEY"$hr"
HR_COMPACT_VERSION1
MIN_SHARED_BYTES32
CompactDehydratedState / MaybeCompactDehydratedStatetypes

All platform-internal — the renderer compacts the SSR query cache and getSharedQueryClient inflates it. You do not call these.

Visitor preferences

readVisitorPref<T>(scope, name): T | undefined, writeVisitorPref<T>(scope, name, value): void, VISITOR_PREF_TTL_MS (72 hours in milliseconds).

Deliberately not exported

These exist in src/runtime/ but are absent from the /runtime barrel, so you cannot import them: fetchPropertyBySlug, resolveMountCssUrls, hasRenderedChildren, UnmountableEl, adoptDeclarativeShadowRoot, waitForShadowLinks, applyPreloadReservation, resolvePreloadMinHeight, buildSlotChildOptions.

Not supported yet. Writing your own layout mount is therefore not possible with the published surface: the child-options builder and the DSD adoption helper are both private. Use mount() with category: "layout" — see layouts.md.

/utils

ExportSignature
useTranslation<K extends string>(store: Record<K, Record<string, string>>, currentLocale?: string) => {_t}
useLocaleDetection(useAutoDetectedLocale?: boolean, configuredLocale?: string) => {detectedLocale, ...helpers}
detectBrowserLocale() => string
getBestMatchingLocalelocale negotiation helper
findObjectByLocale / getObjectForCurrentLocale / getAvailableLocaleslocale-keyed object helpers
getProxiedImageUrl(imageUrl: string, width?: number, height?: number) => string — defaults 64 x 64
getFeedQuery(feedId: number) => {queryKey: ["feed", feedId], queryFn}
getWidgetQuery(widgetId: string) => {queryKey: ["widget", widgetId], queryFn}
WidgetProvider / useWidgetcontext carrying {options, feedId, widgetId, widgetType, data, resolvedTheme}
preloadMinHeightHint(widgetType: string, options?: unknown) => string | null

_t(key, defaultValue = key) resolves store[key][currentLocale], then store[key]["en_US"], then defaultValue. useTranslation holds no React state despite the name, so it is safe to call anywhere. useLocaleDetection deliberately seeds state with the configured locale and only switches to the browser locale one tick after mount — seeding from the browser would fork the SSR render from the client’s first render.

preloadMinHeightHint only knows system widget types. A plugin:* widget type always returns null, which is why a CSR-only plugin widget needs expectedHeight in its summary (or data-hr-min-height on the embed) to reserve space. See widget-summary.md.

Three different query keys exist for the same resource and they are not interchangeable: useFetchWidget caches under ["widget", feedId, widgetId], getWidgetQuery under ["widget", widgetId], getFeedQuery under ["feed", feedId]. Pick one and use it on both the server and the client.

/vite and hr-widget-build

ExportSignature
viteHomerunnerWidget(opts: ViteHomerunnerWidgetOptions) => {plugins, define, build, server}
ViteHomerunnerWidgetOptions{slug: string, command?: "serve" | "build", remoteName?: string, root?: string}
stampWidgetCoreVersion<T>(manifest: T, version?: string) => T — pure; returns a copy with runtime.widgetCore set

Defaults: remoteName is ${slug}${BUILD_WIDGET ? "_" + BUILD_WIDGET : ""} with every non-alphanumeric replaced by _; root is process.cwd(); an omitted command is treated as a build.

// pdp-suite (reference plugin): vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import { viteHomerunnerWidget } from "@homerunner-next/widget-core/vite";

export default defineConfig(({ command }) => {
  const hr = viteHomerunnerWidget({ slug: "pdp-suite", command });
  return {
    plugins: [react(), ...hr.plugins],
    define: hr.define,
    build: hr.build,
    server: { ...hr.server, port: 3001 },
  };
});

What the preset installs

Six plugins, in order: the SSR browser-globals tree-shaker, the widget style plugin (CSS resolver plus dev middleware), the widget asset proxy plugin, the large-asset emitter, the plugin-assets plugin, and the manifest generator.

define is {__HR_WIDGET_DEV__: "true" | "false"} plus — in dev only — every NEXT_PUBLIC_* key found in .env / .env.local injected as a process.env.X literal. Production builds skip that injection deliberately: a published IIFE must carry no environment URL and reads the base URL from window.HRWidgetRuntime.apiBaseUrl instead. A NEXT_PUBLIC_* key that works in npm run dev can therefore be undefined at SSR time.

server is {cors: true}.

What each pass emits

Single-widgetSuite (0.11.0+)
Client entry./src/index.tsx./src/widgets/{BUILD_WIDGET}/index.tsx
Client outputdist/{slug}.iife.jsdist/{widget}/{widget}.iife.js
CSS outputdist/{slug}.cssdist/{widget}/{widget}.css
SSR entry (BUILD_SSR=1)./src/ssr-entry.ts./src/widgets/{BUILD_WIDGET}/ssr-entry.ts
SSR outputdist/{slug}-ssr.umd.jsdist/{widget}/{widget}-ssr.umd.js
Dev CSS route/dist/{slug}/{slug}.css/dist/{slug}/{widget}/{widget}.css

Both passes set cssCodeSplit: false and sourcemap: true. The client pass is iife format named after remoteName and externalizes react, react/jsx-runtime, react-dom, react-dom/client, @tanstack/react-query and @homerunner-next/widget-core/runtime against HRWidgetRuntime. The SSR pass is cjs with exports: "named" and inlineDynamicImports: true — every dynamic import() is hoisted into the single umd, which is why browser-only side effects must sit behind a typeof guard.

The SSR tree-shaker rewrites typeof window | document | self | navigator | localStorage | sessionStorage to the literal "undefined" in every .js/.jsx/.ts/.tsx/.mjs/.cjs module on SSR passes only. Only the typeof form is rewritten — if (document) still runs against the sandbox’s truthy Proxy stub.

The manifest generator runs at closeBundle on the SSR pass (single-widget) or on the pass marked HR_BUILD_FINALIZE=1 (suite). It walks dist/, writes a {base}-{sha256[0:8]}{ext} twin beside every non-.map file, emits dist/manifest.json as {buildHash, generatedAt, files} keyed by dist-relative path, then rewrites your tracked root manifest.json through stampWidgetCoreVersion and logs runtime.widgetCore stamped: <version>. Never hand-write runtime.widgetCore.

Declared assets.fonts are read from the root manifest at config time and silently truncated to the per-widget maximum before they reach the client IIFE’s document.head font loader — an over-long list loses its tail with no warning at build or publish (the limit is in limits-and-errors.md). Asset imports and CSS url()s over the inline limit get ?no-inline appended so library mode emits them as files instead of data URLs. Details: assets-and-urls.md.

hr-widget-build (0.11.0+)

Run it through npm run build; never invoke vite build directly on a suite. Preconditions: run from the project root, manifest.json present, vite installed in the project.

  • Single-widget: two passes — vite build, then BUILD_SSR=1 vite build.
  • Suite: validates every summary first, then rm -rf dist/, then runs the flattened (widget x pass) list with BUILD_WIDGET={slug}, skipping the SSR pass for a widget declaring ssr: false, and marks the last pass HR_BUILD_FINALIZE=1.

Validation is fail-fast before any pass runs — the CLI prints and exits non-zero for an invalid slug, a reserved slug, a duplicate slug or a missing src/widgets/{slug}/index.tsx. A bare vite build on a project whose manifest declares widgets[] throws rather than producing a plausible-looking broken build. Every exact message is indexed in limits-and-errors.md.

/mock

Dev-only MSW browser mocking. Imported by your dev entry, never by src/index.tsx.

ExportSignature
startMockWorker(keyword: string, options?: MockOptions) => Promise<MockControl> — idempotent
createMockHandlers(keyword?: string, fixtures?: MockFixtures) returning MSW handlers — keyword defaults to plugin:dev-widget
MockControltype {worker, scenarios, getState, setScenario, setSettings, setDelay, setBranding, subscribe, stop}
MOCK_SCENARIOS["default", "empty", "single", "large", "loading", "error"]
MockScenario / MockState / MockOptions / MockFixtures / MockBrandingtypes
initMockState / getMockState / setMockState / subscribeMockStatethe shared mutable store
makeProperty(i: number)
makePropertiesResponse(count: number, total?: number)
makeReview(i: number)
makeReviewsResponse(count: number, total?: number, averageRating?: number)
makeFeedDetails(feedId: number, branding: MockBranding)
makeWidget(widgetId: string, feedId: number, keyword: string, settings: Record<string, unknown>)
scenarioCount(scenario: MockScenario) => numberempty 0, single 1, large 48, otherwise 6
DEFAULT_BRANDINGthe default MockBranding

MockOptions defaults: scenario: "default", delayMs: 300, widgetId: "dev-widget", feedId: 1. MockFixtures keys are properties, reviews, feedDetails, widget, feedWidgets, propertyFull, calendar, plus an open index signature; each value is raw JSON or a (request) => json factory. The worker starts with onUnhandledRequest: "bypass" and serviceWorker.url = "/mockServiceWorker.js".

// packages/homerunner-widget-core/src/mock/browser.ts:55
import { startMockWorker } from "@homerunner-next/widget-core/mock";

const control = await startMockWorker("plugin:pdp-suite:stay-hero", {
  scenario: "default",
  delayMs: 300,
  widgetId: "dev-widget",
  feedId: 1,
  widgetSettings: { section: { show: true, title: "Stay" } },
  branding: { colorScheme: "dark" },
});
control.setScenario("empty");   // also on window.__HR_MOCK__

Not supported yet. startMockWorker is a page singleton: repeat calls return the existing control, and the single keyword you passed is baked into the handlers — along with one scenario, one latency, one branding and one settings object for the whole page. A suite works around the keyword half with a widget fixture factory keyed by the requested id (what --template suite ships); the shared mock state has no workaround. See ../local-dev/dev-sandbox-and-mocking.md.

/testing

Node-only. Imports node:vm, node:http and msw/node.

ExportSignature
renderPluginSSR(opts: RenderPluginSSROptions) => Promise<RenderPluginSSRResult>
evaluatePluginSSR(ssrCode: string, apiBaseUrl: string, assetBaseUrl: string, filenameOrOptions?: string | EvaluatePluginSSROptions) => PluginSSRModule
buildEnforcedFetch(allowedHosts: string[] | undefined, baseFetch: typeof fetch, centralHost: string | null) => typeof fetch (0.12.2+)
SSR_EXEC_TIMEOUT_MSconst — the default module-scope budget (0.12.2+)
createNodeMockServer(keyword: string, options?: MockOptions) => NodeMockServer
createSSRDevServer(opts: SSRDevServerOptions) => SSRDevServer
resolvePluginSSRTarget({rootDir, slug, widget?}) => {target?: PluginSSRTarget, error?: string} (0.12.2+)
widgetFromInvocation(argv?: string[], env?: ProcessEnv) => string | undefined (0.12.2+)
RenderPluginSSROptions{ssrCode, filename?, ctx: SSRCtx, resolvedTheme?, apiBaseUrl?, assetBaseUrl?, ssrId?, externalFetch?, pluginAssetBase?, execTimeoutMs?}
RenderPluginSSRResult{html, dehydratedState, assets, props, ssrId}
EvaluatePluginSSROptions{filename?, externalFetch?, pluginAssetBase?, execTimeoutMs?} (0.12.2+)
PluginSSRModule{default, getInitialData?, dehydrateState?, getStaticAssets?}
PluginSSRTarget{keyword, widget: string | null, ssrFile, iifeFile, cssFile, externalFetch?} (0.12.2+)
SSRCtx{options: Record<string, unknown>, feedId: number, widgetId: string}
NodeMockServer{server, close}
SSRDevServer{server, url, ready, close} (ready 0.12.4+)ready resolves with the base URL once listening; with port: 0 read url only after awaiting it
SSRDevServerOptions{slug, widget?, rootDir?, port?, widgetId?, feedId?, mock?, hydrate?, runtimeUrl?, apiBaseUrl?, assetBaseUrl?, externalFetch?}

renderPluginSSR runs the production order: evaluate the umd, getInitialData, dehydrateState, getStaticAssets, renderToString. createSSRDevServer defaults: port 3003, widgetId: "dev-widget", feedId: 1, apiBaseUrl: "https://beta.homerunner.io", assetBaseUrl: "https://assets-dev.homerunner.io"; runtimeUrl defaults to ${assetBaseUrl}/w/runtime.iife.js only when hydrate: true.

Four options closed the harness’s fidelity gaps in 0.12.2, and createSSRDevServer handles all four so you never pass them: externalFetch (an array enforces those hosts, false disables enforcement, omitted reads the widget’s declared list out of manifest.json — the dev server passes the manifest’s), pluginAssetBase (the sandbox’s __HR_PLUGIN_ASSET_BASE__ — the dev server points it at its own /dist/), execTimeoutMs (module-scope budget, default SSR_EXEC_TIMEOUT_MS) and ssrId (the render’s identifierPrefix, default {widgetId}:{feedId}:0, returned in the result — the dev server stamps it into the envelope’s data-hr-ssr-id). What each one buys you, and what still differs from production, is in ../local-dev/ssr-preview.md; the production sandbox contract is normative in component-and-ssr-module.md.

Selecting a suite widget (0.12.2+)

createSSRDevServer takes widget. Unset, it falls back to widgetFromInvocation()--widget {slug} in argv, then --widget={slug}, then HR_SSR_WIDGET — so a scaffold’s dev:ssr script previews a suite widget without being edited. The name is resolved against the root manifest.json’s widgets[] at boot: it selects the keyword plugin:{slug}:{widget}, the nested dist/{widget}/{widget}-* bundles and that widget’s declared externalFetch. A single-widget (v1) plugin passes no widget and resolves plugin:{slug} with the flat dist/{slug}-* paths, unchanged.

resolvePluginSSRTarget is that same lookup, exported for hand-rolled harnesses (a loop over every widget, a slot-composing render). It returns its errors rather than throwing, so you choose how to surface them; createSSRDevServer throws the returned string. The four conditions and their messages are indexed in limits-and-errors.md.

// packages/create-hr-plugin/template-suite/scripts/dev-ssr.mts — validate, then boot
import {
  createSSRDevServer,
  resolvePluginSSRTarget,
  widgetFromInvocation,
} from "@homerunner-next/widget-core/testing";

const { target, error } = resolvePluginSSRTarget({
  rootDir: process.cwd(),
  slug: "acme-suite",
  widget: widgetFromInvocation(),
});
if (!target) {
  console.error(`\n  ${error}\n`);
  process.exit(1);
}

createSSRDevServer({ slug: "acme-suite", widget: target.widget ?? undefined, port: 3003 });

Registering a widget: the shape both bootstraps take

// pdp-suite (reference plugin): src/widgets/stay-hero/index.tsx
import { mount } from "@homerunner-next/widget-core/runtime";
import { registerPluginWidget } from "@homerunner-next/widget-core/globals";
import StayHero from "./widget";
import "./stay-hero.css";

registerPluginWidget("pdp-suite", "stay-hero", StayHero);

function doMount() {
  document
    .querySelectorAll<HTMLElement>("[data-hr-widget-container]")
    .forEach((container) => {
      if (container.querySelector('[data-hr-widget="plugin:pdp-suite:stay-hero"]')) {
        mount(container, { widget: StayHero, widgetType: "plugin:pdp-suite:stay-hero" });
      }
    });
}

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", doMount);
} else {
  doMount();
}

A layout adds { category: "layout" } to both calls — see ../get-started/04-add-a-layout.md.

Feature-to-version map

Gate anything you read here against the SDK version you actually installed.

VersionWhat it added
0.9.0spacing replaces width on the base schema; parseWidgetConfig
0.10.0Evergreen runtime — /runtime externalized, runtime.widgetCore stamped; the publish floor
0.10.1shadowDOM manifest flag; the raw-options contract codified
0.11.0Suites, registerPluginWidget, hr-widget-build, layout widgets, presets, mediaSafeAuto, enforced externalFetch
0.12.0assets.fonts, bundled assets, layout pages, marketplace icon / cover
0.12.1Slot prefill
0.12.2/testing: suite SSR preview (widget, resolvePluginSSRTarget, widgetFromInvocation) and five sandbox-fidelity fixes — /runtime shared, TextEncoder/TextDecoder removed, __HR_PLUGIN_ASSET_BASE__ defined, externalFetch enforced, module scope bounded, plus a reproducible hydration tree

Nothing in 0.12.2 changes what your published plugin does — it is a local-tooling release. Your build still stamps runtime.widgetCore with whatever you built against.

This page documents 0.12.2. What the npm registry currently serves is older, and the consequences (no hr-widget-build bin, no suite support) are stated once with a date in ../get-started/01-install-and-versions.md.

Signatures the previous SDK reference got wrong

If you have an older copy of this page, these are the differences that will break code.

SymbolPreviously documentedActual
getFinalSettings(feed, widget, override?)(widget, feed, optionsOverride?)
resolvePluginAssetUrl(slug, urlOrPath, manifestOrigin?)(manifestUrl, urlOrPath); throws when relative with no base
isAbsoluteUrltrue for http://, https://, //, data:^https?:// only
DEFAULT_PROXY_BASE_URLhttps://dashboard.homerunner.iohttps://assets.homerunner.io
MountConfigfive fieldseight fields, incl. category, extraCssUrls, resolveChildJsUrl
UseTopLevelPortalOptions{cssString?}{cssString?, enabled?}
HRWidgetRuntime{React, ReactDOM, jsxRuntime, ReactQuery, …}React Query exports are flat; there is no ReactQuery key
HRPluginsRecord<string, {component}>Record<string, {component, category?}>
widgetSchemacarries widthcarries spacing (since 0.9.0)
PluginUiLayoutSpec{layout, label, fields}{layout?, label?, description?, showIf?} — no fields
PluginUiField.componenteleven tokenstwelve — WidgetSelect was missing
/spacing, /schema parse helpers, ~25 /runtime exportsabsentdocumented above

Public API

Every endpoint your plugin may call lives under /public-api/v1 on HomeRunner Central. It is the only HomeRunner data API a plugin can reach: there is no plugin-scoped API, no API key and no per-plugin identity. The identifier in the URL — a feed id, a platform UUID, a property UUID, a widget id — is what authorises the read.

Everything on this page applies to both manifest shapes (single-widget and suite). The API does not know which shape you shipped. The one shape-dependent rule is where you declare externalFetch, covered in Server-side.

Enforcement on this page is an HTTP response, not a publish-time check. No audit inspects the URLs your code builds, so a call with a misspelled parameter passes review and misbehaves silently in production. Each parameter table’s On violation column says what actually happens; advisory (unvalidated) means the value is accepted and then ignored or clamped with no error. The book-wide enforcement legend lives in Limits and error index.

Base URL

Resolve the host at runtime. Never hard-code it — the same plugin bytes are served to production, staging and local rigs, and only the runtime knows which Central it is paired with.

// packages/homerunner-widget-core/src/runtime/api.ts:33-44 (verbatim)
export function homerunnerApiBaseUrl(): string {
  if (typeof window !== "undefined") {
    const fromRuntime = window.HRWidgetRuntime?.apiBaseUrl;
    if (fromRuntime) return fromRuntime;
  }
  const envValue =
    typeof process !== "undefined"
      ? process.env?.NEXT_PUBLIC_HOMERUNNER_BASE_URL
      : undefined;
  if (envValue) return envValue;
  return "https://central.homerunner.io";
}

Resolution order, highest first:

#SourceWhere it is set
1window.HRWidgetRuntime.apiBaseUrlBaked into the env-matched runtime IIFE the host page loads. Authoritative in the browser on customer pages, the dashboard playground, staging and production.
2process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URLVite-injected from your .env.local during npm run dev; also whitelisted into the renderer’s SSR sandbox environment.
3https://central.homerunner.ioHard fallback.

Build every URL as `${homerunnerApiBaseUrl()}/public-api/v1/...`. The helper is exported from @homerunner-next/widget-core/runtime — see SDK reference.

Authentication

There is no token, header or signature. Two middlewares decide whether a request is served, and which one a route uses determines which identifier you must supply.

MiddlewareWhat it resolvesFailure
auth.public.v1The first present of platform_uuidproperty_uuidwidget_idfeed_id, preferring the route segment. Query fallbacks are ?platform=, ?property=, ?widget=, ?feed= (each logs a server-side warning). Confirms the resource exists; a platform must be active.404 NOT_FOUND for an unknown platform, property, widget or feed; 400 BAD_REQUEST with Platform is not active., or with Platform UUID, Property UUID, Widget ID, or Feed ID is required when none is present.
auth.public.feed.v1feed_id only — route segment, else ?feed_id=, else ?feed=.401 UNAUTHENTICATED when it is missing; 404 NOT_FOUND when the feed does not exist.
(none)GET /widgets and GET /plugins/{slug} carry no auth middleware at all. Both enforce their own gate in the controller.See their entries below.

Because auth.public.v1 resolves the first identifier it finds, a request that carries both a route feed_id and a ?platform= UUID resolves as the platform. Prefer route segments over query fallbacks: the query forms work but are logged as suspect and may be withdrawn.

Always spell the query parameter feed_id, never feed. auth.public.feed.v1 accepts either, but the controllers behind it read only feed_id (or the route segment). Sending ?feed= therefore passes authentication and then fails inside the controller with 400 Feed ID is required. — an error that looks like a bug in your code and is not.

Both middlewares then run one shared availability gate over every feed the request touches, not just the identifier that matched. Three states refuse the request:

ConditionStatuserror.codeerror.message
Feed switched off at the edge403FEED_DISABLEDThis feed is no longer being served.
Feed archived403FEED_ARCHIVEDThis feed has been archived.
Owning account suspended403ACCOUNT_SUSPENDEDYour account is suspended. Please contact support.

Treat all three as permanent for the life of the page: do not retry, render your empty or error state. A property attached to several feeds is refused only when every one of them is blocked.

Not supported yet. A plugin has no credential of its own. You cannot prove to Central that a request came from your widget rather than from anyone who read the feed id out of the page source, and you cannot request elevated scope. Everything reachable here is data the feed already publishes. Do not design a feature that depends on privileged reads.

Rate limit

300 requests per minute, per client IP, across every /public-api/* endpoint combined.

// homerunner-central/app/Providers/RouteServiceProvider.php:73 (verbatim)
RateLimiter::for('public-api', fn (Request $request) => Limit::perMinute(300)->by($request->ip()));
  • The throttle runs before authentication, so refused requests (bad feed id, archived feed, 404s) consume budget too.
  • Successful responses carry X-RateLimit-Limit and X-RateLimit-Remaining.
  • The bucket is the IP, not the feed or the widget. On a server-rendered page every widget on that page shares the renderer’s IP; one cold property page can easily cost a dozen calls. Batch and cache accordingly, and see Fetching data.
  • A 429 body is the canonical error envelope with error.code TOO_MANY_REQUESTS, but a gateway may answer before Central does — always read res.status before parsing JSON.

Not supported yet. You cannot read your remaining budget from a 429. The throttle raises an exception, and the exception handler rebuilds the response as the canonical envelope — the throttle’s own Retry-After and X-RateLimit-Reset headers do not survive that rebuild. Separately, Central’s CORS configuration sets exposed_headers to an empty list, so browser JavaScript cannot read X-RateLimit-* even on a success. Back off on a schedule of your own; do not wait for a header. Server-side code in the SSR sandbox is not subject to CORS, but in a normal render its GETs go through the sandbox’s cached-fetch wrapper, which rebuilds the response with Content-Type only — so it cannot read the success headers either. They survive only on non-GET requests and in ?dev=1 renders, which bypass the cache.

CORS is otherwise fully open for reads: allowed_origins: ["*"], allowed_methods: ["*"], supports_credentials: false. Never send credentials or cookies — they would be rejected.

Response envelope

Success — two shapes, not one

There is no single success envelope. Older controllers wrap the payload in result, newer ones in data. Check the per-endpoint entry before writing a type.

// Shape A — older controllers (helper: centralApiResponse; injects status_code)
{ "status": "ok", "status_code": 200, "result": { }, "pagination": { } }

// Shape B — newer controllers
{ "success": true, "status_code": 200, "data": { }, "meta": { } }

Shape A endpoints: /details, all /feed/* property and meta routes, /property/{id}, /property/{id}/full, /property-*, /reviews, /feed/widgets, /feed/plugins, /plugins/{slug}, quote reads and writes (result). Shape B endpoints: /widget/{id}, /widgets, /property/{id}/payment-settings, the two Stripe routes, /{quote_id}/checkout-url, /reservations/{id} and its payment-methods POST.

Errors — one canonical envelope

Every /public-api/* error — thrown, forwarded, validation failure, unmatched route, wrong verb, 429 — is funnelled through one helper by the exception handler.

// homerunner-central/app/PublicApi/V1/Helpers/ErrorEnvelope.php:98-109
{
  "success": false,
  "status_code": 404,
  "error": {
    "code": "NOT_FOUND",
    "message": "The requested resource was not found.",
    "details": {}          // ALWAYS a JSON object. {} when there is nothing to say.
  }
}

details is deliberately coerced to an object so {} never arrives as []. On a 422 it is { "<field>": ["<message>", …] }.

Status → error.code and the default error.message, verbatim from ErrorEnvelope::codeForStatus and ErrorEnvelope::defaultMessageForStatus:

Statuserror.codeDefault error.message
400BAD_REQUESTThe request was invalid.
401UNAUTHENTICATEDAuthentication is required to access this resource.
402PAYMENT_FAILEDPayment could not be processed.
403FORBIDDENYou do not have permission to access this resource.
404NOT_FOUNDThe requested resource was not found.
405METHOD_NOT_ALLOWEDThe HTTP method used is not allowed for this endpoint.
409CONFLICTThe request could not be completed due to a conflict.
422VALIDATION_ERRORThe request could not be validated.
429TOO_MANY_REQUESTSRate limit exceeded. Please try again later.
≥ 500INTERNAL_SERVER_ERRORSomething went wrong. Please try again later.
any other 4xxERRORAn error occurred.

Which message you actually see

This is the rule that surprises people. In production (APP_DEBUG off) most of the specific messages a controller throws are discarded and replaced by the status default above. Do not string-match on them.

StatusWhat error.message contains in production
400, 402, 409, 422The throw site’s own message, stripped of ids, emails, URLs and payment-processor tokens — provided it passes a guest-safety screen. A message longer than 300 characters, starting with {, [ or <, or containing any of SQLSTATE, Stack trace, vendor/, .php, Exception:, DOCTYPE, token, secret, credential, whitelist, api key, client_id, authorization server, redis, connection refused, undefined array key, undefined variable, undefined index, call to a member, must be of type, curl error, could not resolve, ssl certificate, {", </, platform= is dropped and replaced by the status default. This is why Platform credentials are missing reaches you as The request was invalid.
401, 403, 404, 405, 429, 5xxAlways the status default. The specific text (Feed not found, This feed has been archived., Quote not found.) exists only when debug mode is on, which production never is.

Field-validation failures are the exception to the exception: a controller that runs a validator builds the envelope itself, so a 422 always arrives verbatim as error.message Validation failed with error.details set to { "<field>": ["<message>", …] }. That map is the only part of any error response you can usefully show a customer.

Endpoint sections below therefore quote a message verbatim only where production really emits it — everywhere else they name the status and error.code.

Nine codes are emitted outside the status table by middleware and controllers that build their response directly, and are therefore always verbatim:

error.codeStatusEmitted by
FEED_DISABLED, FEED_ARCHIVED, ACCOUNT_SUSPENDED403the shared availability gate
MISSING_PARAMETER400GET /widgets with no ids
WIDGET_NOT_FOUND404GET /widget/{id}
NOT_FOUND / The requested plugin was not found.404GET /plugins/{slug}
QUOTE_EXPIRED, QUOTE_EXPIRED_UNAVAILABLE, QUOTE_EXPIRED_PRICE_CHANGED409POST /{quote_id}/reservation

Match on status_code first and treat error.code as a refinement — these responses also serialise details as [], not {}, because they bypass ErrorEnvelope.

Business rejections forwarded from a PMS do survive: a Guesty refusal such as Listing is not available. minimumStay does not meet booking criteria. arrives at 400 with its text intact, and is safe to render.

Pagination

Three different conventions are live. Using the wrong page-size parameter is the single most common plugin bug — it fails silently and you get the default page size.

Endpoint familyPage-size paramDefaultBoundspagination key style
/{feed_id}/feed/properties, /{platform_uuid}/propertieslimit10none enforcedcamelCase
/feed/related-propertieslimit3none enforcedcamelCase
/property-imageslimit501–100, 422 outsideboth styles in one object
/feed/property-nameslimit100clamped 1–500camelCase
/feed/availabilitylimit20clamped 1–50camelCase
/reviewsper_page101–20, 422 outsidesnake_case

Every paginated endpoint takes page (1-based, default 1).

  • camelCase block: { page, pageSize, totalPages, totalItems, hasNextPage, hasPrevPage }.
  • snake_case block: { page, page_size, total_pages, total_items, has_next_page, has_prev_page }.
  • /property-images emits both key sets in the same object.
  • /reviews additionally carries a deprecated pagination.average_rating; read meta.average_rating instead.

per_page is advisory (unvalidated) on the property endpoints — it is accepted, never read, and you silently receive 10 rows. Three of the four published reference plugins (featured-stays, stats-spotlight, amenity-match) ship this bug against /feed/properties today. Use limit there and per_page only on /reviews.

/{feed_id}/feed/collection and /{feed_id}/feed/property-pins are not paginated at all and ignore limit/page entirely.

Identifying a property

A {property_identifier} path segment and the ?property= query parameter are feed slugs, resolved through the feed’s own property table against property_slug or additional_property_slug. A property UUID does not resolve there.

FormResolves byRequires
{property_identifier} in the path, ?property= in the queryfeed slug (or additional slug)a feed_id, always — without it you get 400 Feed ID is required to resolve property.
{property_uuid} in the path (/{property_uuid}/calendar, /property/{property_uuid}/quotes)properties.uuid, resolved by the auth middlewarenothing else
id in a list-endpoint rowuuid ?? id — the public identity of a property is its UUID

A slug resolves only when the property is still active and still a member of that feed; otherwise 404 NOT_FOUND. A feed-scoped payload also overlays the feed’s own overrides (title, guest cap, rating, meta fields, slug) on top of the base row.

Endpoint index

All paths are relative to {base}/public-api/v1. feed = auth.public.feed.v1, v1 = auth.public.v1.

MethodPathAuthPurpose
GET/{feed_id}/detailsv1Feed row
GET/{feed_id}/feed/propertiesv1Property cards, filtered + paginated
GET/{feed_id}/feed/collectionv1Whole catalogue, flat scalar rows, unpaginated
GET/{feed_id}/feed/availabilityv1Multi-property calendar grid
GET/{feed_id}/feed/related-propertiesv1Same-city cards for one source slug
GET/{feed_id}/feed/explorer-metav1Filter vocabulary across the feed
GET/{feed_id}/feed/property-namesv1name + slug suggestions
GET/{feed_id}/feed/property-pinsv1Whole filtered set as map pins
GET/{feed_id}/feed/widgetsv1Widgets configured on the feed
GET/{feed_id}/feed/pluginsv1Plugins installed and enabled on the feed
GET/{platform_uuid}/propertiesv1Property cards for one platform
GET/{platform_uuid}/explorer-metav1Filter vocabulary for one platform
GET/property/{property_identifier}feedOne property, columns only
GET/property/{property_identifier}/fullfeedProperty + images + descriptions + amenities
GET/property/{property_identifier}/payment-settingsfeedPublic payment config
POST/property/{property_identifier}/stripe/setup-intentsfeedStripe SetupIntent
POST/property/{property_identifier}/stripe/verify-cvcfeedServer-side CVC check
GET/property-detailsfeedOne property, columns only, no path segment
GET/property-descriptionsfeedLong-form descriptions
GET/property-amenitiesfeedAmenity list
GET/property-spacesfeedRooms and bed breakdown
GET/property-imagesfeedGallery, paginated
GET/property-calendarfeedAvailability + pricing by date
GET/{property_uuid}/calendarv1Same, addressed by UUID
GET/property/{property_uuid}/calendarv1Same, addressed by UUID
GET/reviewsfeedReviews with filters
POST/quotesfeedCreate a quote
POST/{property_uuid}/quotesv1Create a quote, addressed by UUID
POST/property/{property_uuid}/quotesv1Create a quote, addressed by UUID
GET/quotes/{quote_id}feedRead a quote
POST/quote/updatefeedMutate a quote
GET/{quote_id}/checkout-urlfeedPMS-hosted checkout URL
POST/{quote_id}/reservationfeedConvert a quote to a reservation
GET/reservations/{reservation_id}feedConfirmation lookup (hash-gated)
POST/reservations/{reservation_id}/payment-methodsfeedAttach or retry payment (hash-gated)
GET/widget/{widget_id}v1One widget row
GET/widgets?ids=noneBulk widget rows
GET/plugins/{slug}nonePlugin manifest metadata

An unmatched path returns 404 NOT_FOUND; a wrong verb returns 405 METHOD_NOT_ALLOWED. There is no fallback route.

Feed

GET /{feed_id}/details

Auth auth.public.v1. No parameters.

Returns the feed row as stored, minus its encrypted credentials, plus a computed development_mode_active boolean: { id, user_id, name, feed_url, additional_info, archived_at, development_mode, development_mode_expires_at, edge_disabled_at, development_mode_active, created_at, updated_at } under result.

additional_info.branding is the feed’s theme (colorScheme, lightModeColors.accent, darkModeColors.accent, font.size, font.family) — the values that back the base config fields described in Config schema and UI schema.

The endpoint does not eager-load relations. platforms[], properties_count and slug are absent from the response even though the SDK’s exported Feed type declares the first two. Treat them as optional and never index into them without a guard.

fetchFeed(feedId) from @homerunner-next/widget-core/runtime wraps this endpoint, throwing WidgetFetchError unless res.ok and body.status_code === 200.

Properties

GET /{feed_id}/feed/properties

Auth auth.public.v1. The primary listing endpoint: filtered, sorted, paginated property cards. GET /{platform_uuid}/properties is the same controller scoped to one platform.

Only feed members with status = 1 are returned; being on one of the feed’s platforms is not enough.

Paging and scope

ParamTypeRequiredRuleOn violation
limitintnoPage size. Default 10. No maximum is enforced.advisory (unvalidated) — a non-numeric value is not rejected here
pageintno1-based. Default 1.out-of-range page returns an empty result
platformUUIDnoNarrows to one platform inside the feed’s scope.unknown UUID is ignored, not an error
platform_idintnoSame, by internal id.advisory (unvalidated)
localestringnoCanonical locale (fr_fr). Localises amenity and tag name, and adds a labels map.unknown locale falls back to the PMS name

Filters — all optional, all applied with AND between them. A blank or 0 value reads as absent everywhere (price_min=0, bedrooms=0 and guests=0 do nothing).

ParamTypeRuleOn violation
typescsv intProperty type ids.advisory (unvalidated); unknown ids simply match nothing
amenitiescsv intMatch any of these amenity ids.advisory (unvalidated)
amenities_andcsv intMatch all of these amenity ids.advisory (unvalidated)
tags, locations, groups, citiescsv intTaxonomy term ids.advisory (unvalidated)
state, countrycsv stringFree-text columns, exact match on any listed value.advisory (unvalidated)
featuredbooltrue only. Parsed with FILTER_VALIDATE_BOOLEAN, so "false" correctly disables it.advisory (unvalidated)
petsboolPet-friendly only. Same parsing.advisory (unvalidated)
guestsintmaximum_guest_count >= n.advisory (unvalidated)
bedroomsintbedroom_count >= n.advisory (unvalidated)
bedrooms_eqintExact bedroom count. Overrides bedrooms when both are sent.advisory (unvalidated)
bathroomsintbathroom_count >= n.advisory (unvalidated)
price_fieldbase | lowest | averageChooses which stored price column price_min/price_max compare against and which one sort_by price sorts read. Anything else falls back to base.silently falls back to base
price_min, price_maxfloatInclusive bounds on the price_field column.advisory (unvalidated)
statusactive | inactive | boolProperty status.advisory (unvalidated)
keywordsstringLIKE %value% on properties.name only — not on title.advisory (unvalidated)
excludecsv intExclude these Central property ids.advisory (unvalidated)
ctidcsvRestrict to these ids — matched against either the integer id or the UUID.advisory (unvalidated)
exclude_propertycsv slugExclude these feed slugs. Needs a resolved feed.ignored without a feed id
include_propertycsv slugRestrict to these feed slugs. If none resolve, the result is empty — it does not fall back to the whole feed.ignored without a feed id
map_boundswest,south,east,northGeo box. Properties without both coordinates are excluded.ignored unless exactly 4 numeric parts

Dated availability — supply checkin and checkout to switch the endpoint into availability mode.

ParamTypeRuleOn violation
checkin, checkoutdateBoth required together. Parsed with strtotime.400 Invalid checkin or checkout date.
checkin must be strictly before checkout.400 Checkout date must be after checkin date.
adults, childrenintWhen either is present, guests is recomputed as adults + children, overwriting any guests you sent.advisory (unvalidated)
guestsintCapacity filter. In a dated search with no guest count anywhere, it defaults to 1 so the date filter still applies.advisory (unvalidated)

A dated response adds datesPrice: { total, average, lowest, nights } per card, computed over the requested window.

Sortingsort_by, one of:

featured · title_asc · title_desc · bedrooms_asc · bedrooms_desc · bathroom_asc · bathroom_desc · guests_asc · guests_desc · lowest_price_asc · lowest_price_desc · average_price_asc · average_price_desc · base_price_asc · base_price_desc · created_asc · created_desc · rating_asc · rating_desc · random

Note the asymmetry: bedrooms_* is plural, bathroom_* is singular. sort_by is advisory (unvalidated) — an unrecognised value produces no error and no ordering clause. rating_* is feed-scoped and a no-op on the platform route. random accepts sort_seed (integer); without one the seed is the current date, so the order is stable for a day. A deterministic tiebreaker on property id is always appended last, so paging is repeatable.

Response (Shape A). Each row:

// homerunner-central/app/PublicApi/V1/Controllers/PropertyController.php:2053-2127
{
  "id": "uuid-or-id", "propertyType": "Villa", "title": "Big Fish Lodge",
  "price": 420,                       // integer, base_daily_rate
  "lowestPrice": 380, "averagePrice": 450, "highestPrice": 610,
  "bookableNights": 120,              // null = no forward calendar at all
  "calendarSyncedAt": "2026-09-01T04:00:00+00:00", "calendarStale": false,
  "images": ["https://…"], "altText": "Big Fish Lodge", "tag": "Lakefront",
  "location": { "label": "Asheville", "lat": 35.5951, "lng": -82.5515 },
  "type": { "id": 7, "name": "Villa" }, "city": { "id": 12, "name": "Asheville" },
  "groups": [], "locations": [], "tags": [], "amenities": [],   // {id,name}[]
  "subtitle": { },
  "features": { "guests": 8, "baths": 3, "beds": 4 },
  "max_pets": 2, "updated_at": "2026-08-30T11:02:00.000000Z",
  "slug": "big-fish-lodge",           // present only on feed-scoped calls
  "rating": { },                      // present only on feed-scoped calls
  "datesPrice": { }                   // present only on a dated search
}

slug is the join key for every per-property endpoint. id is uuid ?? id and is what map pins and card dedupe compare against.

GET /{feed_id}/feed/related-properties

Auth auth.public.v1. Cards in the same city as a source property, source excluded.

ParamTypeRequiredRuleOn violation
slugstringyesThe source property’s feed slug. Named slug, not property?property= is consumed by the auth middleware as a UUID and 404s before the controller runs.400 Property slug is required.
limitintnoDefault 3.as /feed/properties
platformUUIDnoNarrows within the feed.ignored if unknown
all /feed/properties filtersnoPassed through. City and source-exclusion are applied first and cannot be overridden.as above

Default order is is_featured desc, created_at desc; sort_by overrides it. A source property with no city returns 200 with an empty result and a zeroed pagination block — not an error. Response shape is identical to /feed/properties.

GET /{feed_id}/feed/collection

Auth auth.public.v1. The feed’s entire active catalogue as flat, scalar-only rows, in one unpaginated response. Filters, sorting, limit and page are all ignored.

Every key is present on every row (null-padded): id, title, slug, property_type, alt_text, tag, price, price_lowest, price_average, price_highest, currency_code, lat, lng, location_label, city, state, country, post_title, meta_title, meta_description, guests, baths, beds, rating_average, rating_count, amenities, tags, locations, image_count, post_excerpt_length, post_excerpt_1…5, image_1…10. Taxonomies are comma-joined name strings; the description is split into five ≤ 2000-character parts with the true length in post_excerpt_length.

This endpoint loads the whole catalogue in one query. Do not call it from a widget render path.

GET /{feed_id}/feed/property-pins

Auth auth.public.v1. The full filtered set reduced to map markers — the answer to “/feed/properties only gives me pins for the current page”.

ParamTypeRequiredRuleOn violation
capintnoServer cap on rows. Default 2000, clamped to 50–2000.silently clamped
platform, platform_id, price_fieldnoAs /feed/properties.as above
/feed/properties filters and dated paramsnoShared code path, so the pin set can never disagree with the card set.as above

map_bounds, sort_by, limit and page are stripped before filtering — the endpoint is never viewport-bounded and never ordered.

Response: result: { pins: [{ id, slug, title, lat, lng, price?, currency? }], total, capped }. total is how many properties matched, not how many were returned; capped is true when the cap truncated the set. price/currency appear only when the stored price is greater than zero. Undated requests are cached server-side for a short window (see Limits and error index); dated ones never are.

GET /{feed_id}/feed/property-names

Auth auth.public.v1. Lightweight { name, slug } suggestions. Rows with no feed slug are skipped.

ParamTypeRequiredRuleOn violation
qstringnoCase-insensitive LIKE on name or title.
limitintnoDefault 100, clamped 1–500.silently clamped
pageintnoDefault 1.

GET /{feed_id}/feed/availability

Auth auth.public.v1. Multi-property calendar rows, one per property.

ParamTypeRequiredRuleOn violation
startdatenoDefaults to today.400 Invalid start or end date.
enddatenoDefaults to start + 30 days. Must be ≥ start.400 End date must be on or after start date.
Span capped at 92 days.400 Date range must not exceed 92 days.
platformUUIDnoMust belong to this feed.404 NOT_FOUND
guestsintnomaximum_guest_count >= n.advisory (unvalidated)
limitintnoDefault 20, clamped 1–50.silently clamped
pageintnoDefault 1.

Each row: { id, slug, title, thumbnail, currency_code, availability }, where availability is keyed by date with the same per-day object as the calendar endpoints (below) and is {} when the property has no rows in the window.

GET /property/{property_identifier} and GET /property-details

Auth auth.public.feed.v1. The same columns-only projection, reachable two ways.

ParamTypeRequiredRuleOn violation
feed_idintyesRequired by auth.public.feed.v1, and again by slug resolution. /property-details treats it as optional in the controller, but the middleware does not.401 UNAUTHENTICATED when absent; 400 Feed ID is required to resolve property. when only ?feed= was sent
propertystring/property-details: yesFeed slug.400 Property identifier is required.

Returns the property’s own columns with credential-bearing relations stripped, plus city: {id,name}, type: {id,name}, max_pets, and — on /property-details with a feed — a rating aggregate. Feed-level overrides (title, guest cap, rating, meta_title, meta_desc, explorer_subtitle, multi_calendar_title, slug) are overlaid where set.

fetchPropertyBySlug(feedId, slug) in @homerunner-next/widget-core/runtime wraps /property-details and returns body.result ?? {}.

GET /property/{property_identifier}/full

Auth auth.public.feed.v1. The one endpoint that returns a property with its amenities, images and descriptions pre-joined.

ParamTypeRequiredRuleOn violation
feed_idintyes400 Feed ID is required.
includecsvnoOpt in to extra blocks: reviews, calendar. Nothing else is recognised.unknown values ignored
image_limitintnoDefault 50.advisory (unvalidated)
description_limitintnoDefault 100.advisory (unvalidated)
review_limitintnoDefault 10. Only with include=reviews.advisory (unvalidated)
from, todatenoCalendar window. Only with include=calendar. Defaults today → today + 1 year.advisory (unvalidated)

Result: { property, images[], descriptions[], amenities[], reviews?: { items[], average_rating }, calendar?: { "YYYY-MM-DD": {…} } }. Descriptions are ordered with the platform’s default locale first — read descriptions[0] only as a fallback.

GET /property-descriptions, /property-amenities, /property-spaces

Auth auth.public.feed.v1. All three require feed_id and property (a feed slug): 400 Feed ID is required. / 400 Property identifier is required.; 404 NOT_FOUND for an unknown feed or a property that is not in it; 403 FORBIDDEN for an archived feed.

EndpointExtra paramsresult
/property-descriptionslimit (default 100){ default_locale, descriptions[] } — default-locale row ordered first
/property-amenities{ amenities: [{ id, name, … }] }
/property-spaces{ spaces: [{ id, name, number, type, privacy, has_private_bathroom, beds, total_bed_count }] }

GET /property-images

Auth auth.public.feed.v1.

ParamTypeRequiredRuleOn violation
property or property_uuidstringyesFeed slug. The legacy property_uuid spelling is accepted and is also read as a slug.401 UNAUTHENTICATED
feed_idintyesRequired by the auth middleware and by slug resolution.401 UNAUTHENTICATED; 400 Feed ID is required to resolve property. when only ?feed= was sent
limitintnoDefault 50. Validated 1–100.422 VALIDATION_ERROR
has_caption0/1/true/falsenoOnly captioned / only uncaptioned.422 VALIDATION_ERROR
position_min, position_maxint ≥ 0noInclusive position window.422 VALIDATION_ERROR
pageintnoDefault 1.

result is [{ url, caption, position }] ordered by position. meta.property.name carries the property name. This is the only endpoint that emits both pagination key styles.

Calendars

GET /property-calendar (auth auth.public.feed.v1, params feed_id + property slug) and GET /{property_uuid}/calendar / GET /property/{property_uuid}/calendar (auth auth.public.v1, addressed by UUID) return the same object.

ParamTypeRequiredRuleOn violation
fromdatenoDefault today.an unparseable date is not validated and surfaces as 500 INTERNAL_SERVER_ERROR
todatenoDefault today + 1 year.as above
// homerunner-central/app/PublicApi/V1/Controllers/PropertyController.php:2517-2542
{
  "status": "ok",
  "result": {
    "2026-01-15": {
      "date": "2026-01-15",
      "available": 1, "checkin": 1, "checkout": 0,   // 0 / 1, not booleans
      "min_stay": 2, "max_stay": 30,
      "price": 150.00
    }
  }
}

Dates with no calendar row are simply absent from the map — iterate your own date range and treat a missing key as unknown, not as available.

Explorer metadata

GET /{platform_uuid}/explorer-meta and GET /{feed_id}/feed/explorer-meta, auth auth.public.v1. The feed variant aggregates and de-duplicates across every platform on the feed and returns 404 NOT_FOUND when the feed has none.

ParamTypeRequiredRule
price_fieldbase | lowest | averagenoWhich column the returned priceRange bounds are computed from. Must match the price_field you send to /feed/properties or your slider and your filter disagree.
localestringnoLocalises amenity and tag labels and adds a labels map.
// homerunner-central/app/PublicApi/V1/Controllers/PropertyController.php:798-820
{
  "placeTypes": ["Villa"],                        // name strings
  "priceRange": { "min": 80, "max": 950 },
  "amenities":  ["Hot Tub"],                      // name strings
  "location": { "cities": [{ "id": 12, "name": "…", "title": "…",
                             "description": "", "image": "…" }],
                "states": ["NC"], "countries": ["USA"] },
  "taxonomies": {                                 // id-bearing — use THESE for filters
    "types": [{ "id": 7, "name": "Villa" }],
    "amenities": [], "locations": [], "groups": [], "tags": [], "cities": []
  }
}

Filter parameters take ids, so read taxonomies.*, not the flat name arrays. The location.cities[].image field is a placeholder URL, not real content.

Reviews

GET /reviews

Auth auth.public.feed.v1. Note the middleware: feed_id is what authorises the call.

ParamTypeRequiredRuleOn violation
feed_idintyes (auth)Authorises the request. Does not filter the results.401 UNAUTHENTICATED
property_idintnoA Central integer property id. Cast with (int) before use, so a UUID becomes 0 and the filter is dropped, and a CSV keeps only the first id.advisory (unvalidated)
propertystringnoFeed slug; resolved to a property id. The reliable way to scope to one property.404 NOT_FOUND
platform_idintnoAll properties on that platform. Note: platform (a UUID) is not read here.advisory (unvalidated)
per_pageintnoDefault 10. Validated 1–20.422 VALIDATION_ERROR, error.message Validation failed
pageintnoDefault 1.
ratingcsv intnoExact ratings, e.g. 4,5. Validated only as a string.422 VALIDATION_ERROR if not a string
min_rating, max_ratingintnoValidated 1–5. 0 is rejected — omit the parameter to mean “no filter”.422 VALIDATION_ERROR
source, channelcsv stringnoMax 100 chars. Exact match on any listed value.422 VALIDATION_ERROR
authorstringnoMax 255. Substring; % and _ are escaped.422 VALIDATION_ERROR
date_from, date_todatenodate_to must be ≥ date_from.422 VALIDATION_ERROR
has_responseboolnotrue/false/1/0/on/off. An unrecognised value is dropped before validation.silently ignored
searchstringnoMax 255. Substring on title or content.422 VALIDATION_ERROR
order_byenumnoDefault date_desc. rating_, date_, created_, author_, source_, channel_, property_name_ each _asc/_desc.422 VALIDATION_ERROR

meta.average_rating is computed from the property_id / platform_id scope before the rating, source, date and search filters are applied — it is a stable badge value, not the mean of the rows you received.

Not supported yet. /reviews cannot be scoped to a feed. The feed filter exists in the controller but is commented out, so feed_id authorises the request and contributes no WHERE clause. A call carrying only feed_id returns an unscoped page of reviews, not the feed’s. Always send property, property_id or platform_id — otherwise your widget renders rows that do not belong to the page it is on.

Row shape: { id, property_id, property_uuid, property_name, title, content, response, rating, date, author, source, channel, guests_count, created_at, updated_at }.

Quotes and reservations

These are write endpoints against a live PMS. Rate-limit budget and PMS latency both apply; never call them during SSR.

POST /quotes · POST /{property_uuid}/quotes · POST /property/{property_uuid}/quotes

/quotes uses auth.public.feed.v1 with feed_id in the body; the UUID forms use auth.public.v1.

FieldTypeRequiredRuleOn violation
feed_idintyesBody or query. Required by auth.public.feed.v1 and again by the controller.401 UNAUTHENTICATED when absent; 400 Feed ID is required when only ?feed= was sent; 404 NOT_FOUND for an unknown feed
propertystringyesFeed slug.422 VALIDATION_ERROR
checkindateyesMust be after today.422 VALIDATION_ERROR
checkoutdateyesMust be after checkin.422 VALIDATION_ERROR
adultsintyes, unless guests is sent1–50.422 VALIDATION_ERROR when neither is present
guestsintno1–50. Copied into adults when adults is absent.422 VALIDATION_ERROR
childrenintno0–50.422 VALIDATION_ERROR
infantsintno0–20.422 VALIDATION_ERROR
petsintno0–10.422 VALIDATION_ERROR
additional_guestsintno0–50. Stored on the quote’s additional_info.422 VALIDATION_ERROR
couponstringno≤ 100 chars.422 VALIDATION_ERROR
ip_addressstringnoMust be an IP. Auto-filled from the request when absent.422 VALIDATION_ERROR
referrerstringno≤ 500 chars.422 VALIDATION_ERROR

Success is 201 with Shape A (result): { id, property_id, status, checkin, checkout, guests, adults, children, infants, pets, additional_info, nights, ip_address, referrer, rateplans, coupons, addons, source, expires_at, created_at, updated_at, property: { title, slug } }.

expires_at is real — a quote expires and reservation creation rejects an expired one. Show a countdown or re-quote before submitting.

Money contract on rateplans[]: discount, stayDiscount, couponDiscount and promotionalDiscount are always >= 0 and always reductions; a length-of-stay price increase is reported separately as surcharge (>= 0), never as a negative discount. totalRent is pre-discount, total is post-discount. Invoice line items keep signed amounts for display. This is uniform across PMS integrations — never Math.abs() or sign-guess these fields.

POST /quote/update

Auth auth.public.feed.v1. Only the fields you send change; everything else is carried over from the stored quote.

FieldTypeRequiredRuleOn violation
quote_idstringyes (route or body)404 NOT_FOUND
checkindatenoMust be after today.422 VALIDATION_ERROR
checkoutdatenoMust be after checkin when both are sent.400 Checkout date must be after checkin date
adultsintno1–50.422 VALIDATION_ERROR
childrenintno0–50.422 VALIDATION_ERROR
infantsintno0–20.422 VALIDATION_ERROR
petsintno0–10.422 VALIDATION_ERROR
additional_guestsintno0–50.422 VALIDATION_ERROR
couponstringno≤ 100. Omitting it keeps the existing coupon.422 VALIDATION_ERROR

PMS-level refusals: 400 Method not supported; 400 The request was invalid. when the platform’s credentials are missing (the real message names credentials and is screened out); 503 INTERNAL_SERVER_ERROR when the integration is in maintenance.

GET /quotes/{quote_id}

Auth auth.public.feed.v1. Same result shape as the create call. Pass feed_id to get property.slug resolved; without it the slug is null. 404 NOT_FOUND for an unknown id.

GET /{quote_id}/checkout-url

Auth auth.public.feed.v1. Shape B: data: { url }. Refusals: 404 NOT_FOUND for an unknown quote or a quote whose property is gone; 400 Platform not connected to an integration.; 400 External checkout not supported by this integration.; 400 The request was invalid. when platform credentials are missing (screened out by name).

Check payment-settings.reservation_target first: homerunner means the local checkout flow is supported, anything else means the PMS hosts it and this endpoint is the right call.

POST /{quote_id}/reservation

Auth auth.public.feed.v1. Converts a quote into a reservation. Only properties whose payment-settings.reservation_target is homerunner can be booked here — anything else is refused at 400.

FieldTypeRequiredRuleOn violation
first_name, last_namestringyesGuest identity.400, all validator messages joined into one string
emailstringyesMust be a valid address.400
phonestringyes400
languagestringnoxx or xx_YYYY / xx-YYYY.400
rateplanstringnoThe id of one of the quote’s rateplans[]. An unmatched id silently falls back to rateplans[0].advisory (unvalidated)
reservation_methodinquiry | request | instant | homerunnernoDefaults to the property’s own method.400 Reservation method {value} not supported.
payment_settings.payment_statusstringnopaid or authorized marks the booking pre-paid. The legacy payment_paid boolean is still honoured and takes precedence.advisory (unvalidated)

Success is 201:

// homerunner-central/app/Services/ReservationService.php:978-985
{
  "success": true,
  "status_code": 201,
  "data": {
    "reservation": { /* the reservation row, property and platform stripped */ },
    "confirmation_hash": "$2y$..."
  }
}

Store data.confirmation_hash. It is the ?hash= value the confirmation endpoints below require, and it is returned exactly once — the key is confirmation_hash, not hash.

This is the one endpoint that does not use the canonical error envelope. Its errors are built by the private-API helper, so 401 is spelled UNAUTHORIZED (not UNAUTHENTICATED) and 422 is UNPROCESSABLE_ENTITY (not VALIDATION_ERROR), and every message except the 429 one collapses to An error occurred. Please try again. in production. A stale quote that could not be transparently re-validated returns 409 with one of three machine-readable codes: QUOTE_EXPIRED (request a fresh quote and retry), QUOTE_EXPIRED_UNAVAILABLE (the dates are gone — do not retry) and QUOTE_EXPIRED_PRICE_CHANGED (re-quote and re-consent the guest before charging). Branch on error.code; the message will tell you nothing.

GET /reservations/{reservation_id}

Auth auth.public.feed.v1 plus an in-controller hash check.

ParamTypeRequiredRuleOn violation
hashstringyesQuery string. Verified against the reservation id.400 Confirmation hash is required.; 403 FORBIDDEN on a mismatch

Shape B. data carries { id, type, status, checkin, checkout, guests, adults, children, infants, pets, nights, currency, amount, first_name, property_id, rateplan, payment_settings, property: { id, title, image_url } }.

Guest contact fields (last_name, email, phone) and the payment-provider block (payment_processor, stripe_publishable_key, has_secure_stripe, payment_provider_id) appear only while the reservation can still be paid — status not closed, and payment_status empty, pending or failed. On a settled reservation they are absent, not null. rateplan is whitelisted to id, title, currency, total, subTotal, totalRent, totalFees, totalTaxes, discount, surcharge, securityDeposit, cleaningFee, invoice_items, invoiceItems.

POST /reservations/{reservation_id}/payment-methods

Auth auth.public.feed.v1 plus the same hash gate. Attaches or retries a payment method. Refused with 409 This reservation is not awaiting payment. when payment_status is anything other than empty, pending or failed. Returns the refreshed reservation in Shape B.

GET /property/{property_identifier}/payment-settings

Auth auth.public.feed.v1. Requires feed_id. Shape B. Public payment configuration only — no secret keys are ever returned.

data: { payment_processor, reservation_method, inquiry_requires_payment, stripe_publishable_key, has_secure_stripe, payment_provider_id, payment_target, reservation_target, currency_code, cancellation_policy, rental_condition, payment_terms[], house_rules[] }.

payment_processor is one of stripe, amaryllis, card, none. payment_terms[] entries are { event, amount_type, amount, time_relation, time_amount, time_unit }.

Refusals: 400 Feed ID is required.; 404 NOT_FOUND for an unknown feed or a property that is not in it; 403 FORBIDDEN for an archived feed.

POST /property/{property_identifier}/stripe/setup-intents and /stripe/verify-cvc

Auth auth.public.feed.v1, both require feed_id. setup-intents takes no body and returns the SetupIntent in data. verify-cvc requires payment_method_id in the body (400 Payment method id is required.) and returns data: { cvc_check }.

Widgets and plugins

GET /{feed_id}/feed/widgets

Auth auth.public.v1. Active widget rows for the feed, built-in and plugin:* alike.

ParamTypeRequiredRuleOn violation
expandboolnofalse (default) returns { id, keyword, display_name } only. true returns the full row and, for plugin:* rows, inlines plugin_manifest and plugin_manifest_url.any non-boolean reads as false

A manifest is inlined only when the plugin is approved and enabled on that feed. The inlined manifest already has the feed’s per-widget toggles applied, so a widget switched off for the feed is absent from widgets[]. This is the SSR kill switch — see Install, customers and kill switches.

GET /widget/{widget_id}

Auth auth.public.v1. Shape B: data: { id, status, keyword, user_id, feed_id, display_name, settings, created_at, updated_at }. 404 WIDGET_NOT_FOUND / Widget not found.

settings is the raw stored object. It is not validated, not defaulted and not the same as what your component receives — see Config schema and UI schema for parseWidgetConfig.

fetchWidget(widgetId) from @homerunner-next/widget-core/runtime wraps this endpoint and throws WidgetFetchError unless res.ok and body.success.

GET /widgets?ids=

No auth middleware. The feed-availability gate runs in the controller instead, and refuses the whole batch — not a filtered subset — if any widget belongs to a blocked feed.

ParamTypeRequiredRuleOn violation
idscsv UUIDyesComma-separated widget ids.400 MISSING_PARAMETER / The ids parameter is required

Shape B, data is an array. Unknown ids are simply absent — the response is not padded and carries no error, so compare lengths yourself.

GET /plugins/{slug}

No auth middleware. Approved plugins only.

Shape A: result: { plugin: { slug, name, status, featured, manifest_url, manifest_cache } }. manifest_cache is the published manifest; manifest_url is the absolute URL it was fetched from and is the base for resolving path-only ssr.url and assets.* entries — see Keywords, assets and URLs.

404 with error.code NOT_FOUND and error.message The requested plugin was not found. for an unknown slug and for a pending or suspended one — suspension takes a plugin’s assets offline through this endpoint.

GET /{feed_id}/feed/plugins

Auth auth.public.v1. Plugins installed and enabled on the feed, approved only, ordered by sort_order.

Shape A: result: { plugins: [{ plugin_id, slug, name, featured, enabled, config, sort_order, manifest_url, manifest }] }. manifest has both the platform-wide kill switch and the feed’s own widget toggles already applied.

Calling from a plugin

Client-side

Ordinary browser fetch under ordinary CORS. No host restrictions apply.

// Corrected from hr-plugins/stats-spotlight/src/data.ts:27-37 — the shipped
// version sends `per_page`, which /feed/properties ignores. `PropertiesResponse`
// is declared alongside it in the same file.
import { homerunnerApiBaseUrl, WidgetFetchError } from "@homerunner-next/widget-core/runtime";

export async function fetchProperties(
  feedId: number,
  opts: { limit?: number; platform?: string } = {},
): Promise<PropertiesResponse> {
  const url = new URL(`${homerunnerApiBaseUrl()}/public-api/v1/${feedId}/feed/properties`);
  url.searchParams.set("limit", String(opts.limit ?? 12));
  if (opts.platform) url.searchParams.set("platform", opts.platform);

  const res = await fetch(url.toString());
  // Read the status BEFORE parsing: a 429 may not be JSON at all.
  if (!res.ok) throw new WidgetFetchError(res.status, `properties: HTTP ${res.status}`);
  return res.json();
}

Throw WidgetFetchError rather than a bare Error, so isRateLimitError, isPermanentError and retryTransient classify your failures the same way they classify the SDK’s own. Every helper named on this page — homerunnerApiBaseUrl, fetchFeed, fetchWidget, fetchPropertyBySlug, WidgetFetchError, isRateLimitError, isPermanentError, retryTransient, getProxiedImageUrl — exists in every publishable widget-core (0.10.0+). Full signatures are in SDK reference; retry and query-key discipline are in Fetching data.

Server-side

Inside getInitialData / dehydrateState the renderer hands your module a wrapped fetch, not the platform one. Central’s host is always allowed. Any other host must be declared in externalFetch — on the widget’s summary entry for a suite, on the root manifest for a single-widget plugin — or the call throws before the socket opens. Loopback, private, link-local and cloud-metadata hosts are refused even when declared. Redirects are followed manually and every hop is re-validated. The exact block messages, the host versus host:port form and the redirect cap live in Component and SSR module.

Server-side GETs are additionally served from a short in-process cache (duration in Limits and error index), so data you fetch during SSR can be slightly stale. That is deliberate: composed pages are cached for hours anyway, and one uncached property page fans out to many public-api calls against a per-IP budget.

Images

getProxiedImageUrl(url, width?, height?) from @homerunner-next/widget-core/utils rewrites any image URL from the API through HomeRunner’s resizing proxy (https://hrimgs.co/w:{w}/h:{h}/rt:fill/plain/{url}), defaulting to 64 × 64. Scheme-less and protocol-relative inputs get https://; a URL with no image extension gets -homerunner.jpg appended so the proxy treats it as JPEG. Empty input is returned unchanged.

Known gaps

Not supported yet. There is no write API beyond the quote and reservation flow. You cannot create, update or delete feeds, properties, widgets or plugin state from a widget.

Not supported yet. There are no webhooks, no subscriptions and no change feed. Every update is a poll, and every poll is charged against the shared per-IP rate limit.

Not supported yet. There is no cursor pagination, no fields/sparse-fieldset parameter and no batch endpoint that takes a list of slugs. To hydrate N properties you make N calls to /property/{slug}/full, or one wide /feed/properties call and accept its card projection.

Not supported yet. sort_by, price_field and every taxonomy filter are unvalidated. A typo produces a 200 with a silently different result set, so there is nothing to catch in an error handler. Assert on the shape of what you got back instead.

Limits and error index

Every hard number the platform enforces, and every message it can show you, in one place. Other pages state a rule in context and link here for the number and the exact string; this page is the normative home for both.

Paste a message you got into your browser’s find-in-page. Each row names the cause and links to the page that explains the fix.

Enforcement levels

This legend is used throughout the book. Four levels, and only four:

LevelWhat it meansWhere you see it
publish ERRORBlocks. Nothing is uploaded, published or made live until you fix it.Your browser (source-zip audit), the reviewer’s browser (built-zip audit), or a coded 4xx from central.
publish warningRecorded, never blocking. The build still goes live.Source-zip warnings appear in your uploader. Built-zip warnings appear only to the reviewer — see Known gaps.
silent runtime truncationAccepted at publish, then quietly clipped or dropped when the platform reads it. No message, anywhere.Nowhere. You only notice the missing behaviour.
advisory (unvalidated)Nothing checks it at any layer. A typo is yours to find.Nowhere.

The three gates a publish ERROR can come from

GateRuns whereAudits whatYou see it
Source-zip auditYour browser, before any bytes leave your machine (auditPluginZip)Your submission zip and its manifest.jsonImmediately, in Dashboard → Plugins → My plugins
Central submit / publish / rollback APICentral, after uploadSlug ownership, version monotonicity, manifest shape, widget-core stampAs a coded 4xx surfaced by the dashboard
Built-zip auditThe reviewer’s browser at publish (auditBuildZip)The zip the reviewer built from your sourceNever directly — only if the reviewer relays it in review notes

The source-zip audit and the built-zip audit share one widget-summary validator, so a widgets[] mistake fails at gate 1 (fast) rather than gate 3 (slow). Central re-validates the same rules server-side; the browser audit is UX, not the authority.

Limits

Manifest counts and sizes

LimitValueShapeEnforcementMessage / behaviour
Widgets per plugin24Suitepublish ERRORmanifest declares {n} widgets — the limit is 24 per plugin.
Presets per plugin8 (0.11.0+)Suitepublish ERRORmanifest "presets" must be a list of at most 8 entries.
prefill keywords per slot8 (0.12.1+)Suite (layouts)publish ERROR and silent runtime truncationmanifest widget "{slug}" slot "{name}" needs a "prefill" list of at most 8 keywords. At runtime normalizeLayoutSlots dedupes, drops malformed entries and slices to 8 with no message.
assets.fonts per widget8 (0.12.0+)Bothpublish ERROR and silent runtime truncationmanifest widget "{slug}" "assets.fonts" must be a list of at most 8 stylesheets. normalizeFonts filters invalid entries and slices to 8.
Slot name length64 charsSuite (layouts)publish ERRORmanifest widget "{slug}" has an invalid or duplicate slot name.
expectedHeight0 <= n <= 10000 (px)Suitepublish ERRORmanifest widget "{slug}" has an invalid "expectedHeight" (number, 0-10000 px). A value of 0 also means “no reservation”: data-hr-min-height is emitted only when > 0.
Media ref length (icon, cover, widgets[].icon)2048 charsBothpublish ERRORmanifest "icon" must be a "media/<file>" path or an absolute http(s) URL.
Font ref length2048 charsBothpublish ERRORmanifest widget "{slug}" "assets.fonts" entries must be absolute http(s) URLs or relative paths inside the version prefix.
README ref length255 charsBothpublish ERROR{label} must be a "media/<file>.md" (or "media/<dir>/<file>.md") path.
README file size64 KBBothpublish ERROR{label} {ref} is {n} KB — the limit is 64 KB. The dashboard also drops an over-size README silently when fetching it for display.
tags shown on the plugin pagefirst 12Bothsilent truncation (display only)Extra tags are stored and served, just not rendered.
Submission notes5000 charsBothpublish ERROR (central)422 VALIDATION_ERROR

Field-by-field rules for these live in Manifest: root fields, Manifest: widget summary and Layout widgets.

Source zip

The zip you upload. Rules are checked in your browser before a single byte is transmitted.

LimitValueEnforcementMessage
Compressed size20 MBpublish ERRORZip is {n} MB — the limit is 20 MB. The presign step re-checks with The zip must be between 1 byte and 20 MB., and central again with 422 VALIDATION_ERROR
Entry count2000publish ERRORZip contains too many entries ({n} > 2000).
Uncompressed total100 MBpublish ERRORZip expands to {n} MB — the limit is 100 MB.
Compression ratio100:1publish ERRORZip compression ratio exceeds 100:1.
Inflated for inspectionmanifest.json and package.json only, 2 MB eachsilentA larger manifest.json is treated as unreadable → manifest.json is missing from the zip root or is not valid JSON.
Per-entry error linesde-duplicated, first 12displayPer-entry rule violations are collapsed to one line per rule and capped at 12; the manifest/package.json checks are appended after that cap.

Forbidden content and the required root files are in Packaging and publishing rules; their exact strings are indexed under “Source-zip audit” below.

Built zip and its size warnings

The zip the reviewer produces from your source. You never upload it, but its limits decide whether your reviewed plugin actually goes live.

LimitValueEnforcementMessage
Zip size60 MBpublish ERRORZip is {n} MB — zip the built plugin WITHOUT node_modules (60 MB limit).
Per publishable file30 MBpublish ERROR"{path}" is over the 30 MB per-file limit.
Total publishable bytes150 MBpublish ERRORThe build expands to {n} MB — over the 150 MB limit.
Files per publish request1–200publish ERRORBetween 1 and 200 files per publish.
Directory depth under dist/, widgets/, media/exactly one levelsilent — file is not published, no errordist/a/b/c.js is dropped without a word.
File-name charset[A-Za-z0-9._-] per segmentpublish warning"{path}" won't be published — file names may only use letters, digits, ".", "_" and "-".

Size warnings — never blocking, and visible only to the reviewer:

// src/lib/plugin-zip.ts (auditBuildZip) — the three soft thresholds, per widget
if (plan.js)  logical.push({ kind: "client bundle", name: plan.js,  warn: 1_500_000 });
if (plan.css) logical.push({ kind: "stylesheet",    name: plan.css, warn:   512_000 });
if (plan.ssr) logical.push({ kind: "SSR bundle",    name: plan.ssr, warn: 1_000_000 });
// → `dist/{name} is {n} MB.`
// → `dist/{name} is {n} MB — over the renderer's 1 MB cache threshold (slower cold SSR).`

The 1 MB SSR threshold is not cosmetic: a bundle over 1,000,000 bytes is skipped by the renderer’s Redis bundle cache entirely, so every cold lambda re-fetches it from the CDN.

Runtime bounds

Every timeout, cap and cache window the platform applies to a plugin at run time.

BoundValueGovernsWhat happens at the edge
SSR module-scope execution5000 ms (floor 250 ms; PLUGIN_SSR_EXEC_TIMEOUT_MS)Synchronous work in your SSR bundle’s module scopeThe vm throws; the loader returns null; your widget becomes a failure node. It does not bound async work your module queues, nor getInitialData/dehydrateState.
SSR bundle fetch redirects0Fetching ssr.urlHTTP {status} redirect refused — SSR bundles must be served directly from their published URL
externalFetch redirects5 hops, each re-validatedYour server-side fetch[plugin] fetch blocked — more than 5 redirects.
Sandbox GET response cache60 s, ok-GET only, 50 entries LRUYour server-side fetch on non-dev rendersServer-side data can be up to 60 s stale. ?dev=1 renders bypass it (still enforced, just uncached).
Sub-manifest fetch5 s (AbortSignal.timeout(5000))Dashboard reading widgets/{slug}.manifest.jsonA timeout or non-2xx degrades to a schemaless config panel, not an error.
Build-manifest fetch (renderer)3 sResolving hashed CDN asset URLsFalls back to the /p/ proxy URL.
Build-manifest fetch (/p/ proxy)3 sMapping a logical file to its hashed twinServes the logical name; cached for the life of the edge instance when the dist location is a versioned prefix.
Shadow-root CSS load3000 mswaitForShadowLinks before revealing a migrated/ponyfilled rootThe widget is revealed unstyled rather than staying hidden.
Child widget registration10000 msA CSR layout waiting for a slot child’s IIFE[widget-core] widget "{type}" did not register within 10000ms — that slot child stays empty; the layout still renders.
Evaluated SSR module cache48 entries LRUWarm SSR rendersVersioned (/{x.y.z}/dist/…, non-loopback) URLs never expire; legacy author-hosted URLs expire after 5 min.
Redis SSR bundle cacheskip above 1,000,000 bytesCold-start bundle textVersioned bundles are slug-keyed with a 30-day backstop (a publish overwrites them); legacy bundles are URL-keyed with a 5-min TTL.
/p/ proxy pointer TTL60 sWhich plugin/version the proxy resolves toOnly 403/404/410 from central are negative-cached; 429/5xx/timeouts keep serving the last good entry.
Composed page cache86400 s (24 h)The customer page your widget renders intoA page containing any data-hr-widget-error= breadcrumb, or a failed prefetch, is marked degraded and drops to 60 s. A 404 is negative-cached for 60 s.
Publish purge fan-outconcurrency 4, 30 s in-request + 150 s afterHow fast a publish reaches live pagesFeeds not reached inside the budget fall back to the 24 h page TTL.

Public-API rate limiting is a separate contract — see Public API. Cache-Control headers on published files are in Keywords, assets and URLs.

Two of these bounds also apply on your laptop (widget-core 0.12.2+): npm run dev:ssr applies the same 5000 ms module-scope budget and the same 5-hop externalFetch redirect cap. Neither is adjustable from npm run dev:ssrSSRDevServerOptions exposes no knob for either. Only a hand-rolled renderPluginSSR / evaluatePluginSSR call can move the module-scope budget, via execTimeoutMs (SDK reference); the redirect cap is fixed everywhere. Nothing else in the table has a local equivalent — the caches, the page TTL and the purge budgets are all platform-side.

Error index

1. Source-zip audit (your browser, before upload)

Structure and content of the zip itself.

MessageCauseFix
Zip is {n} MB — the limit is 20 MB.Compressed zip over 20 MBExclude node_modules/, dist/, .git/ — see Packaging and publishing rules
Not a zip archive (no end-of-central-directory record).Not a zip, or truncatedRe-create the archive
Zip64 archives are not supported for plugin submissions.Zip64 extensions in use (usually a huge or >65535-entry archive)Shrink the archive
Corrupt zip: central directory extends past the end of the file.Truncated uploadRe-create the archive
Corrupt zip: bad central-directory entry.Malformed archiveRe-create the archive
Zip contains too many entries ({n} > 2000).Over 2000 entriesPrune generated/vendored files
"{name}": absolute paths are not allowed.An entry name starts with /Zip from inside the project directory
"{name}": path traversal is not allowed.A .. segment in an entry nameSame
"{name}": node_modules must not be included — submit source only.node_modules at any depthDelete it before zipping
"{name}": .git must not be included..git at any depthSame
"{name}": dist/ must not be included — the reviewer rebuilds it from source.A root-level dist/ (root-level only; nested dist dirs are fine)Delete dist/ before zipping
"{name}": symlinks are not allowed.A symlink entryReplace with the real file
Zip expands to {n} MB — the limit is 100 MB.Uncompressed total over 100 MBPrune
Zip compression ratio exceeds 100:1.Zip-bomb-shaped archivePrune
manifest.json is missing from the zip root or is not valid JSON.No root manifest.json, invalid JSON, or over 2 MBSee Manifest: root fields
package.json is missing from the zip root or is not valid JSON.No root package.jsonThe reviewer builds from it; it is required
Warning No package-lock.json — the reviewer build will resolve dependencies fresh.No lockfileCommit package-lock.json for a reproducible reviewer build

__MACOSX/ entries and .DS_Store are ignored. A single top-level wrapper folder (what macOS “Compress” produces) is tolerated, and every path rule is then applied relative to it.

Manifest identity and shape:

MessageCauseFix
manifest.json is missing the required "id" field. (also "version", "name")Missing or non-stringManifest: root fields
manifest "id" ("{id}") must be lowercase alphanumeric with hyphens — it is the plugin's slug.id fails /^[a-z0-9][a-z0-9-]*$/Rename the plugin slug
manifest "version" ("{v}") is not valid semver (e.g. 1.2.0).Not MAJOR.MINOR.PATCHUse three parts
manifest.json is missing the required "widgetType" field.v1 shape with no widgetTypeSingle-widget only; suites must not carry it
manifest.json is missing "ssr.url" (the SSR bundle path).v1 shape with no ssr.urlv1 plugins must server-render; ssr: false is a suite-only option
manifest.json is missing "assets.js" (the client bundle path).v1 shape with no assets.js

Widget summaries — Shape: Suite. These run in both the source-zip and built-zip audits, and central mirrors them:

MessageCause
manifest widgets[{i}] is not an object.A non-object entry in widgets[]
manifest widgets[{i}] needs a lowercase-alphanumeric-with-hyphens "slug".Missing slug, or fails /^[a-z0-9][a-z0-9-]*$/
manifest widget slug "{slug}" is reserved.Slug is dist, widgets or media
manifest declares widget slug "{slug}" more than once.Duplicate slug
manifest widget "{slug}" is missing "name".Empty or non-string name
manifest widget "{slug}" needs a relative "assets.js" path inside the version prefix.Absolute path, URL scheme, backslash or ..
manifest widget "{slug}" has an invalid "assets.css" path.Same rule, on assets.css
manifest widget "{slug}" "assets.fonts" must be a list of at most 8 stylesheets.Not an array, or over 8
manifest widget "{slug}" "assets.fonts" entries must be absolute http(s) URLs or relative paths inside the version prefix.A bad font entry
manifest widget "{slug}" needs "ssr.url" (relative path) or an explicit "ssr": false for CSR-only widgets.Neither a confined ssr.url nor the literal false
manifest widget "{slug}" has an invalid sub-manifest path.manifest is not a confined relative path
manifest widget "{slug}" has an unknown "category" (content | layout).category is neither literal
manifest widget "{slug}" declares "pages" but is not a layout.pages on a content widget
manifest widget "{slug}" needs a non-empty "pages" list.pages: []
manifest widget "{slug}" has unknown page(s) in "pages": {list} (pdp | listings | checkout | confirmation | collection | misc).An unknown page key
manifest widget "{slug}" declares "slots" but is not a layout.slots on a content widget
manifest widget "{slug}" needs a "slots" list.slots is not an array
manifest widget "{slug}" has an invalid or duplicate slot name.Empty, non-string, over 64 chars, or repeated
manifest widget "{slug}" slot "{name}" needs a "prefill" list of at most 8 keywords.Not an array, or over 8
manifest widget "{slug}" slot "{name}" has invalid prefill keyword(s): {list} (system widget keywords only, e.g. gallery).An entry failing /^[a-z][a-z0-9-]{0,63}$/ — including any plugin:… reference
manifest widget "{slug}" has an invalid "expectedHeight" (number, 0-10000 px).Not a number in range
manifest declares {n} widgets — the limit is 24 per plugin.Over 24

Presets — Shape: Suite (0.11.0+):

MessageCause
manifest "presets" must be a list of at most 8 entries.Not an array, or over 8
each manifest preset needs a "name".Missing name
preset "{name}" must reference a declared layout widget.layout names an unknown slug, or one whose category is not layout
preset "{name}" needs a "slots" object.slots is missing, null or an array
preset "{name}" has an invalid slot binding shape.A slot value that is not an array
preset "{name}" references "{x}" which is neither a declared content widget nor a system widget keyword.A child that is not one of your own non-layout widget slugs and not a plain system keyword

Preset semantics are in Layout widgets.

2. Central submit API (after upload)

Every response uses central’s coded envelope.

HTTPCodeMessageCause
422VALIDATION_ERRORfirst validator messagezip_sha256 not 64 hex chars, zip_size_bytes outside 1..20971520, notes over 5000 chars, zip_key over 512 chars
422INVALID_ZIP_KEYzip_key must match submissions/{slug}/<file>.zipThe upload key does not belong to this slug
422INVALID_MANIFESTManifest "id" must be lowercase alphanumeric with hyphens (it is the plugin slug).Slug shape
422INVALID_VERSIONManifest "version" is not valid semver: {v} · Manifest "version" must be MAJOR.MINOR.PATCH semver: {v}Central rejects two-part versions like 1.0 that a loose parser would accept
422VERSION_NOT_GREATERVersion {v} must be greater than the published {current}.Every submission must strictly increase over the live version
409SLUG_RESERVEDThe plugin id "{slug}" is reserved. Contact an administrator to claim it.A pre-pipeline row owns the slug with no author
409SLUG_TAKENThe plugin id "{slug}" already belongs to another author. · … was just registered by another author.Someone else owns the slug
409NO_SUBMISSIONThis plugin has no submission in review.A review action with nothing in flight
409PLUGIN_EXISTSA plugin with this slug is already registered.Legacy admin-only register-by-URL against an existing slug

Only VERSION_NOT_GREATER, SLUG_TAKEN and SLUG_RESERVED can surprise you after a clean browser audit — the browser only checks version shape, never monotonicity. See Preflight and submit.

3. Built-zip audit (publish; reviewer-visible only)

These block your reviewed plugin from going live. You will normally learn about them through review notes.

MessageCause
manifest.json not found at the zip root — zip the plugin folder AFTER building.Wrong zip root
manifest id "{x}" does not match the submission's "{y}".Built manifest drifted from what was submitted
manifest version "{x}" does not match the submitted "{y}".Same
manifest has no "runtime.widgetCore" stamp — this build predates the evergreen runtime. Update @homerunner-next/widget-core to 0.10.0+ and rebuild.The SDK post-build step never ran, or an SDK older than 0.10.0
Built against widget-core {v} — the fleet requires 0.10.0+ (older builds bundle a stale mount that breaks on SSR pages). Update and rebuild.Stamp below the floor
dist/manifest.json (build manifest with {buildHash, files}) is missing — run the widget-core build.Built with a bare bundler instead of the widget-core preset
manifest is missing "assets.js". · manifest is missing "ssr.url".v1 shape, missing at build time
dist/{name} ({label}'s {kind}) is missing from the zip.A declared client bundle / stylesheet / SSR bundle was never emitted
The build manifest has no hashed twin for {name}.dist/manifest.json does not list the file
Hashed twin dist/{hashed} is missing from the zip.The hashed file was pruned
{label}'s client bundle never registers on window.HRPlugins — the widget would never mount.The IIFE never calls registerPlugin/registerPluginWidget; the audit greps for the literal HRPlugins
{path} ({label}'s sub-manifest) is missing from the zip or invalid JSON.A declared widgets/{slug}.manifest.json was not built or not committed
manifest "icon" must be a "media/<file>" path or an absolute http(s) URL. (also "cover")Wrong ref shape, or over 2048 chars
Declared icon {ref} is missing from the zip. (also cover)A media/ ref with no file
manifest screenshot "{x}" must be a "media/<file>" path.Screenshots are stricter than icons: an absolute URL is rejected here
Declared screenshot {ref} is missing from the zip.
widget "{slug}" icon must be a "media/<file>" path or an absolute http(s) URL.Per-widget icon shape
Declared widget "{slug}" icon {ref} is missing from the zip.
{label} must be a "media/<file>.md" (or "media/<dir>/<file>.md") path. ({label} = manifest "readme" or widget "{slug}" readme)Wrong README ref shape
Declared {label} {ref} is missing from the zip.README declared but not shipped
{label} {ref} is {n} KB — the limit is 64 KB.Over 64 KB
"{path}" is over the 30 MB per-file limit. · The build expands to {n} MB — over the 150 MB limit. · Zip is {n} MB — zip the built plugin WITHOUT node_modules (60 MB limit).Size
Could not read the zip: {msg}Decompression failure

media/ is authored content, not build output. Anything you declare — icons, cover, screenshots, READMEs — must be committed and present in your source zip, or these errors fire at publish, long after you were told the submission looked fine.

Built-zip warnings (never blocking):

MessageMeaning
manifest has no configSchema — was this zipped after `npm run build`? The dashboard form will be empty.v1 only; the schema-inject step did not run
{path} has no configSchema — {label}'s dashboard form will be empty.A sub-manifest with no schema
{label}'s configSchema doesn't extend the base widgetSchema (no colorScheme/spacing) — standard theme + spacing controls won't appear. Build config.ts as widgetSchema.extend({...}) and export zodToManifestSchema(configSchema).You hand-wrote JSON Schema instead of extending the base — see Config schema and UI schema
dist/{name} is {n} MB.Client bundle over 1.5 MB, or stylesheet over 512 KB
dist/{name} is {n} MB — over the renderer's 1 MB cache threshold (slower cold SSR).SSR bundle over 1 MB
"{path}" won't be published — file names may only use letters, digits, ".", "_" and "-".The file is silently excluded from the CDN

4. Central publish and rollback

HTTPCodeMessageCause
409NOT_APPROVEDOnly an approved submission can be published (current: {status}).Publish attempted before review approval
422VERSION_MISMATCHRequested version {x} does not match the submitted {y}.
422MANIFEST_URL_INSECUREmanifest_url must be served over https.Non-https CDN (loopback is allowed for local rigs)
422MANIFEST_INVALIDvalidator messageThe published manifest fails central’s field validation
422MANIFEST_MISMATCHThe published manifest does not match the submission's slug/version.
422MANIFEST_URL_MISMATCHmanifest_url must address /{slug}/{version}/.The URL is not under the immutable version prefix
422MANIFEST_SHAPE_CHANGEDThis manifest changes the plugin between single-widget and multi-widget shapes. Existing widget placements and embed URLs would stop resolving — publish the new shape under a new plugin slug instead.Permanent contract. A live plugin can never flip shapes — see The two manifest shapes
422MANIFEST_WIDGET_CORE_MISSINGThe manifest has no runtime.widgetCore stamp — rebuild with @homerunner-next/widget-core 0.10.0 or newer.Central applies the same floor as the built-zip audit, on publish and rollback
422MANIFEST_WIDGET_CORE_TOO_OLDBuilt against widget-core {v}; the platform requires 0.10.0 or newer.
409ALREADY_LIVEVersion {v} is already live.Rollback to the current version
422ROLLBACK_MANIFEST_MISMATCHmanifest_url must address /{slug}/{version}/. · The manifest at manifest_url is {x}@{y}, not {a}@{b}.
422ROLLBACK_MANIFEST_UNREACHABLECould not fetch the manifest for {v}: {msg}The old prefix was deleted
422PLUGIN_NOT_APPROVEDOnly approved plugins can be enabled on a feed.Install attempted on a non-approved plugin
409PLUGIN_IN_USERemove the {n} widget(s) built from this plugin first. (with details.widget_count)Uninstall blocked while widget instances exist

See Publishing, versions and rollback and Install, customers and kill switches.

5. Dashboard operator messages

Shown to whoever drives the upload. Quoted because you will see them relayed to you.

MessageMeaning
The zip must be between 1 byte and 20 MB.The presign step refuses the size before uploading
{slug}@{version} already exists on the CDN — published versions are immutable. A changed build needs a resubmission with a bumped version.Version prefixes can never be overwritten
Could not verify that {slug}@{version} is unpublished (storage check failed). Nothing was uploaded — retry in a moment.The immutability check fails closed
The published manifest isn't readable yet at {url} (HTTP {n}). Check the bucket is public and the custom domain is live, then retry.Publish verification could not read the manifest back
The manifest served at {url} is for {x}@{y}, not {a}@{b}.The CDN served a different build
This build changes {slug} between single-widget and multi-widget shapes. Existing placements and embed URLs would stop resolving — publish the new shape under a new plugin slug.The dashboard’s pre-upload mirror of MANIFEST_SHAPE_CHANGED
Only an approved submission can be published. · This build no longer matches the submitted version.Publish preconditions
The build upload must include manifest.json. · Between 1 and 200 files per publish. · Unexpected file path "{path}".Publish request shape
v{version} is the live version — roll back to another version first.Refusal to delete the live version prefix
This plugin has no submission zip on file.The zip was cleared (a rejection nulls it)
Publishing is not configured — set R2_PUBLIC_BASE_URL.Deployment misconfiguration
Refusing to publish from a local dashboard (CDN prefix "local/") against {host}. Publish from the deployed dashboard, or set PLUGIN_CDN_ALLOW_LOCAL_PUBLISH=1 on purpose.Guard against a laptop publishing to production

Retrying a failed publish is always safe: the root manifest.json is uploaded last and is both the immutability sentinel and the verification target, so a publish that dies mid-upload leaves nothing live.

6. Renderer SSR failures

A plugin failure never takes the page down. The widget’s markup is replaced by a hidden breadcrumb node:

// packages/homerunner-renderer/lib/render-utils.tsx (widgetFailureNode)
const safe = `HR widget failed — type="${info.type}"${info.id ? ` id="${info.id}"` : ""}: ${info.reason}`
    .replace(/-{2,}/g, "-")
    .replace(/>/g, "&gt;");
return (
    <div
        data-hr-widget-error={info.type}     // the widget KEYWORD, not the failure kind
        hidden
        dangerouslySetInnerHTML={{ __html: `<!-- ${safe} -->` }}
    />
);

View-source and search for data-hr-widget-error to read the reason. A page containing one is flagged degraded, so its cache TTL drops from 24 h to 60 s.

reason=CauseFix
keyword does not resolve against the plugin manifestThe stored keyword does not match the manifest shape: a bare plugin:{slug} against a suite, a widget slug against a v1 manifest, or an unknown/kill-switched slugThe two manifest shapes
plugin layout widget rendered as content — needs a layout entry with slotsA category: "layout" widget was placed as ordinary contentLayout widgets
no plugin SSR URL in manifestA v1 manifest with no ssr.url. In v1 this is a failure, not a CSR-only widget — only a suite summary may declare ssr: falseManifest: root fields
bad plugin asset URL: {msg}A relative asset path with no known manifest URL, or an unparseable URLKeywords, assets and URLs
SSR bundle failed to load from {url}Fetch failed, a redirect was refused, the module-scope timeout fired, require hit a non-shared module, or the bundle has no default exportComponent and SSR module
module load failed: {code}The bundle could not be imported at all
SSR data prep failed: {msg}A throw inside getInitialData, dehydrateState, getStaticAssets or the component’s renderToStringParse ctx.options with parseWidgetConfig and default everything
layout SSR data prep failed: {msg}The layout equivalent. {msg} is one of: keyword does not resolve against the plugin manifest, "{type}" is not a layout widget (category: {cat}), plugin layout widgets require an SSR bundle (`ssr: false` is content-only), SSR bundle failed to load from {url}Layout widgets
layout-in-layout nesting not allowedA slot binding points at another layoutLayouts cannot nest
slot module load failed: {code}One slot child’s module failedOnly that child is dropped

Not supported yet. A content widget that throws during render becomes a failure node and the rest of the page survives. A layout component that throws during render is not contained — its element is rendered later in the page-level renderToString, which has no try/catch, so the whole render endpoint 500s. Never throw in a layout’s render path.

Renderer-side log lines you can ask an operator to grep, by prefix:

  • [plugin] Fetching SSR bundle: {url} — a cold load (no in-process module, no Redis entry).
  • [plugin] SSR bundle from Redis: {url} — L2 hit.
  • [plugin] Failed to load SSR bundle from {url}: {msg} — the load threw; the widget fails.
  • [plugin] Loaded "{type}" ({id}) via SSR bundle — success.
  • [plugin] No manifest entry for "{type}". Skipping. — keyword/shape mismatch.
  • [plugin] "{type}" is a layout widget — it must render as a layout entry (with slots), not content. Skipping.
  • [widget] SSR prefetch FAILED — shipping loading markup. key={hash} error={msg} — a prefetch error is swallowed, the widget still renders, and the page is marked degraded.
  • [layout] Slot "{name}" references missing widget {id} — a stale slot binding.
  • [layout] Plugin layout widgets are not supported in slots yet. Slot "{name}" references "{keyword}". Skipping.
  • [layout] Failed to prepare "{type}" ({id}): {msg} — precedes a layout SSR data prep failed breadcrumb.

Your own console.* calls go straight to renderer stdout — the host console is injected into the sandbox.

7. SSR sandbox throws

Thrown inside the node:vm context. Each one ends up as a SSR bundle failed to load or SSR data prep failed reason above, but the text lands in the renderer’s logs. The local harness is a port of the same sandbox and raises the same strings — the require whitelist’s seventh entry, the externalFetch block messages and the execution timeout all reached it in widget-core 0.12.2. The divergences that remain are in Previewing SSR locally.

MessageCause
Plugin cannot require "{mod}" (not in shared modules whitelist)Only seven modules are shared: react, react/jsx-runtime, react-dom, react-dom/client, react-dom/server, @tanstack/react-query, @homerunner-next/widget-core/runtime. Everything else must be bundled
[plugin] fetch to "{host}" blocked — not declared in the widget's externalFetch manifest field.Server-side fetch to a host outside central ∪ your declared externalFetch
[plugin] fetch to "{host}" blocked — private/metadata hosts are never allowed.Loopback, private ranges, *.local, link-local, metadata.google.internal — refused even when declared
[plugin] fetch blocked — more than 5 redirects.Redirect chain over 5 hops
[plugin] fetch blocked — unparseable URL: {raw}A non-URL passed to fetch
HTTP {status} redirect refused — SSR bundles must be served directly from their published URLYour ssr.url 3xx’d
HTTP {status}Non-2xx fetching the SSR bundle
[plugin] SSR bundle has no default export: {url}Your ssr-entry.ts exports no default — mandatory, layouts included
// packages/homerunner-renderer/lib/plugin-federation.ts (buildEnforcedFetch)
const host = u.hostname.toLowerCase();
const hostPort = u.port ? `${host}:${u.port}` : host;
const allowed = host === centralHost() || declared.has(host) || declared.has(hostPort);
// a declared entry may be "api.example.com" (any port) or "api.example.com:8443"

8. Browser: mount and runtime

data-hr-error on the host element — the complete vocabulary:

ValueSet byMeaning
mount-failedmount() per-embed catch, or a deferred hydrate/render rejectionThat embed threw; other embeds on the page still mounted
render-failedWidgetRenderBoundaryYour component threw during render; the error state is shown instead of a blank node. Cleared when a changing resetKey retries
rate-limitedClientWrapperForWidgetThe config fetch returned 429. Removed on a successful retry
widget-fetch-failedClientWrapperForWidgetThe config fetch failed for any other reason. Removed on a successful retry
property-not-foundthe rendered error element onlyA configured property slug does not resolve

Console strings, by prefix:

StringMeaning
Widget ID is requiredThe host element has no id. It must be "{widgetId}:{feedId}"
[hr-widget] "{type}" mount failed for one embed — other embeds continue.Per-embed isolation working as designed
[hr-widget] "{type}" deferred mount step failed (widget {id}, feed {feed}).A throw after the CSS gate resolved
[hr-widget] "{type}" render failed (widget {id}, feed {feed}) — rendering the error state instead of a blank node.Render boundary caught your component
[widget-core] window.HRWidgetRuntime is not available. Make sure the runtime.iife.js <script> tag loads before your widget entry.Script order on a hand-written embed
[widget-core] widget "{type}" did not register within 10000msA CSR layout’s slot child never registered — usually a 404 on its bundle
Cannot resolve relative plugin asset "{x}": manifest URL not known. Ship absolute URLs in the manifest, or pass the manifest URL when fetching.resolvePluginAssetUrl with no base
[widget] SSR state could not be inflated; widgets fetch client-side.A malformed dehydrated-state block; the page degrades to client fetching rather than breaking
[layout-csr] Skipping nested layout child "{keyword}" ({id}) in slot "{slot}".A system layout bound into a slot
[layout-csr] Skipping nested plugin layout child "{keyword}" — layouts cannot nest.A plugin layout bound into a slot
[layout-csr] Slot "{slot}" child {id} failed to resolve; skipping.The child widget row could not be fetched
[layout-csr] Child widget "{keyword}" failed to load/register; its slots stay empty.The child IIFE 404’d or never registered
[layout-csr] Property "{slug}" was not found in feed {id}. Check the layout's Property setting, or the data-hr-options filter.property on the embed.A hard 4xx on the pushed property
[HRWidget] discarded slot child "{type}" (id {id}) has no unmount handle (stale bundle or custom mount) — its widget tree may stay orphaned.A slot child mounted by something other than widget-core’s mount()

9. The /p/ asset proxy

GET {assets}/p/{slug}/{file}{file} is the dist-relative name (acme.iife.js for v1, hero/hero.iife.js for a suite). Full contract in Keywords, assets and URLs.

StatusBodyCause
400{"error":"Missing path: /p/{slug}/{file}"}No / after the slug
404{"error":"Plugin not found"}Unknown slug, or the plugin is not approved (a suspended plugin 404s within the 60 s pointer TTL)
500{"error":"Could not resolve plugin dist location"}The manifest has no usable dist base
404{"error":"Asset not in plugin manifest"}The file is neither a manifest-declared asset nor a key of your own dist/manifest.json. A kill-switched widget’s whole dist/{widget}/ directory 404s this way
302Success: Location is the hashed twin, Cache-Control: public, max-age=60, stale-while-revalidate=30, Access-Control-Allow-Origin: *

10. Dashboard config panel

MessageCauseFix
Plugin "{slug}" not enabled for this feed.The plugin is not installed on the feedInstall, customers and kill switches
Widget "{keyword}" is not declared by plugin "{slug}".A stored keyword the current manifest no longer resolves
Loading plugin manifest...Transient
No configSchema for this widget. Ask the plugin developer to ship one via zodToManifestSchema(widgetSchema.extend({...})) in the widget's config.ts.The sub-manifest is missing, 404’d, timed out at 5 s, or carries no configSchema. This degrades — it is never an errorConfig schema and UI schema
sub-manifest fetch failed (HTTP {status})Thrown by the fetcher; every call site catches it and degrades to the banner above
Unsupported schema type: [object Object]The whole config panel crashes. Caused by z.any(), z.unknown(), z.tuple(), z.intersection() or z.literal() surviving the JSON-Schema round tripConfig schema and UI schema

11. Local build and dev tooling

Build CLI (hr-widget-build, run for you by npm run build):

MessageCause
hr-widget-build: no manifest.json in the current directory.Run it from the project root
hr-widget-build: vite is not installed in this project.Missing dev dependency
hr-widget-build: invalid widget slug in manifest.widgets: {json}Slug fails /^[a-z0-9][a-z0-9-]*$/
hr-widget-build: widget slug "{s}" is reserved.dist, widgets or media
hr-widget-build: widget slug "{s}" is declared more than once.Duplicate slug
hr-widget-build: missing entry for widget "{s}" ({path}).No src/widgets/{slug}/index.tsx
[viteHomerunnerWidget] This project declares manifest.widgets[] — build it with `hr-widget-build` (which loops BUILD_WIDGET per widget), not a bare `vite build`.A suite built with a bare vite build. Use npm run build

Scaffolder (create-hr-plugin). Every row but the last is checked before a single file is written, and exits 1:

MessageCause
Error: expected a plugin slug first — got "{arg}".The first argument starts with -; flags go after the slug
Error: invalid plugin slug "{slug}" — lowercase letters, digits and hyphens only, starting with a letter or digit (e.g. "acme-weather").The slug fails /^[a-z0-9][a-z0-9-]*$/ — the same rule the publish audit applies to manifest.id (create-hr-plugin 0.8.1+; earlier versions accepted anything and you found out at publish)
Error: plugin slug "{slug}" is reserved — it collides with the published layout's own directories.The slug is dist, widgets or media (0.8.1+)
Error: unknown template "{t}" — expected "single" or "suite".--template / --template= with anything else, including nothing usable after the flag (0.8.0+; the --template=suite form is parsed from 0.8.1)
Error: directory "{slug}" already exists.Target exists
npm install failed — you can run it manually.Not fatal — the scaffold continues and exits 0

SSR preview (npm run dev:ssr). The first row is a red panel on every scenario tab. The rest are resolved at boot, before the server starts (widget-core 0.12.2+): the suite scaffold’s scripts/dev-ssr.mts prints them and exits 1, while a direct createSSRDevServer call throws the same string:

MessageCause
Missing {file} — run `npm run build` first.No SSR bundle at that path. {file} is dist-relative: {slug}-ssr.umd.js (single-widget) or {widget}/{widget}-ssr.umd.js (suite)
No widget selected — manifest.json declares `a`, `b`, `c`. Pass `widget: "a"` (or `--widget a`) to preview one.A suite with no --widget / --widget= / HR_SSR_WIDGET
Unknown widget "{w}" — manifest.json declares `a`, `b`, `c`.The named widget is not in widgets[]
Unknown widget "{w}" — manifest.json declares no `widgets[]`. Single-widget plugins have no widget to name; drop the option.A widget named on a v1 project
Widget "{w}" is CSR-only (`"ssr": false`) — there is no SSR bundle to preview.Correct behaviour, not a fault — verify it in the browser sandbox

More symptom-first triage in Troubleshooting.

Known gaps

Not supported yet. Built-zip audit results — errors and warnings — are visible only in the reviewer’s console. You cannot run that audit yourself, and nothing forwards its warnings to you. Treat the size thresholds and the configSchema warnings above as a self-checklist.

Not supported yet. There is no notification of any review outcome. No email, no in-app message. Check Dashboard → Plugins → My plugins.

Not supported yet. accepts: ["layout"] can never match. The slot picker always excludes layout-category candidates before the accepts predicate runs, and accepts is not validated at publish at all — it is advisory (unvalidated).

Not supported yet. Preset slot keys are never validated against the layout’s declared slot names. A typo creates a binding the layout will never render, with no error at any gate.

Not supported yet. prefill keywords are shape-validated only (/^[a-z][a-z0-9-]{0,63}$/) — never checked against the real system-widget keyword list. An invented keyword passes publish and is reported at creation time as {keyword}: not a widget this platform can create.

Not supported yet. externalFetch is advisory (unvalidated) at publish: any list of strings passes. It is enforced only at SSR runtime, so a typo’d host is discovered as a blocked fetch on a live page.

Not supported yet. replacedBy is declared, diffed at review, and read by no runtime or UI code — it is surfaced nowhere.

Not supported yet. A pre-release version (1.0.0-rc.1) passes both the browser audit and central, but every immutability optimisation keys off a bare X.Y.Z path segment. Publishing one silently drops you to TTL-bound caching and /p/-proxied asset URLs everywhere. Use plain three-part versions.

Styling and theming

Two things decide how a plugin widget looks: where your stylesheet is attached, and which host attribute says “dark”. Get those right and the rest is ordinary CSS. Get them wrong and you either repaint the customer’s whole page or flash light before going dark. The field rules this page depends on stay on the contract pages, linked rather than repeated.

Where your CSS actually lands

A shadow root protects your widget from the customer’s CSS. It does not protect the customer from yours.

PathYour stylesheet is attached…
Content widget, server-rendered pagein the page <head> and inside your shadow root
Content widget, CSR embedinside your shadow root only
Layout shell, server-rendered pagein the page <head> only — the shell is light DOM
Layout shell, CSR embedinside the layout’s shadow root

The <head> half surprises everyone. On a composed page the renderer collects every widget’s resolved assets.css, de-duplicates the list, and the edge worker appends one <link rel="stylesheet"> per entry to <head>:

// src/lib/homerunner/cf-worker.ts — buildCssHtml(), fed by the renderer's
// combined, de-duplicated assets.css.
return (assets.css || [])
  .map(function (href) { return '<link rel="stylesheet" href="' + escapeHtml(href) + '">'; })
  .join("");

That is deliberate: on browsers that do not get Declarative Shadow DOM the server-rendered markup is plain light DOM until mount() runs, and it has to be styled meanwhile. The consequence is the same either way — on a server-rendered page every selector you ship is a global selector. Three rules follow, for content widgets exactly as much as for layouts:

  1. Namespace every class. .sfacts, .pdpf-region, .acme-weather-card — a prefix you own. Never .card, .title, .grid.
  2. Never ship a global reset. See the Tailwind section below; this is the one that bites.
  3. :host(...) rules are the one form that self-scopes — outside a shadow tree they are valid CSS matching nothing, so they are inert in <head> and live in the shadow root.

Layouts add a fourth rule: never paint a background on the shell. That rule, and why a :host-only stylesheet is inert on every server-rendered page, are normative in Layout widgets.

The Tailwind stack, and why a suite ships none of it

Single-widget only. create-hr-plugin --template single ships Tailwind 4 through @tailwindcss/vite, plus tw-animate-css, clsx + tailwind-merge behind a cn() helper, Radix primitives and lucide-react. There is no tailwind.config.js; v4 is configured in CSS.

/* packages/create-hr-plugin/template/src/widget.css (first two lines) */
@import "tailwindcss";
@import "tw-animate-css";

Suite. --template suite ships none of that (create-hr-plugin 0.8.1) — no Tailwind, no tailwindcss() Vite plugin, no clsx/tailwind-merge, no Radix, no lucide-react, and no src/components/ui/ kit. Each widget gets a hand-written, class-prefixed {slug}.css instead, which is the same choice the reference suite made. The reason is the preflight problem below: a suite is expected to carry a layout, and a layout’s stylesheet is loaded document-level and unisolated on a server-rendered page, so a framework’s global reset lands on the customer’s whole document. Nothing stops you adding Tailwind to a suite yourself — everything in this section then applies, per widget. Build a suite has the file-by-file conversion.

Utilities work unchanged inside a shadow root because Tailwind 4 emits its theme variables on :root, :host — confirm it on any built plugin stylesheet with grep -o ':root[^{]*{' dist/acme-weather.css. Two things do not carry over from a normal Tailwind app.

dark: is the wrong signal. Tailwind’s built-in dark variant compiles to @media (prefers-color-scheme: dark) — the visitor’s OS preference, not the widget’s configured colour scheme. A customer who explicitly picks dark gets light utilities; one who picks light gets dark ones on a dark laptop. Never use dark: for theme-driven styling; use the host-attribute contract below.

Preflight is a page-wide reset. @import "tailwindcss" pulls in preflight, whose first rule is a universal selector:

/* tailwindcss/preflight.css:7-16 */
*, ::after, ::before, ::backdrop, ::file-selector-button {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  border: 0 solid;
}

Inside a shadow root that is contained. In the customer’s <head> it strips every margin, padding and border on their whole page. If your widget is server-rendered — or is a layout, which is always light DOM there — import Tailwind without preflight:

/* Preflight-free Tailwind 4. Both subpaths are real package exports
   (tailwindcss/package.json "exports"); `@import "tailwindcss"` is just
   these two plus preflight in @layer base. */
@layer theme, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);

Set your own box model on your namespaced root instead, then build and confirm the emitted CSS carries no *,::after,::before reset. Both the suite scaffold and the reference suite pdp-suite sidestep the whole problem by shipping no Tailwind at all.

The theme contract: data-hr-theme vs data-hr-scheme

Both attributes live on the host element — the shadow host for a content widget, the light-DOM shell <div> for a server-rendered layout. They mean different things and different actors own them.

AttributeMeansSet by
data-hr-theme="light|dark"the resolved theme; JS owns theming from herethe runtime at mount on every path — and the server, but only on a layout shell
data-hr-scheme="auto"config: “follow the OS”. A pre-hydration CSS gate, nothing morethe server only, under the conditions below

They are never both meaningful at once: the runtime removes data-hr-scheme the moment it attaches, and the media-query arm you write carries :not([data-hr-theme]) so it yields cleanly.

Why the attribute and not a wrapper class. Your stylesheet paints the host itself (:host { background: … }), and a .dark class on a descendant <div> can never change a custom property on the host — inheritance only flows down. That is why widget-core keeps a marker on the host (HOST_THEME_ATTR, runtime/host-theme.ts) instead.

Why not props.resolvedTheme. For an explicit light or dark the prop is correct. For auto it is derived from a cookie the Cloudflare worker never forwards, so it is "light" on every production server render — and because mount() hydrates with the server’s props, a component that branches on it stays light after hydration too. Every published sample plugin does this, and every one of them is light-only on a server-rendered page. Branch in CSS off the host attribute instead: attachHostThemeController also keeps that attribute live across an OS theme flip mid-session, which the React prop never does. Per-path prop details are in Component and SSR module.

Dark at first paint

Replacing the old guide’s “accept the flash or skip auto”: you can paint dark before a single byte of JavaScript runs. Two steps.

1. Dual-emit every dark rule under exactly these two prefixes, appended after the light rule — never branched, never replacing it:

/* The two prefixes required by the `mediaSafeAuto` contract in
   packages/homerunner-widget-core/src/manifest.ts. Light rule first, unchanged. */
.sfacts { background: #fff; color: #18181b; }

:where(:host([data-hr-theme="dark"])) .sfacts {
  background: #0a0a0b;
  color: #f4f4f5;
}

@media (prefers-color-scheme: dark) {
  :where(:host([data-hr-scheme="auto"]:not([data-hr-theme]))) .sfacts {
    background: #0a0a0b;
    color: #f4f4f5;
  }
}

2. Declare mediaSafeAuto: true on the widget summary (widget-core 0.12.0+). It is a promise about your CSS that nothing validates; the field rule and its enforcement level are in Manifest: widget summary.

The stamp appears only when all three hold: the resolved colorScheme is auto, the request emitted a Declarative Shadow DOM template, and the flag is true. On the legacy (non-DSD) bucket you keep the old behaviour, light until JS — correctly, because :host rules are inert without a shadow root at parse time. See How a widget renders for when DSD is emitted.

Four footguns, each of which shipped a real bug

  1. :not() must be INSIDE :host(...). :host([data-hr-scheme="auto"]:not([data-hr-theme])) works. :host([data-hr-scheme="auto"]):not([data-hr-theme]) parses fine and silently never matches a shadow host. Your dark arm is dead and nothing fails.
  2. :where() holds specificity at zero, so the dark selector must string-match the light one. :where(:host([data-hr-theme="dark"])) .price loses to .acme-card .price — same specificity contest, and the light rule may come later in the file. Repeat the light rule’s full descendant selector in the dark arm. The zero-specificity wrap is what lets a customer’s customCss keep winning, so do not drop it.
  3. Gate symmetrically. If a light rule is emitted only under a condition (an accent is set, a variant is on), the dark arm needs a branch for the “no dark value” case that restores the base values — otherwise auto paints the light value and flashes at hydration.
  4. Never resolve auto on the server. No cookies, no client hints, no UA sniffing. The server stamps configuration; the browser resolves it. Anything else poisons the shared page cache or is wrong on the first visit.

When your tokens come from JS

If you build a <style> string from options (accent, font, custom properties), emit the dark half twice from one helper so the two arms cannot drift:

// Shape of the platform's own palette hook — see the two `explorerDarkOverrides`
// calls in packages/homerunner-embeddables/widgets/explorer/hooks/useExplorerCss.ts.
// Token names are yours.
const darkTokens = (prefix: string, dc: PluginConfig["darkModeColors"]) => `
  ${prefix} .acme-card { --acme-accent: ${dc.accent}; color-scheme: dark; }
`;

const themeCss = `
  :host { --acme-accent: ${options.lightModeColors.accent}; }
  ${darkTokens(':where(:host([data-hr-theme="dark"]))', options.darkModeColors)}
  @media (prefers-color-scheme: dark) {
    ${darkTokens(':where(:host([data-hr-scheme="auto"]:not([data-hr-theme])))', options.darkModeColors)}
  }
  ${options.customCss ?? ""}
`;

Read options.darkModeColors directly, never through a resolvedTheme-gated variable — the dark arm has to ship on every render. Keep customCss last.

Theming a layout

A layout shell carries the same two attributes, but it is light DOM on a server-rendered page and a shadow host on a CSR embed. :host(...) only covers the second case, so write both arms in one forgiving selector list:

/* Layout shell: `[data-hr-theme]` matches the light-DOM wrapper on an SSR page;
   `:host([data-hr-theme])` matches the shadow host on a CSR embed. Each is inert
   on the other path. */
.pdpf-label { color: rgba(100, 100, 120, 0.9); }

:where([data-hr-theme="dark"], :host([data-hr-theme="dark"])) .pdpf-label {
  color: rgba(200, 200, 220, 0.9);
}

@media (prefers-color-scheme: dark) {
  :where([data-hr-scheme="auto"]:not([data-hr-theme])) .pdpf-label {
    color: rgba(200, 200, 220, 0.9);
  }
}

One difference from content widgets: a layout shell gets its server theme attribute without mediaSafeAuto. On a DSD-bucket request an auto shell is stamped data-hr-scheme="auto"; anything else is stamped data-hr-theme="light|dark" outright. A layout shell is the only server-rendered surface that carries a resolved data-hr-theme.

Rendering the base fields yourself

The dashboard renders the controls for spacing, colorScheme, accent colours, font, customCss and section automatically — see Config schema and UI schema. The platform applies none of them. They arrive in options and you render them.

// packages/create-hr-plugin/template/src/widget.tsx (parse + spacing, verbatim)
// plus the token/section pattern from hr-plugins/reviews-marquee/src/widget.tsx.
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { resolveWidgetSpacingStyle } from "@homerunner-next/widget-core/spacing";
import { Stylesheet, WebFont, WidgetSection } from "./components/ui";

const options = React.useMemo(
  () => parseWidgetConfig(configZod, props.options) as PluginConfig,
  [props.options],
);

const themeCss = `
  :host {
    --acme-accent: ${options.lightModeColors.accent};
    font-size: ${options.font.size}px;
    font-family: ${options.font.family}, ui-sans-serif, system-ui, sans-serif;
  }
  ${/* dark arms here — see above */ ""}
  ${options.customCss ?? ""}
`;

return (
  <>
    <WebFont family={options.font.family} />
    <Stylesheet css={themeCss} />
    <div style={resolveWidgetSpacingStyle(options)}>
      <WidgetSection show={options.section.show} title={options.section.title} />
      {/* … */}
    </div>
  </>
);

Four corrections to the old guide, all in that block:

  • <Stylesheet> takes css (string or string array, joined) and renders one plain <style>. It does not de-duplicate and it does not update in place.
  • <WidgetSection> takes {show, title, className} and returns null when show is false or title is empty. It is a heading, not a wrapper — no children, no options prop.
  • resolveWidgetSpacingStyle accepts raw or parsed options (it runs the legacy-width lift itself). Never hand-roll CSS from the spacing object.
  • <Stylesheet>, <WebFont> and <WidgetSection> are scaffold components under src/components/ui/, not SDK exports — in a suite, copy them into your project.

options is raw until you parse it, on the server and in the browser (Settings and options); customCss goes last so the customer’s rules win. Every value interpolated into that template is an admin-supplied string heading for dangerouslySetInnerHTML — sanitise the accent, family and customCss before you concatenate. And if you open a dialog through the elevated portal, only <link> tags are cloned into it: this whole <style> is missing there unless you pass the same string as cssString. See Portals and dialogs.

Spacing for a layout’s slots

Not supported yet. The platform’s own layouts expose per-slot spacing and a gap control. Those fields live in a private package and a plugin layout inherits neither.

Declare your own, reusing the SDK’s schema builders so the dashboard renders the same compound control:

// Builders from packages/homerunner-widget-core/src/spacing.ts:224-268.
import { z } from "zod";
import { widgetSchema } from "@homerunner-next/widget-core/schema";
import {
  marginBoxSchema, paddingBoxSchema, widthSchema, maxWidthSchema,
  resolveSpacingStyle, DEFAULT_SLOT_SPACING,
} from "@homerunner-next/widget-core/spacing";

const slotSpacing = z
  .object({
    margin: marginBoxSchema(DEFAULT_SLOT_SPACING.margin),
    padding: paddingBoxSchema(),
    width: widthSchema(DEFAULT_SLOT_SPACING.width),
    maxWidth: maxWidthSchema(DEFAULT_SLOT_SPACING.maxWidth),
  })
  .default(DEFAULT_SLOT_SPACING);

export const configZod = widgetSchema.extend({
  slots: z.object({ hero: z.array(z.string()).default([]) /* … */ }).default({}),
  slotSpacing: z.object({ hero: slotSpacing /* … */ }).default({}),
});

Apply it with resolveSpacingStyle(cfg.slotSpacing.hero) on the region wrapper. DEFAULT_SLOT_SPACING starts fully neutral, so your stylesheet keeps winning until the customer changes something. The slots field itself is mandatory and its keys must match your manifest slot names exactly (Layout widgets); every /spacing export is listed in SDK reference.

Fonts and bundled images

@font-face is ignored inside a shadow root — a stylesheet loaded there can use a family but can never define one. Hence two channels:

  • A face your design depends on: declare it in assets.fonts on the widget summary. It is injected at document level on both paths, where the browser will honour it.
  • A family the customer picks (options.font.family from the base schema): load it at runtime with the scaffold’s <WebFont family={options.font.family} /> — browser-only, and a no-op in the SSR bundle.

Images and other files are ordinary Vite imports (import heroBg from "./hero-bg.png", or url(./x.png) in CSS). The build rewrites each reference so it resolves against wherever your bundle actually loaded from, on the client and inside the SSR sandbox alike; small files of known asset types become data: URLs and larger ones are emitted beside your bundle. Never hand-write an absolute /dist/... path. Font counts and path rules, the inline threshold, the extension list and the suite-directory requirement are all in Keywords, assets and URLs — which also lists the origins a customer’s Content-Security-Policy has to allow for your stylesheets and fonts.

While you are developing

npm run dev intercepts CSS imports from src/ so Vite never injects a <style> into document.head (a shadow root would ignore it), serves your compiled stylesheet at the URL shape mount() expects, and on save cache-busts the <link> inside the shadow root rather than doing a normal CSS hot update. You configure none of it; the routes and the HMR event name are in Keywords, assets and URLs and the sandbox itself in Dev sandbox and mocking.

The document-level <head> copy of your stylesheet you can see locally, and should. npm run dev:ssr in its default server-render mode builds no shadow root and links your stylesheet into the harness page’s own <head> — the same unisolated condition as a composed page, which makes it the local test for the three rules above. Both shapes. One caveat: that <link> comes from your getStaticAssets, and a bundle exporting none — what --template suite scaffolds — needs widget-core 0.12.3+ to get one at all; below that it previewed with an empty <head> and no CSS. Previewing SSR locally lists what that harness still cannot do.

What you cannot see locally is dark-at-parse, which needs a real DSD-capable request. Check it on a server-rendered page before you submit — reload with the OS in dark mode and JavaScript disabled.

Fetching data

Your widget fetches in two places: inside the renderer’s server sandbox during SSR, and in the browser after it mounts. Both talk to the same HomeRunner public API, both share one React Query cache per page, and the whole point of doing the server half is that the browser half does not repeat it. One rule holds them together: the key you prefetch under on the server must be the key you read on the client. Everything else here is detail around that. It all applies to both manifest shapes; the one shape-dependent rule is where you declare externalFetch.

The base URL

Never hard-code a Central origin. The same plugin bytes are served to production, staging and local rigs, so the base URL is resolved at runtime by homerunnerApiBaseUrl() from @homerunner-next/widget-core/runtime, in this order:

  1. window.HRWidgetRuntime.apiBaseUrl — baked into the env-matched runtime IIFE the host page loads. This is what answers on every real customer page and in the dashboard playground.
  2. process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URL — Vite-injects it from your .env.local during npm run dev, and the renderer whitelists it into the SSR sandbox’s process.env.
  3. A hard fallback origin.

No host sniffing, nothing baked into your build. For local work, copy .env.example to .env.local and point that variable at whichever Central has your test data. The verbatim implementation and the fallback value live in Public API.

A fetcher you can actually ship

Put your fetchers and your query keys in one file so the server and client halves cannot drift. This is the shipped featured-stays fetcher, corrected:

// Corrected from hr-plugins/featured-stays/src/data.ts:30-45 (real, published).
// Three changes: `limit` (the shipped file sends `per_page`, which /feed/properties
// ignores), a WidgetFetchError instead of a bare Error, and an abort timeout.
import { homerunnerApiBaseUrl, WidgetFetchError } from "@homerunner-next/widget-core/runtime";

export async function fetchProperties(
  feedId: number,
  opts: { limit?: number; platform?: string } = {},
): Promise<PropertiesResponse> {
  const url = new URL(`${homerunnerApiBaseUrl()}/public-api/v1/${feedId}/feed/properties`);
  if (opts.limit) url.searchParams.set("limit", String(opts.limit));
  if (opts.platform) url.searchParams.set("platform", opts.platform);
  const res = await fetch(url.toString(), { signal: AbortSignal.timeout(8000) });
  // Read the STATUS before parsing: a 429 can be answered by a gateway and need
  // not be JSON, and the status is what the retry predicates classify on.
  if (!res.ok) throw new WidgetFetchError(res.status, `properties: HTTP ${res.status}`);
  return res.json();
}

export const queryKeys = {
  properties: (feedId: number, limit: number, platform?: string) =>
    ["featured-stays:properties", feedId, limit, platform] as const,
};

Why each change matters:

  • Page-size parameter names differ per endpoint family. per_page is silently ignored on the property endpoints and you get the default page — three of the four published reference plugins ship that bug. The per-endpoint table is in Public API.
  • AbortSignal.timeout matters most on the server. The vm timeout bounds only synchronous top-level work, so a hanging fetch inside dehydrateState holds the whole page render.
  • Throw WidgetFetchError, not Error. It carries status as an own property, which is what both the browser and the server retry policies read.

Route image URLs from the API through getProxiedImageUrl(url, width, height) from @homerunner-next/widget-core/utils, passing real dimensions — it defaults to 64 x 64 and its normalisation rules are in Public API.

The SDK’s fetch vocabulary

Available from @homerunner-next/widget-core/runtime (0.10.0+, so present in the version on npm today): homerunnerApiBaseUrl, fetchFeed, fetchWidget, useFetchWidget, WidgetFetchError, isRateLimitError (429), isPermanentError (any 4xx), retryTransient, and WidgetErrorState for a visible failure. From /utils: getFeedQuery, getWidgetQuery, getProxiedImageUrl. Signatures are in SDK reference.

retryTransient(max) returns a TanStack retry callback that retries a network error or a 5xx and never a 4xx; max counts retries, not attempts, so retryTransient(1) makes at most two requests.

Two names the old guide used do not exist in widget-core and will not resolve: okOrThrow and retryUnlessRateLimited. Both live in HomeRunner’s private packages.

Not supported yet. fetchPropertyBySlug exists in widget-core — it is how the CSR layout renderer validates a pushed property once instead of 404-ing in every slot — but it is not exported from /runtime. Call /public-api/v1/property-details yourself with feed_id and property.

The shared query client

Every widget on a page — first-party and plugin alike — renders inside one React Query client. In the browser it is the getSharedQueryClient() singleton on window.HRWidget, and it is created with these defaults:

DefaultValueWhy it matters to you
staleTime5 minutesData is fresh for five minutes after it was fetched.
refetchOnMountfalseA widget never refetches on mount, even when its data is stale.
refetchOnWindowFocus, refetchOnReconnectfalseTabbing back or coming back online does nothing.
retryretryTransient(3)Up to three retries for network/5xx, none for a 4xx.

refetchOnMount: false is the load-bearing one. Server-rendered pages are cached for hours (see Limits and error index), so by the time a visitor loads one the hydrated dataUpdatedAt is usually far past staleTime — and nothing refetches anyway. Hydrated SSR data is what the visitor sees until they navigate. If your widget genuinely needs live data, override it on your own query (refetchOnMount: "always", an interval, an explicit refetch()) and pay for it against the shared rate budget knowingly.

Get the client with useQueryClient(), not by calling getSharedQueryClient() inside your component: on the server that function returns a fresh throwaway client and anything you write to it is discarded.

The renderer’s server-side client is a different object with different defaults — staleTime: Infinity, and a retry policy that does retry a 429 (with backoff) because a page’s parallel prefetch burst can trip the limiter and bake skeleton markup into a cached page. It classifies errors by reading error.status structurally before falling back to instanceof, which is the second reason to throw WidgetFetchError.

Query-key discipline

The dehydrated state carries queryKey, queryHash and the data — never your queryFn. Hydration matches on the hash, which is JSON.stringify of the key with object keys sorted. So:

  • Build the key from one factory imported by both ssr.ts and widget.tsx. A key assembled twice by hand is a key that will drift.
  • Every element counts. ["k", 42, 6, undefined] hashes as ["k",42,6,null], which is not ["k", 42, 6]. Dropping a trailing argument on one side is the classic silent double-fetch. Keep keys to primitives.
  • Namespace with your plugin slug["pdp-suite:stay-hero", feedId, …]. The cache is page-wide, so an un-namespaced ["properties", 42] can collide with another vendor’s widget and silently serve their data.
  • The ["widget", …] and ["feed", …] key spaces belong to the platform. The renderer pre-seeds them before your hook runs and the CSR layout renderer writes them too. Do not write to them. See Component and SSR module.

Parse before you build a key. ctx.options arrives RAW in dehydrateState, exactly as it does in your component, so a key built from raw options can differ from the key the parsed component asks for — schema defaults, null/"" save artifacts and lifted legacy values all move the value. Run parseWidgetConfig(configZod, ctx.options) first, on both sides. The rules are in Config schema and UI schema.

The SDK’s own helpers disagree with each other and are not interchangeable: useFetchWidget(feedId, widgetId) caches under ["widget", feedId, widgetId], getWidgetQuery(widgetId) under ["widget", widgetId], and getFeedQuery(feedId) under ["feed", feedId]. Pick one per resource and use it on both sides.

Prefetch on the server, reuse in the browser

dehydrateState(queryClient, ctx) runs before your component renders on the server. Write into the client it hands you; the renderer serialises the whole cache into the page and getSharedQueryClient() rehydrates it before the first widget mounts.

// hr-plugins/featured-stays/src/ssr.ts:10-25 (real, published) — unchanged
// shape; the fetcher it calls now sends `limit`.
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { fetchProperties, queryKeys } from "./data";
import { configZod, type PluginConfig } from "./config";

export async function dehydrateState(
  queryClient: { setQueryData: (key: unknown, data: unknown) => void },
  ctx: { options: Record<string, unknown>; feedId: number; widgetId: string },
) {
  // Parse RAW stored settings with the component's own schema — the prefetch
  // key must equal the key the hydrated component asks for.
  const options = parseWidgetConfig(configZod, ctx.options) as PluginConfig;
  const limit = options.limit ?? 6;
  const platform = options.filter?.platform ?? undefined;
  try {
    const res = await fetchProperties(ctx.feedId, { limit, platform });
    queryClient.setQueryData(queryKeys.properties(ctx.feedId, limit, platform), res);
  } catch (err) {
    console.error("[featured-stays] dehydrateState failed:", err instanceof Error ? err.message : err);
  }
}

The component makes the ordinary call, and on a server-rendered page it resolves from the hydrated cache with no request:

// hr-plugins/featured-stays/src/widget.tsx:39-45 (real, published) — the same
// key factory, the same arguments, derived from the same parsed options.
const limit = o.limit ?? 6;
const platform = o.filter?.platform ?? undefined;

const q = useQuery({
  queryKey: queryKeys.properties(props.feedId, limit, platform),
  queryFn: () => fetchProperties(props.feedId, { limit, platform }),
});

prefetchQuery({queryKey, queryFn}) is the fire-and-forget alternative: it swallows the failure and caches an errored query. setQueryData inside a try/catch — what all four published reference plugins do — is the shape where you decide what a failure looks like, at the cost of an uncaught throw removing the widget.

Either way, only successful queries are dehydrated — TanStack’s default shouldDehydrateQuery is status === "success". A query that errored on the server ships no data, so your component renders its no-data branch into the HTML. If that branch is a spinner, crawlers get a spinner; return a real empty state instead. The renderer warns and marks the page degraded, which collapses its cache lifetime; failure semantics are normative in Component and SSR module.

getInitialData or dehydrateState?

Use dehydrateState. It is the one that gives you a single code path.

getInitialData(ctx) returns a value that becomes props.data, and the renderer ships it to the browser in the page’s props registry, so hydration does not refetch it either. But props.data is only ever populated on the SSR path: on the CSR path mount() passes data={undefined} explicitly, so a CSR-only widget, a hand-written customer embed and the dashboard playground all get nothing and you write the fetch twice anyway. dehydrateState + useQuery behaves the same everywhere — cache hit after SSR, live fetch on CSR, one component code path.

Reach for getInitialData only for a small non-query value the server render cannot proceed without, and handle it being undefined. Both receive the same three-key ctx, normative in Component and SSR module. Do not use initialData on your useQuery to paper over the difference; it competes with the hydrated entry.

What is already in the cache when you hydrate

On a server-rendered page the renderer seeds the widget config before any plugin code runs, so useFetchWidget and getWidgetQuery resolve without a network call. One trap comes with it: the seeded feed record is a projection, not the API response. To keep operator fields out of the page source, the renderer seeds only id and additional_info — under ["feed", id] and as the feed half of the ["widget", feedId, widgetId] pair. On a CSR page those keys hold whatever /details actually returned. So a component that reads feed.name works when you test it as a standalone embed and renders blank on a real server-rendered page. Read feed data only from additional_info (branding, route config), or fetch it under your own key.

Rate limits, and what a 429 does to a page

The public API is rate-limited per client IP across every endpoint, and on a server-rendered page every widget shares the renderer’s IP — one cold property page can spend a dozen calls before your widget’s first request. The number, the 429 envelope and the fact that you cannot read your remaining budget are in Public API.

  • Do not retry a 429 in the browser. retryTransient already refuses to, because retrying while throttled only stretches a visible loading state. Do not override it.
  • Detect it and say so. isRateLimitError(error) is true only for a 429; render a temporary, retry-later state — WidgetErrorState from /runtime gives you an amber one.
  • Treat isPermanentError(error) as a misconfiguration, not a blip: a 404, a disabled or archived feed, a bad id. Stop spinning and render a final state.
  • Batch on the server. One wide /feed/properties call in dehydrateState costs the page one request; N per-property calls from N widgets cost N.

A 429 during SSR is retried by the renderer; if it still fails the widget ships no-data markup and the page is degraded. In the browser a failed top-level config fetch also stamps the host element — the data-hr-error vocabulary is in Runtime, mount and the DOM contract.

Third-party hosts: externalFetch

In the browser there is no restriction — an ordinary fetch under ordinary CORS reaches any host that allows you. In the SSR sandbox fetch is wrapped in an allowlist. Central’s host is always reachable; every other host must be declared in the widget’s externalFetch array — on the widgets[] entry for a suite, at the manifest root for a single-widget plugin. Undeclared hosts, and private / loopback / link-local / cloud-metadata hosts even when declared, throw before the socket opens; redirects are re-validated at every hop up to a cap. The block messages, the host versus host:port form and the cap are in Component and SSR module; the field row is in Manifest: widget summary.

Nothing validates externalFetch at publish time (advisory (unvalidated)), so a typo’d host survives review and is discovered on a live page. The block throws inside your hook, so you choose the outcome: catch it for optional enrichment and render without the extra data; let it propagate for primary content, and the widget is replaced by a failure breadcrumb rather than rendering something wrong.

Two sandbox behaviours to design around:

  • Successful GETs are cached in-process for a short window (duration in Limits and error index), keyed by URL alone. Your server-side data can be that stale, and two widgets requesting the same URL make one request. ?dev=1 renders bypass the cache but stay fully enforced — you see a block locally, not in review.
  • A cached GET loses its response headers. The wrapper rebuilds the Response with Content-Type only, so header-driven logic (ETags, rate-limit counters, pagination links) is unavailable server-side on a normal render.

Mutations

Writes run client-side only. Use useMutation; the shared client’s retry default applies to queries, not mutations, so a mutation makes exactly one attempt unless you say otherwise.

// Corrected from the previous data-fetching page, which called an undefined
// `apiBase()`. The write surface is the quote/reservation flow — see
// contracts/public-api.md for bodies and the reservation envelope.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { homerunnerApiBaseUrl, WidgetFetchError } from "@homerunner-next/widget-core/runtime";

const queryClient = useQueryClient();

const createQuote = useMutation({
  mutationFn: async (payload: QuotePayload) => {
    const res = await fetch(`${homerunnerApiBaseUrl()}/public-api/v1/quotes`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload),
    });
    if (!res.ok) throw new WidgetFetchError(res.status, `quote: HTTP ${res.status}`);
    return res.json();
  },
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ["my-plugin:availability"] }),
});

invalidateQueries refetches any active subscriber immediately, which is the supported way to refresh data past the refetchOnMount: false default. Never send cookies or credentials — the API is supports_credentials: false and would reject them.

Developing against this offline

npm run dev intercepts your fetches with a mock service worker so you can drive empty, large, loading and error states without a live feed, and npm run dev:ssr runs your SSR bundle the way the renderer does, dehydrateState included. Both have real limits — the mock worker is a page singleton bound to one keyword, and the SSR dev server renders one widget per run (a suite names it: npm run dev:ssr -- --widget {slug}, widget-core 0.12.2+), never a composed page. See Dev sandbox and mocking and Previewing SSR locally.

Portals and dialogs

Your widget renders inside a shadow root. A dialog rendered in place inherits that root’s styles — and also its clipping, its overflow, and every stacking context between the host element and the viewport. React portals move the dialog elsewhere in the DOM. This page is about where, because the wrong target silently drops every stylesheet you rely on. Everything here ships in @homerunner-next/widget-core/runtime at the 0.10.0 publish floor, so no recipe below needs a newer SDK — see Install and versions.

The shadow structure you portal into

Two code paths build the same shape, and both name their parts with class names, not data attributes. mount adopts a server-emitted root by scanning for hr-react-root; if that class is missing, adoption fails and it rebuilds from scratch.

<!-- packages/homerunner-renderer/lib/render-utils.tsx:745-747 — server-emitted DSD -->
<div data-hr-widget="plugin:acme-weather:forecast" id="{widgetId}:{feedId}" data-hr-ssr-id="…">
  <template shadowrootmode="open">
    <link rel="stylesheet" href="…">       <!-- your CSS, plus any declared fonts -->
    <div class="hr-react-root">…your server markup…</div>
    <div class="hr-portal-container"></div>
  </template>
</div>
// packages/homerunner-widget-core/src/runtime/shadow-dom.ts:85-120 — client-built
[data-hr-widget="plugin:acme-weather:forecast"]      ← shadow host
  └─ #shadow-root (open)
       ├─ <link rel="stylesheet">                    ← one per resolved CSS URL
       ├─ <div class="hr-react-root">                ← React renders/hydrates here
       └─ <div class="hr-portal-container">          ← in-shadow portal target

setupShadowDOM(hostEl, cssUrls) returns {shadowRoot, shadowHost, reactRoot, portalContainer, cssReady, cssPending}. cssPending is the field most often missed: it says whether the react-root must stay hidden until cssReady settles. It is true whenever the <link>s were only just connected (a fresh client build, or the DSD ponyfill attaching a root around an inert template) and false for native Declarative Shadow DOM, which the browser already styled at parse. cssReady resolves when every sheet applies, on error, or after a hard timeout — see Limits and error index.

adoptDeclarativeShadowRoot and waitForShadowLinks are internal and not exported; setupShadowDOM, migrateSSRContent, getCSSUrlsFromElement and isShadowDOMSupported are. The adopt-versus-migrate order, the visibility gate and the shadowDOM disagreement matrix are in Runtime, mount and the DOM contract.

Not supported yet. A widget whose entire server output is a hoistable tag — a lone <style>, <link>, <script>, <meta>, <title> or <base> — reads as “no server markup”, so mount client-renders instead of hydrating. If your component’s top level is a bare <Stylesheet>, wrap it in a real element.

Two portal targets, and one failure that looks like a third

Both shapes. There are exactly two targets the platform gives you.

TargetHookLives inUse it when
In-shadow containerusePortalContainer()div.hr-portal-container, a sibling of your react-rootThe default. The dialog fits inside the widget’s box and nothing on the host page clips it.
Elevated singletonuseTopLevelPortal({cssString, enabled})Its own shadow root inside div.hr-dialog-portal-host at document.bodyA sticky sidebar, an overflow: hidden navbar, a fixed banner or a small card would clip or under-stack the dialog.

There is no third target. If no provider is in scope, usePortalContainer() returns undefined and Radix falls back to the document body:

// @radix-ui/react-portal 1.1.x, dist/index.mjs — the fallback that bites
const container = containerProp || (mounted && globalThis?.document?.body);

When your widget lives in a shadow root, document.body is outside it, so none of your stylesheets reach the dialog: it renders as unstyled browser-default HTML over the customer’s page. Treat that as a bug, never a design. Two ways to land there: portalling from a component that is not a descendant of mount’s providers, or calling useTopLevelPortal on a widget that has no shadow host — the hook needs one to clone stylesheets from and returns undefined without it, falling through to the same fallback. (With shadowDOM: false the fallback is harmless, because the whole widget is already in the light DOM.)

The providers come from WidgetHydrationTree on the hydrate path and from mount directly on the CSR path. On the server both are empty — the renderer passes no elements — and Radix’s Portal renders null until it has mounted, so dialog content is always client-only and has no server markup to mismatch against.

The default: usePortalContainer()

The scaffold’s DialogContent already does this, so most widgets never call the hook directly:

// packages/create-hr-plugin/template/src/components/ui/dialog.tsx:112-117
const portalContainer = usePortalContainer();
const elevatedPortal = useTopLevelPortal({ cssString, enabled: !!elevateDom });
const effectiveContainer = elevateDom ? elevatedPortal : portalContainer;

return <DialogPortal container={effectiveContainer}>…</DialogPortal>;

Note DialogPortal. It is a separate named export from the same module, because Dialog is a re-export of Radix’s Root and has no .Portal property — <Dialog.Portal> is a TypeError, not an API. The vendored Tooltip wires usePortalContainer() the same way.

Because this container sits inside your own shadow root, everything already works: your stylesheet, your CSS variables, your :host([data-hr-theme="dark"]) rules. Nothing to pass.

Escaping the widget: useTopLevelPortal

useTopLevelPortal({cssString, enabled}) builds, on first use (packages/homerunner-widget-core/src/runtime/elevated-portal.tsx:87-136):

  • a refcounted singleton div.hr-dialog-portal-host appended to document.body — fixed, 0 × 0, overflow: visible, z-index: 99999. It is created on the first mount and removed when the last elevated portal unmounts;
  • inside it, a per-dialog wrapper <div> with its own attachShadow({mode: "open"});
  • inside that shadow root, a cloned copy of your stylesheet links, one <style> fed from cssString, and div.hr-dialog-portal-root — the element the hook returns.

enabled: false skips all of it and returns undefined, which is how an elevateDom-style switch falls back to the in-shadow container. The two-field option type is normative in SDK reference.

The token trap

This is the failure everyone hits once. Read the two lines that cause it:

// packages/homerunner-widget-core/src/runtime/elevated-portal.tsx:105-119
const stopThemeMirror = mirrorHostTheme(shadowHost, wrapper);          // theme IS mirrored
const links = originalShadowRoot.querySelectorAll('link[rel="stylesheet"]');
links.forEach((link) => shadow.appendChild(link.cloneNode(true)));     // ONLY <link> is cloned
const styleEl = document.createElement("style");                       // cssString goes here

Only link[rel="stylesheet"] elements are cloned. Your compiled stylesheet — Tailwind utilities, your component classes — comes across intact. Everything you rendered as an inline <style> does not: accent colours, font.size, font.family and the customer’s customCss are all computed from options at render time and injected with <Stylesheet>, which is a <style> tag inside the react-root. In the elevated portal those declarations are simply absent, so every var(--your-token, <light fallback>) resolves to its fallback — a dialog that looks almost right in light mode and paints a white panel over a dark widget.

data-hr-theme is the exception. mirrorHostTheme copies it onto the portal wrapper and keeps it live with a MutationObserver, so an OS dark-mode flip while a dialog is open re-themes the dialog too, and :host([data-hr-theme="dark"]) rules inside the cloned sheets do match. The parse-time data-hr-scheme="auto" arm is not mirrored and need not be: the runtime replaces it with a concrete data-hr-theme the moment JS takes over, long before a dialog can open.

The fix: emit your tokens once, feed them to both

// derived from packages/homerunner-embeddables/widgets/split-cost/widget.tsx:191-226
const tokenCss = useMemo(
  () => `
    :host { font-size: ${cfg.font.size}px; }
    .acme-scope { --acme-accent: ${cfg.lightModeColors.accent}; }
    :where(:host([data-hr-theme="dark"])) .acme-scope {
      --acme-accent: ${cfg.darkModeColors.accent};
    }
    ${cfg.customCss ?? ""}
  `,
  [cfg.font.size, cfg.lightModeColors.accent, cfg.darkModeColors.accent, cfg.customCss],
);

return (
  <div className="acme-scope" style={resolveWidgetSpacingStyle(cfg)}>
    <Stylesheet css={tokenCss} />
    <Dialog>
      <DialogTrigger>Open</DialogTrigger>
      {/* the scope class must be on the portalled content too — see rule 1 below */}
      <DialogContent className="acme-scope" elevateDom cssString={tokenCss}>…</DialogContent>
    </Dialog>
  </div>
);

Two rules make the same string work in both shadow roots:

  1. Scope tokens to :host, or to a class you also put on the portal content. :host resolves to your widget host in one root and to the portal wrapper in the other, so it works in both. A class selector only matches what is actually inside the portal — and Radix renders only your DialogContent subtree there, not your outer wrapper. If your variables hang off a container class, add a layout-free scope class to the dialog’s own root element and include it in the token selector list. The platform’s explorer added exactly that (ELEVATED_TOKEN_SCOPE, in packages/homerunner-embeddables/widgets/explorer/hooks/elevatedScope.ts) after shipping without it and painting light-mode popovers over dark widgets.
  2. Sanitise before interpolating. Accent colours, font family and customCss are admin-supplied strings going into dangerouslySetInnerHTML, twice over.

cssString syncs in place through its own effect, so changing a token neither tears down the portal nor closes an open dialog.

elevateDom and cssString are props of DialogContent (and of the scaffold’s ResponsiveDialogContent, which forwards both), never of Dialog. The dual-emit dark selectors and the <Stylesheet> API belong to Styling and theming.

Radix’s accessibility warnings cannot see into a shadow root

Both portal targets put your DialogTitle inside a shadow tree. Radix checks for it with document.getElementById(titleId), which only searches the document’s own node tree, so the check fails and logs, on every open, from an otherwise correct dialog:

`DialogContent` requires a `DialogTitle` for the component to be accessible for screen reader users.
Warning: Missing `Description` or `aria-describedby={undefined}` for {DialogContent}.

Both are false positives — the aria-labelledby wiring inside the portal is correct. The platform’s own dialog fixes them; the scaffold’s vendored copy does not, so add this to your DialogContent if the noise matters:

// packages/homerunner-embeddables/components/ui/dialog.tsx:144-182 — the two fixes
<DialogPrimitive.Content aria-describedby={undefined} …>   {/* opts out of the description check */}
…
{/* sibling of DialogPortal, not a child, so Radix's Portal does not swallow it */}
{elevateDom && mounted && createPortal(
  <DialogPrimitive.Title style={srOnlyStyle} aria-hidden>{title ?? ""}</DialogPrimitive.Title>,
  document.body,
)}

Gate the mirror behind a post-mount useState/useEffect flag, as shown. typeof document differs between the server render and the hydration pass, and a structural difference there makes React discard your whole SSR tree.

useShadowHost()

Returns the element the shadow root is attached to, or null when shadow DOM is off. Three real uses: dispatching events the host page can hear (only the host is visible from outside the boundary); measuring, since getBoundingClientRect() on the host gives the widget’s box on the customer’s page and a shadow root has no box of its own; and reaching the light DOM for a third-party SDK that mounts by id into document and cannot see inside a shadow tree.

What it is not for: reading data-hr-options or data-hr-css off the host. Server-rendered pages moved props, options and CSS into a page-level registry keyed by data-hr-ssr-id; those attributes survive only on hand-written CSR embeds and old cached pages. The attributes actually present on a host today are listed in Runtime, mount and the DOM contract.

Events across the shadow boundary

Outbound — a custom event must be marked composed to cross an open shadow root, and dispatched on the host so the host page’s listeners see it:

// packages/homerunner-embeddables/widgets/related-properties/impressions.ts:55
hostEl?.dispatchEvent(
  new CustomEvent("acme:opened", { detail, bubbles: true, composed: true }),
);

Inbound — a document-level listener sees event.target retargeted to your shadow host, so root.contains(e.target) reads every click inside the widget as outside. Outside-close handlers must use composedPath():

// packages/homerunner-embeddables/widgets/explorer/components/filters/pro/ProWhereField.tsx:37-40
const isEventInsideRoot = (root: Node, e: Event): boolean =>
  typeof e.composedPath === "function"
    ? e.composedPath().includes(root)
    : root.contains(e.target as Node);

One consequence worth planning for: an elevated portal is a different shadow tree hanging off document.body, so a containment check against your own root correctly reports clicks inside an elevated dialog as outside your widget. Check containment against the portal target too, or let Radix’s own dismissable-layer handling own the close.

Layout widgets have no portal container on a server-rendered page

Suite, category: "layout" only. On a server-rendered customer page the layout shell is emitted as static light DOM — no <template shadowrootmode>, no shadow root — and its client mount is a deliberate no-op. Inside a layout shell on that path usePortalContainer() returns undefined, useShadowHost() returns null, and nothing ever hydrates: a dialog there can never open. Put interactive UI in a content widget bound into a slot — each slot child is its own island with its own shadow root and portal container. The same layout embedded as a CSR snippet does get a shadow root and full providers through LayoutCsrRenderer, which is why the asymmetry is easy to miss locally. Full contract: Layout widgets.

Checklist

  • Default to usePortalContainer(); reach for useTopLevelPortal only when the host page clips or under-stacks the dialog.
  • Never let a portal target be undefined on purpose — that is the unstyled document.body fallback, not a feature.
  • Build one token string; render it with <Stylesheet> and pass it as cssString, scoped to :host or to a layout-free class you also put on the dialog’s root element.
  • Keep mount’s shadowDOM in step with the manifest’s: mounting shadowDOM: false under a manifest that allows shadow DOM leaves no shadow host to clone from, so every elevated portal degrades to the body fallback on top of the stranding described in the mount contract.
  • Verify in dark mode, from a widget inside a sticky container, on a real page — not only in the dev sandbox.

See also

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

Dev sandbox and mocking

npm run dev is fully offline. The scaffold starts a Vite server on :3001, mounts your widget exactly the way a customer page does, and answers every HomeRunner public-api call from an MSW service worker fed by local fixtures. You never need a feed, an account or a network connection to drive the UI through every data state. Server rendering is a separate harness — see Previewing SSR locally.

Both shapes. create-hr-plugin generates a sandbox for each: --template single (the default) the one described below, --template suite a version that mounts every widget of the suite on one page and composes the layout — see Suites. Everything in between the two sections applies to both.

What npm run dev actually does

index.html loads exactly one script, /src/dev/main.tsx. That file is dev-only — the production client entries are src/index.tsx (single-widget) or src/widgets/{slug}/index.tsx (suite), and neither they nor any ssr-entry.ts ever import src/dev/ or mocks/, so MSW never reaches a published bundle.

// packages/create-hr-plugin/template/src/dev/main.tsx:39-70 (abridged) — single-widget.
// The suite template's src/dev/main.tsx has the same four steps; see "Suites" below.
const params = new URLSearchParams(window.location.search);
const mockEnabled = params.get("mock") !== "off";

if (mockEnabled) {
  await startMockWorker(KEYWORD, {
    scenario: (params.get("scenario") as MockScenario) || "default",
    fixtures,
    widgetSettings: defaultSettings(),   // configZod.parse({}), try/catch → {}
    delayMs: 300,
  });
  getSharedQueryClient().setDefaultOptions({ queries: { retry: false } });
}

registerPlugin(SLUG, Widget);
// …then mount() every [data-hr-widget-container] holding a [data-hr-widget="{KEYWORD}"]…
if (mockEnabled) mountDevPanel(KEYWORD);

Four of those steps have consequences you will meet:

  1. ?mock and ?scenario are read once, at boot, and neither is validated.
  2. Settings are seeded with configZod.parse({}), and the mocked /widget/{id} returns that object as the widget’s stored settings — so options.<field> reads are defined instead of crashing. The parse is wrapped in a try/catch that falls back to {}, so a schema with a required field and no default silently seeds nothing.
  3. React Query retries are off. Dev only. Without it the error scenario looks like a long spinner while the default retry backoff runs.
  4. mount() is the real one — shadow root, theme controller, ClientWrapperForWidget, error boundary: Runtime, mount and the DOM contract.

The mount host is <div data-hr-widget="plugin:{slug}" id="dev-widget:1"> — one per namespaced keyword in a suite, with its own id (frame-1:1, intro-1:1, facts-1:1). That id is the colon-joined {widgetId}:{feedId} pair mount splits to build its config fetch, and the single-widget default matches the mock defaults (widgetId: "dev-widget", feedId: 1). The built-in /widget/{id} route matches any id, so you can change it freely — the data-hr-widget keyword is the part that must stay in sync with what you pass to mount(). In a suite the ids matter for a second reason: mocks/fixtures.ts keys each one to its own keyword (Suites).

The two switches are ?scenario=default|empty|single|large|loading|error and ?mock=off (real staging data). ?scenario= is cast, not validated: an unknown value is stored verbatim, behaves like default, and leaves every panel button unhighlighted — that unlit button row is the tell.

Scenarios

Six named data states, defined by the SDK and exported as MOCK_SCENARIOS:

ScenarioBehaviourRows the built-in generators return
defaulta healthy, representative payload6
emptyzero results — empty states, placeholders0
singleexactly one result — single-item layouts1
largea big page — overflow, pagination, virtualization48 (of a reported 240 total)
loadingthe request never resolves — spinners, skeletons
errorthe request 500s — error states, fallbacks

loading is MSW’s delay("infinite"), so the promise genuinely never settles; error returns a 500 after the configured latency; everything else applies the latency and falls through to the data path.

Why the widget always mounts

Both shapes. The three config endpoints — /{feedId}/details, /widget/{widgetId} and /{feedId}/feed/widgets — are deliberately not scenario-gated; they apply the latency and nothing else. mount() cannot render until the widget’s feed and settings resolve, so gating them would turn loading and error into a blank page with nothing to look at. The handler file says so in as many words: “These are NOT gated by loading/error so the widget always mounts”. The gated data endpoints are /{feedId}/feed/properties, /{platform}/properties, /reviews, /property/{id}/full, /property-calendar and /{uuid}/calendar — so the scenarios exercise your data code, never the bootstrap.

Fixtures — mocks/fixtures.ts

Anything you do not override uses the SDK’s built-in generators, scaled by the active scenario. Override an endpoint only when your widget needs a specific shape.

Fixture keyEndpointFactory arguments
feedDetailsGET /{feedId}/details(feedId)
widgetGET /widget/{widgetId}(widgetId, widgetSettings)
feedWidgetsGET /{feedId}/feed/widgetsnone
propertiesGET /{feedId}/feed/properties, GET /{platform}/properties(request)
reviewsGET /reviews(request)
propertyFullGET /property/{id}/fullnone
calendarGET /property-calendar, GET /{uuid}/calendarnone

Each value is either the raw JSON the endpoint returns, or a (…) => json factory called at request time — a factory is how you vary the response by request:

// packages/create-hr-plugin/template/mocks/fixtures.ts — the commented example, uncommented
export const fixtures: MockFixtures = {
  reviews: (request) => {
    const url = new URL((request as Request).url);
    const perPage = Number(url.searchParams.get("per_page") ?? 10);
    return { status: "ok", result: [], pagination: { total_items: 0, average_rating: 0 } };
  },
};

Two rules that catch people out. An override replaces the generator, not the gateloading and error still short-circuit before your fixture runs, so an error-scenario screenshot is the platform’s 500, never your rows. And envelopes are not uniform: the generators mirror the real API, where /{feedId}/details returns {status, status_code, result} while /widget/{id} returns {success, status_code, data, meta}. Match the endpoint you are overriding — Public API.

Handlers match any origin (*/public-api/v1/…), so they work whatever NEXT_PUBLIC_HOMERUNNER_BASE_URL points at. Anything they do not match falls through untouched (onUnhandledRequest: "bypass") — Vite HMR, images, your own third-party APIs — which is also why a mistyped public-api path silently hits the network instead of erroring.

Not supported yet. MockFixtures has an index signature, so TypeScript accepts extra keys — but createMockHandlers reads only the seven above, and an extra key is inert. To mock an endpoint that is not in the table, take the running worker off the control handle and add your own MSW handler: window.__HR_MOCK__.worker.use(…).

The dev panel

A plain-DOM bar pinned to the bottom of the page, mounted only when mocking is on. It drives window.__HR_MOCK__, the control handle startMockWorker publishes.

ControlWhat it does
scenariocontrol.setScenario(s), then a targeted query reset (below)
themelight / dark / auto → writes colorScheme into mock branding and the widget settings, then dispatches options-updated
latencycontrol.setDelay(ms), 0–2000 in steps of 100, applied to every mocked response
settingsa JSON textarea → control.setSettings(obj) + the same options-updated push

The reset-versus-invalidate rule

Both shapes. This is the one piece of panel behaviour you have to know, because it makes the console and the buttons behave differently. Every scenario button runs this after setScenario:

// packages/create-hr-plugin/template/src/dev/DevPanel.ts:44-52 (try/catch elided)
getSharedQueryClient().resetQueries({ predicate: (q) => q.queryKey?.[0] !== "widget" });

reset, not invalidate: invalidating keeps the previous data cached, so isLoading stays false and a switch to loading / error / empty would keep painting the old rows. The predicate spares every ["widget", …] key — the feed-and-settings query ClientWrapperForWidget owns — so the widget re-runs its data queries without unmounting.

That reset lives in the panel, not in the mock layer, so the console equivalent needs both calls (the shared client is a page singleton on window.HRWidget.__QUERY_CLIENT__):

__HR_MOCK__.setScenario("error")                      // alone: stale data stays on screen
HRWidget.__QUERY_CLIENT__.resetQueries({ predicate: (q) => q.queryKey?.[0] !== "widget" })

If your own queries use a key starting with the literal "widget" the predicate spares them too, and the scenario buttons will appear not to work. Prefix your keys with your plugin slug, as the scaffold’s queryKeys factory does — Fetching data.

Dark mode works here

The theme select writes colorScheme into both the mocked feed branding and the pushed settings, and the CSR mount() path resolves resolvedTheme from that value: dark and light pass through, auto follows your OS prefers-color-scheme, and light is only the fallback when nothing is set anywhere. Older documentation claimed CSR mount always passed resolvedTheme: "light" and sent you to the SSR harness for dark mode — that was wrong. What is still true is that resolvedTheme is effectively always light in production SSR, which is why dark styling belongs in CSS — Styling and theming.

Testing options-updated

The settings editor is your test harness for the live-settings channel — the same CustomEvent the dashboard playground dispatches while a customer types in the config form. Paste an options object, hit apply settings, and the mounted widget re-renders without a remount. The listener table, and the fact that a hydrated SSR widget has none, is in Runtime, mount and the DOM contract. Two mechanics to know before you trust what you see:

  • The event payload replaces the widget’s settings wholesale. It is treated as the widget’s complete stored settings and re-run through getFinalSettings, so paste a partial object and every other field falls back to feed branding or disappears. Test the payload your form would actually send — see Settings and options.
  • The panel’s own copy of the settings merges instead. control.setSettings(obj) patches the mock store, while the dispatched event carries exactly what you typed. After a partial apply, the mocked /widget/{id} and the mounted widget hold different objects; a reload resolves to the merged one. Apply a complete object and the two never diverge.

Use it to prove your parse is real: clear a field to null or "" and confirm the component still renders, because parseWidgetConfig strips those leaves rather than throwing. A widget that reads props.options directly flips to data-hr-error="render-failed" here — exactly the failure a customer would see.

Real staging data

Both shapes. ?mock=off skips the worker and the panel entirely, and your fetches go out for real. Copy .env.example to .env.local, set NEXT_PUBLIC_HOMERUNNER_BASE_URL=https://beta.homerunner.io, then open http://localhost:3001/?mock=off. That file is optional for mocked development and only consulted here and by npm run dev:ssr. Three things to get right:

  • The env injection is dev-only. viteHomerunnerWidget reads every NEXT_PUBLIC_* key from .env / .env.local and defines it as a process.env.X literal in the dev build only; production reads the same value from window.HRWidgetRuntime.apiBaseUrl. A key that works here can be undefined at SSR time.
  • With no .env.local you hit the production fallback, not staging. Set the variable.
  • Change the mount id. dev-widget:1 exists on no real feed; the widget-config fetch fails and the host gets data-hr-error="widget-fetch-failed". Put a real {widgetId}:{feedId} in index.html. The public API needs no key but is rate limited per IP — Public API.

With no panel there is nothing to flip: this mode checks real response shapes against your fixtures, it does not drive states.

Suites

npx create-hr-plugin <slug> --template suite generates a sandbox that mounts every widget of the suite on one page and composes the layout (create-hr-plugin 0.8.0+). index.html carries one host per namespaced keyword, src/dev/main.tsx imports each widget’s own src/widgets/{slug}/index.tsx — which registers and auto-mounts it, exactly as its built IIFE does in production — and the layout’s mocked settings carry slots, so it resolves the other two as real slot children through LayoutCsrRenderer. That is the whole client half of layout composition, offline. The server half is not reproducible locally: Previewing SSR locally.

Not supported yet. startMockWorker is a page singleton bound to ONE keyword — it returns the existing control on every repeat call and bakes that keyword into the handlers. So one page has one scenario, one latency, one branding and one settings object for every widget on it. The scaffold seeds those settings with the union of every widget’s configZod.parse({}) and relies on each widget’s zod stripping the keys that are not its own; give two widgets a field with the same name and they share its value here. (npm run dev:ssr renders one widget, so it seeds only that widget’s defaults and has no such collision.)

What the singleton would break, and what stops it:

  • Every content widget still mounts. mount() renders the component you hand it and never routes on the keyword the mocked /widget/{id} echoes back.
  • A CSR layout’s slots would not work. LayoutCsrRenderer resolves each binding by fetching /widget/{childId}, and the built-in handler hands every id the one baked keyword — so every slot child would resolve to the layout itself.

The escape hatch is the widget fixture factory, which receives the requested id. The suite template ships it, and it is not optional in a suite:

// packages/create-hr-plugin/template-suite/mocks/fixtures.ts (abridged) —
// per-id keywords, which is what lets three widgets coexist on one page.
const KEYWORD_BY_ID: Record<string, string> = {
  "frame-1": `plugin:${SLUG}:page-frame`,
  "intro-1": `plugin:${SLUG}:intro-card`,
  "facts-1": `plugin:${SLUG}:quick-facts`,
};

export const fixtures: MockFixtures = {
  widget: (widgetId: unknown, settings: unknown) => ({
    success: true, status_code: 200, meta: [],
    data: {
      id: String(widgetId), status: true, feed_id: 1, user_id: 1, display_name: "Dev Widget",
      keyword: KEYWORD_BY_ID[String(widgetId)] ?? `plugin:${SLUG}:intro-card`,
      settings: settings ?? {},
    },
  }),
};

The bindings the layout opens with are a plain Record<slotName, widgetId[]> — the same shape the dashboard’s Slot Configuration panel writes (Layout widgets) — exported from the same file and merged into the seeded settings under slots:

// packages/create-hr-plugin/template-suite/mocks/fixtures.ts — DEV_SLOTS
export const DEV_SLOTS: Record<string, string[]> = {
  hero: ["intro-1"], main: ["facts-1"], sidebar: [], bottom: [],
};
// packages/create-hr-plugin/template-suite/src/dev/main.tsx (abridged) —
// import the entries AFTER the worker resolves, or the first fetch escapes unmocked.
await startMockWorker(KEYWORDS[0], { fixtures, widgetSettings: defaultSettings(), delayMs: 300 });
getSharedQueryClient().setDefaultOptions({ queries: { retry: false } });

await import("../widgets/page-frame/index");
await import("../widgets/intro-card/index");
await import("../widgets/quick-facts/index");

mountDevPanel(KEYWORDS);   // a LIST of keywords — it pushes settings to all of them

The keyword passed to startMockWorker is only the fallback the built-in /widget/{id} response echoes back; the factory above overrides it per id. mountDevPanel takes the keyword list in the suite template and targets [data-hr-widget="<keyword>"] for each, so one apply settings click reaches every mounted widget — with the one shared object described above.

Converting an existing single-widget sandbox. Three files change, and nothing else: index.html grows one [data-hr-widget-container] host per namespaced keyword with distinct ids; src/dev/main.tsx drops its static ../widget / ../config imports for one dynamic import() per src/widgets/{slug}/index, and passes a keyword list to mountDevPanel; mocks/fixtures.ts gains the widget factory above. The build side needs nothing — Vite’s CSS middleware already serves each widget’s transformed stylesheet at /dist/{plugin}/{widget}/{widget}.css, walking that widget’s own entry so the sheet carries only its imports, and the hr-widget-css-update HMR event cache-busts the <link> inside that widget’s shadow root. The full project conversion is in Build a suite.

The worker file

The sandbox registers /mockServiceWorker.js, which ships in public/ and is regenerated by npx msw init public (both templates record msw.workerDirectory for exactly that) — do that if the sandbox stops intercepting after an msw upgrade. Never delete it: it is what makes npm run dev work.

Vite copies publicDir into outDir on every build, which used to publish that dev-only file inside dist/ and list it in dist/manifest.json. Both scaffold templates now switch publicDir off for builds and leave it on for dev (create-hr-plugin 0.8.0+):

// packages/create-hr-plugin/template/vite.config.ts and template-suite/vite.config.ts
publicDir: command === "build" ? false : "public",

A project scaffolded before 0.8.0 does not have that line — add it, or keep living with a harmless extra file in your published prefix. Either way it is your build config that decides, never the file’s presence in public/.

What the sandbox does not cover

It is a real mount() against fake data, and nothing more:

  • No server render — no node:vm sandbox, no require whitelist, no getInitialData / dehydrateState, no hydration: Previewing SSR locally.
  • No Declarative Shadow DOM and no props registry. The sandbox always takes the CSR branch, so data-hr-ssr-id hydration bugs cannot appear here.
  • No externalFetch enforcement. A cross-origin call your manifest never declares succeeds locally and is blocked on the server: Component and SSR module.
  • No dashboard form. Your configSchema is never converted back to zod here, so a round-trip breaker renders fine locally and empties the customer’s config panel: Config schema and UI schema.
  • No server-side layout composition. The suite sandbox composes a layout’s slots the client way, from bindings you wrote into mocks/fixtures.ts. On a server-rendered page the renderer renders the bound children and hands them to the shell as renderedSlots, in light DOM, with the client mount a deliberate no-op — a different code path with no local equivalent: Layout widgets.

Walk all six scenarios plus a dark-theme pass, then verify the server render. Symptoms and messages are indexed in Troubleshooting.

Previewing SSR locally

npm run dev proves your widget works in a browser. It proves nothing about the server. On a customer page the renderer evaluates your built SSR bundle inside a node:vm sandbox with no DOM, calls your data hooks there, and ships the resulting HTML before any client code runs — see How a widget renders. npm run dev:ssr runs that path on your laptop. It is a port of the renderer’s loader, shipped in the SDK’s /testing subpath (widget-core 0.8.0+, so the npm latest build has it too — Install and versions). Three things it gained recently matter enough to gate every claim below. In 0.12.2: it previews one named widget of a suite, and it stopped diverging from the renderer’s sandbox in five places. In 0.12.3: it links a stylesheet for a widget that exports no getStaticAssets, which before then previewed unstyled.

Both shapes. A single-widget project needs nothing but a build. A suite has no default widget, so you name the one to preview — see Suites: one widget at a time. What has no local equivalent, in either shape, is a layout’s server-side slot composition.


Running it

npm run dev:ssr is tsx scripts/dev-ssr.mts, which calls createSSRDevServer from @homerunner-next/widget-core/testing. It reads dist/ and never compiles anything, so a build must come first.

# packages/create-hr-plugin/template/package.json and template-suite/package.json —
# the same "dev:ssr": "tsx scripts/dev-ssr.mts" script in both shapes.
npm run build                            # Single-widget: dist/{slug}-ssr.umd.js + .iife.js + .css
npm run dev:ssr                          # Single-widget — serves http://localhost:3003

npm run build                            # Suite: dist/{widget}/{widget}-ssr.umd.js per widget
npm run dev:ssr -- --widget intro-card   # Suite — name the widget to preview

Skip the build and every scenario tab renders a red SSR render failed: panel naming the missing bundle by its dist-relative path; the exact string, and the four boot-time selection errors below, are in the error index.

Naming a widget — Suite

The selection is resolved before the server starts, against the root manifest.json’s widgets[], so an unusable choice fails at boot with a message naming the widgets that do work — never a red panel on every scenario tab. The suite scaffold’s scripts/dev-ssr.mts runs that lookup itself and prints the message before exiting 1; call createSSRDevServer yourself and it throws the same string. Three ways to make the selection, highest precedence first (widget-core 0.12.2+):

HowExample
--widget {slug} in argvnpm run dev:ssr -- --widget intro-card
--widget={slug} in argvnpm run dev:ssr -- --widget=intro-card
HR_SSR_WIDGETHR_SSR_WIDGET=intro-card in .env.local

Four conditions fail instead of booting: naming no widget at all, naming one the manifest does not declare, naming one on a single-widget project (there is nothing to name), and naming a CSR-only widget — a summary carrying "ssr": false ships no SSR bundle, so there is nothing to preview. That last one is the correct outcome, not a fault; verify a CSR-only widget in the sandbox instead.

Everything the selection drives comes from that one lookup: the keyword (plugin:{slug}:{widget}), the nested dist/{widget}/{widget}-ssr.umd.js, .iife.js and .css bundles, and the widget’s declared externalFetch hosts (Manifest: widget summary). A single-widget project resolves the flat plugin:{slug} keyword and dist/{slug}-* files exactly as it always has.

Routes: / redirects to /preview?scenario=default, which shows the scenario tabs, the rendered envelope and an inspector (raw server HTML, the props your component received, the dehydrated React Query cache). /__render.json?scenario=… returns the same render as JSON — html, props, dehydratedState, assets — which is the scriptable one. /dist/* and /mockServiceWorker.js are static, and /p/{anything}/{file} is a local stand-in for the plugin asset proxy served out of dist/ (Keywords, assets and URLs).

The SSR bundle is re-read from disk on every request, so npm run build in another terminal plus a refresh picks up new code with no restart. The mock handlers, the widget selection and the seeded settings are fixed at boot — changing a config.ts or your fixtures needs a restart.

Configuration

scripts/dev-ssr.mts calls process.loadEnvFile(".env.local") then process.loadEnvFile(".env"), each in a try/catch so a missing file is fine. Node’s loader never overwrites a variable that is already set, which gives you this precedence (verified on Node 22): shell environment › .env.local.env › the defaults below. --widget is the only CLI flag the script reads; npm run dev:ssr -- --hydrate still does nothing.

KeyDefaultEffect
PORT3003Server port
HR_SSR_WIDGETunsetSuite only. Which widget to preview, when you do not pass --widget (0.12.2+)
HR_SSR_HYDRATEunset1 turns on client hydration; anything else is server-render only
HR_RUNTIME_URL${assetBaseUrl}/w/runtime.iife.js, hydrate mode onlyThe runtime IIFE that defines window.HRWidgetRuntime. In server-only mode it stays undefined and is never used
NEXT_PUBLIC_HOMERUNNER_BASE_URLhttps://beta.homerunner.ioExposed inside the sandbox as process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URL, and to the client as HRWidgetRuntime.apiBaseUrl
NEXT_PUBLIC_WIDGET_ASSET_BASE_URLhttps://assets-dev.homerunner.ioExposed as process.env.NEXT_PUBLIC_WIDGET_ASSET_BASE_URL; also the origin HR_RUNTIME_URL defaults from

.env.example covers most of that, but not all of it and not uniformly. Both templates ship the two NEXT_PUBLIC_* keys uncommented, as live values, plus HR_SSR_HYDRATE and HR_RUNTIME_URL commented out; the suite’s file adds a commented HR_SSR_WIDGET, which the single-widget one has no use for. PORT is in neither file — set it in the shell or add it to .env.local yourself. The two base URLs barely matter for data: the mock handlers match any origin, so the value only shows up if you read it in your own code.

Scenarios, and the loading downgrade

The tabs are the same six scenarios the browser sandbox uses; their meanings and the fixture keys are in Dev sandbox and mocking. Two behaviours are specific to this server. loading is silently downgraded to default for the server render — the loading handler never resolves, so an un-downgraded server render would hang forever instead of producing HTML. The real scenario is restored immediately afterwards, so in hydrate mode the client sees loading over a populated server render. And latency defaults to 0 ms here, not to the sandbox’s default.

Settings come from configZod.parse({}) at boot — the whole default object — so options.<field> reads never crash. In a suite that is the selected widget’s own src/widgets/{widget}/config.ts, not the page-wide union npm run dev has to seed, so there is no cross-widget field collision here. See the divergences for why pre-parsed settings are also a trap.

Hydrate mode

Set HR_SSR_HYDRATE=1 and the page additionally loads, in document order: the dehydrated cache as window.HRWidget.__REACT_QUERY_STATE__, the runtime IIFE from runtimeUrl, a config block setting HRWidgetRuntime.apiBaseUrl and pointing proxyBaseUrl at window.location.origin, then the selected widget’s client IIFE — dist/{slug}.iife.js for a single-widget project, dist/{widget}/{widget}.iife.js for a suite. Your mount() finds the server markup, builds a shadow root, migrates the SSR content into it and hydrates (Runtime, mount and the DOM contract). Because proxyBaseUrl is the local server, CSS resolves through the local /p/ route, not the CDN.

Two things to know before you trust it. Hydrate mode is not offline — the runtime IIFE comes from the asset origin, and with hydrate on and no runtimeUrl resolvable the page prints an amber warning and shows the server HTML only. And client-side requests are not mocked: the page registers mockServiceWorker.js directly, but MSW only intercepts for clients that have sent it a MOCK_ACTIVATE message, which only startMockWorker() does, and no handlers are registered in the page at all. Every fetch your component makes after hydration goes to the real apiBaseUrl with the harness’s placeholder ids.

The server render is always resolvedTheme: "light" unless you pass branding. For a dark one, add branding: { colorScheme: "dark" } to the mock option in scripts/dev-ssr.mts — the harness derives the theme from nothing else.


What the preview genuinely reproduces

renderPluginSSR is a port of the renderer’s plugin-federation.ts loader, kept in the SDK so it stays versioned with the contract. Faithfully reproduced:

  • The same vm contract — your UMD is IIFE-wrapped and evaluated in a fresh node:vm context, with the same “no default export” check and the same module-scope execution timeout (0.12.2+), whose value is in the limits index.
  • The same require whitelist — all seven specifiers, including @homerunner-next/widget-core/runtime (0.12.2+) — refusing everything else with the same message. An import your build did not bundle fails here.
  • The same browser-global stubs for window, document, self, navigator, location, localStorage and sessionStorage — inert proxies, not real objects. A top-level window.foo read survives; a call that expects a real DOM does not. And the same absences: no TextEncoder, no TextDecoder, no crypto (0.12.2+ removed the two encoders the harness used to seed and the renderer never had).
  • The same process.env whitelist (three keys), the same __HR_PLUGIN_ASSET_BASE__ global for bundled assets (0.12.2+), and the same call order: evaluate → getInitialDatadehydrateState (fresh QueryClient, retries off) → getStaticAssetsrenderToString, same ctx shape. Component and SSR module owns that contract.
  • The same externalFetch enforcement (0.12.2+) — the widget’s declared hosts are read out of manifest.json and the sandbox fetch is wrapped in a port of the renderer’s allow list, so a call to an undeclared host fails here instead of first in review. An absent field declares nothing, exactly as in production.
  • The same hydration tree (0.12.2+) — the server render runs inside WidgetHydrationTree with identifierPrefix set to the SSR id, and the preview envelope carries that id in data-hr-ssr-id, so mount() hydrates with the same tree and the same prefix (Runtime, mount and the DOM contract).
  • Mocked server-side fetch. msw/node patches globalThis.fetch and the sandbox reads through to it at call time. Unmatched requests are bypassed to the real network, not failed.
  • The same stylesheet fallback (0.12.3+)getStaticAssets is optional, and the renderer serves the widget’s manifest assets when a bundle omits it. The harness takes the same branch: it links the widget’s declared assets.css (or, absent one, the conventional dist/ name) when that file is actually in dist/, links nothing when it is not, and defers to getStaticAssets verbatim whenever the export is there — including when the export returns an empty list, which production also honours. The fallback reaches data-hr-css too, so hydrate mode is styled as well. Below 0.12.3 there was no fallback, so a hook-less widget — the shape --template suite scaffolds — previewed with an empty <head> and no CSS in either mode.

Where the preview differs from production

Take this seriously: code can pass here and fail on the renderer, and the reverse. Component and SSR module carries the summary table; what follows is what each difference does to you. Everything here is measured against widget-core 0.12.3 — five divergences that used to live on this list (the missing /runtime shared module, seeded TextEncoder/TextDecoder, an undefined __HR_PLUGIN_ASSET_BASE__, unenforced externalFetch, no execution timeout) were closed in 0.12.2, and a sixth (no stylesheet for a widget without getStaticAssets) in 0.12.3. On an older SDK they all still apply.

  • __HR_PLUGIN_ASSET_BASE__ points at your laptop. It is defined, but as http://localhost:{port}/dist/ — the renderer derives it from the bundle’s CDN URL ({origin}/{env}/{slug}/{version}/dist/). Bundled-asset URLs therefore have the right shape here and the wrong origin. Check the real ones against Keywords, assets and URLs.
  • The externalFetch allow list has one local loophole. Private, loopback and metadata hosts are refused even when declared — except when your apiBaseUrl is itself loopback, because a fully local rig’s whole world is loopback. Point the harness at a local Central and a private host you declared will succeed here and be blocked in production.
  • Options are pre-parsed. The scaffold seeds settings from configZod.parse({}), so every field is present and correctly typed. Customers’ stored settings arrive raw. A component or data hook that skips parseWidgetConfig cannot fail here and will fail there. Paste real settings JSON into mock.widgetSettings to reproduce.
  • The envelope is close, not identical. It carries data-hr-ssr-id — the part hydration depends on — but ships the props as a data-hr-ssr-props attribute where production writes a page-level props registry, and it never emits a declarative shadow root. Server-only mode has no shadow root at all and links your stylesheet into the harness page’s own <head>, so :host rules match nothing and your styles can bleed onto the harness chrome. None of that is a bug in your widget — and that last part is useful rather than misleading: it is the closest local view of the unisolated, document-level condition every server-rendered page puts your CSS in (Styling and theming).
  • Data is fixtures, not a feed. Unmatched requests bypass to the real network, and in hydrate mode the browser’s own fetches are not mocked at all (see above). A shape your fixtures get wrong is a shape you have not tested.
  • There is no server-side slot composition. A layout renders its shell with empty slots — the platform, not this harness, renders the bound children and passes them as renderedSlots. See Suites: one widget at a time.

Suites: one widget at a time

A suite puts each widget’s bundle at dist/{widget}/{widget}-ssr.umd.js under the namespaced keyword plugin:{slug}:{widget}, and createSSRDevServer resolves both from the widget you name (widget-core 0.12.2+; the scaffold’s suite dev:ssr script needs create-hr-plugin 0.8.1+):

# packages/create-hr-plugin/template-suite/scripts/dev-ssr.mts — what the script accepts
npm run build
npm run dev:ssr -- --widget intro-card      # → http://localhost:3003
npm run dev:ssr -- --widget=page-frame      # the layout shell, slots empty
HR_SSR_WIDGET=intro-card npm run dev:ssr    # or set it in .env.local

Run it once per widget. There is no “preview the whole suite” mode, and there does not need to be: every SSR question except composition is per-widget.

The one thing you cannot preview is a composed page. renderPluginSSR builds the props itself and passes no renderedSlots, so page-frame renders its shell with every slot empty. That is still a useful smoke test — a layout that throws during render 500s the whole customer page (Layout widgets) — but it is not a preview of the composed result. npm run dev exercises the client half of layout composition (Dev sandbox and mocking); the server half needs a real feed.

If you want a composed render locally, build it yourself, in two steps. renderPluginSSR gives you each child’s HTML; evaluatePluginSSR gives you the layout’s module, which you render with react-dom/server yourself, passing those children as your own renderedSlots. Only the second step needs the hand-rolled call — renderPluginSSR builds its props internally and has no renderedSlots field, so it can never do this. The two lookups the dev server boots with are exported for exactly this (widget-core 0.12.2+):

// packages/homerunner-widget-core/src/testing/ssr.ts (renderPluginSSR, evaluatePluginSSR),
// src/testing/dev-server.ts (resolvePluginSSRTarget) and src/contracts.ts, which types
// `renderedSlots` as Record<string, ReactNode>. Save as scripts/ssr-compose.mts and run it
// with tsx after `npm run build`.
import fs from "node:fs";
import React from "react";
import { renderToString } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
  renderPluginSSR,
  evaluatePluginSSR,
  resolvePluginSSRTarget,
  createNodeMockServer,
} from "@homerunner-next/widget-core/testing";
import { setMockState } from "@homerunner-next/widget-core/mock";

const SLUG = "acme-suite";
const LAYOUT = "page-frame";
const FEED_ID = 1;
// Your own binding table. On a real page this comes from the customer's stored page
// schema; nothing here checks it, so a slot name your layout does not declare renders
// nothing at all.
const BINDINGS: Record<string, string[]> = { main: ["intro-card"] };

// Same lookup createSSRDevServer boots with: keyword, dist paths, declared hosts.
function resolve(widget: string) {
  const { target, error } = resolvePluginSSRTarget({
    rootDir: process.cwd(), slug: SLUG, widget,
  });
  if (!target) throw new Error(error); // CSR-only, undeclared, or no widgets[]
  return target;
}

// 1. Render every slot child on its own — the full production order, per widget.
const renderedSlots: Record<string, React.ReactNode> = {};
for (const [slot, widgets] of Object.entries(BINDINGS)) {
  const nodes: React.ReactNode[] = [];
  for (const widget of widgets) {
    const target = resolve(widget);
    const mock = createNodeMockServer(target.keyword, {
      widgetId: widget, feedId: FEED_ID, delayMs: 0,
    });
    setMockState({ scenario: "default" }); // never "loading" — that handler never resolves
    const { html, ssrId } = await renderPluginSSR({
      ssrCode: fs.readFileSync(`dist/${target.ssrFile}`, "utf-8"),
      filename: target.ssrFile,
      ctx: { options: {}, feedId: FEED_ID, widgetId: widget },
      resolvedTheme: "light",
      externalFetch: target.externalFetch,
    });
    mock.close();
    // Each child reaches the layout as its own host element, not a bare string.
    nodes.push(React.createElement("div", {
      key: widget,
      "data-hr-widget": target.keyword,
      id: `${widget}:${FEED_ID}`,
      "data-hr-ssr-id": ssrId,
      dangerouslySetInnerHTML: { __html: html },
    }));
  }
  renderedSlots[slot] = React.createElement(React.Fragment, null, ...nodes);
}

// 2. Evaluate the LAYOUT in the same sandbox and render the shell yourself.
const layout = resolve(LAYOUT);
const mod = evaluatePluginSSR(
  fs.readFileSync(`dist/${layout.ssrFile}`, "utf-8"),
  "https://beta.homerunner.io",
  "https://assets-dev.homerunner.io",
  { filename: layout.ssrFile, externalFetch: layout.externalFetch, pluginAssetBase: "" },
);

const ctx = { options: {}, feedId: FEED_ID, widgetId: LAYOUT };
const layoutMock = createNodeMockServer(layout.keyword, {
  widgetId: LAYOUT, feedId: FEED_ID, delayMs: 0,
});
setMockState({ scenario: "default" });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const data = mod.getInitialData ? await mod.getInitialData(ctx) : undefined;
await mod.dehydrateState?.(queryClient, ctx);

// No WidgetHydrationTree and no identifierPrefix here, deliberately: on a server-rendered
// page the shell is static light DOM whose mount is a no-op, so there is no hydration to
// line up with (Layout widgets covers that).
console.log(renderToString(
  React.createElement(QueryClientProvider, { client: queryClient },
    React.createElement(mod.default, { ...ctx, resolvedTheme: "light", data, renderedSlots }),
  ),
));
layoutMock.close();

Every divergence above still applies, and the settings are whatever you pass in ctx.options{} is not what a customer sends. Three things this deliberately does not reproduce: the keys a layout pushes down onto each child, and the Declarative Shadow DOM template and props-registry entry every real slot child gets (Layout widgets).


An SSR checklist

Run before you zip — Preflight and submit has the rest.

A content widget with an SSR bundle — Both. Every scenario except loading renders non-empty markup; error paints your error state rather than throwing. The inspector’s Props panel shows the options you expect, and the dehydrated cache is non-empty if you export dehydrateState. Nothing in the terminal says window is not defined. Fetch /__render.json?scenario=default twice and diff: any difference means a non-deterministic render (a clock read, a random value), which costs you the SSR DOM on hydration.

A CSR-only widget ("ssr": false) — Suite. Nothing to preview and no ssr-entry.ts to build; naming it exits 1 and that is the expected outcome. Verify it in the sandbox instead, and set expectedHeight (Manifest: widget summary) so the empty server host reserves the right space.

A layout widget — Suite. SSR is mandatory, and --widget {layout} previews the shell with empty slots — enough to prove it evaluates, renders and does not throw, which is the failure that 500s a whole customer page. It is not a preview of the composed result. Give every slot and every config field a default, keep the shell non-interactive, and class-namespace the CSS: on a server-rendered page the shell is light DOM and its stylesheet is document-level.


Seeing what the server actually did on a real page

Once your plugin is live on a feed, three signals answer “which layer served this, and when”.

Development mode turns off caching for one feed. The feed owner enables it from the dashboard for a time-boxed window; the Cloudflare worker then appends ?dev=1 to its render request, and the renderer serves fresh, writes nothing to cache and returns Cache-Control: no-store… plus X-HR-Dev-Mode: 1. Ask for it while you iterate — otherwise you are looking at a cached page and cannot tell a stale render from a broken one. The sandbox’s server-side GET cache is bypassed on those renders too (still enforced), per the limits index.

The stamp. Every fresh render appends an HTML comment to the compiled page — <!-- hr-render page=… build=… manifest-age=…s rendered-at=<ISO> key=… -->. View-source and read the last one; a page-level stamp follows every widget. A cached page deliberately keeps the stamp of the render that produced it, so an old rendered-at is the staleness evidence.

The headers, on the render API response (the JSON the worker fetches, not the customer page): x-hr-render-cache is hit-fast, hit, miss or bypass (bypass = development mode), x-hr-render-key is the compiled-page key, and x-hr-render-build / x-hr-render-at are read out of the stamp inside the payload being served — so on a hit they describe the original render. curl -sD - -o /dev/null answers all four.

If a widget is missing from the page entirely, search the source for data-hr-widget-error: the renderer leaves a hidden node carrying an HTML-comment reason where the widget should have been. Troubleshooting maps those reasons to causes.


Preflight and submit

Your plugin passes three gates. You watch the first two happen; the third runs in a reviewer’s browser, days later, against a build you never see — and a failure there costs you a resubmission with a bumped version. So the work on this page is front-loading gate three into your own terminal before you zip anything.

GateRunsYou see it
Source-zip auditIn your browser, before a single byte is uploadedImmediately
Central’s submit APIAfter the bytes are in the bucketAs a coded error in the uploader
Built-zip auditIn the reviewer’s browser, at publish, on their build of your sourceNever

The three gates and every message they can emit are indexed in Limits and error index; the rules themselves are in Packaging and publishing rules. This page is the checklist, the zip, the upload, and what happens after.

The preflight checklist

Every item names the gate it satisfies. Items marked gate 3 are the expensive ones: they pass your submission, pass review, and fail at publish.

Every plugin — Both shapes

  • The build succeeds from a clean checkout of what you committed, not just from your working tree — git clone . /tmp/preflight && cd /tmp/preflight && npm ci && npm run build. That is exactly what the reviewer does, and it is the only way to catch a source file you forgot to commit. (npm run build clears dist/ itself, so a stale artefact cannot survive it.) (gate 3)
  • dist/manifest.json exists and lists a hashed twin for every asset your manifest declares, with that twin present on disk. A missing twin means the tree was edited after the build — rebuild, never patch it. (gate 3)
  • manifest.json is dirty in git — commit it. The build rewrites your tracked manifest twice: schema injection, then the runtime.widgetCore stamp. That stamp is the evergreen-runtime gate and must never be hand-written. (gate 3)
  • Nothing under dist/ is more than one directory deep. dist/{widget}/chunks/x.js is dropped silently at publish — no error, no warning, a 404 at runtime. Keep the SDK’s output layout. (gate 3, silent)
  • The version is bumped — three plain numeric parts, strictly greater than the version that is live, and no pre-release suffix. Publishing, versions and rollback explains what a version number costs you and why a rejected one is not spent. (gate 2)
  • Every media/ file your manifest declares is committed. Nothing generates media/ — not your build, not the reviewer’s. A declared icon, cover, screenshot or README that is missing from your source fails the publish. (gate 3)
  • The shape has not changed. If your plugin is already live, it must stay single-widget or stay a suite. This one is not checked at submit or at review — see The two manifest shapes. (publish only)
  • The zip carries no dist/, node_modules/ or .git/, and package-lock.json is committed so the reviewer’s install resolves what you tested. (gate 1; the lockfile is a warning)

Single-widget plugins only

  • widgetType, ssr.url and assets.js are present at the manifest root. (gate 1)
  • The root manifest.json carries a configSchema — that is your build:manifest step’s output, and without it the customer’s settings form is empty. (gate 3, warning only)

Suites only (widget-core 0.11.0+)

  • Every widgets[].manifest sub-manifest exists at the path you declared, is committed, and contains a non-empty configSchema. A declared-but-absent sub-manifest is a hard publish failure; a schemaless one is a silent empty form. (gate 3)
  • Widget slugs are unique, and none of them is dist, widgets or media. (gate 1)
  • Per-widget icon and readme files exist under media/. (gate 3)
  • Every presets[] entry names a declared layout and children that are either your own content widgets or bare system keywords. (gate 1)

By widget kind

  • A content widget with SSR — Both. ssr-entry.ts exists and re-exports the component, and npm run dev:ssr renders every scenario without window is not defined, with two renders of the same scenario byte-identical. Suite: a suite has no default widget, so name one — npm run dev:ssr -- --widget {slug} (widget-core 0.12.2+, create-hr-plugin 0.8.1+) — and run it once per server-rendering widget. Previewing SSR locally.
  • A CSR-only widget — Suite. The summary says exactly "ssr": false, there is no ssr-entry.ts for it, and expectedHeight is set so its empty server-rendered host reserves the right space. Verify it in the dev sandbox.
  • A layout — Suite. Walk the layout checklist in Layout widgets — an SSR bundle is mandatory, the slots zod field must exist, and the CSS must be class-namespaced because the shell renders as light DOM. npm run dev:ssr -- --widget {layout} previews the shell with every slot empty — enough to prove it evaluates and does not throw, which is the failure that 500s a whole customer page, but the composed page has no local preview (Previewing SSR locally).

Run the reviewer’s gate yourself

Not supported yet. You cannot run the built-zip audit, and its warnings are never forwarded to you. Unless a reviewer copies them into review notes, you will not learn that your plugin shipped with an empty settings form.

This script reproduces the gate-3 checks that depend only on your build. Run it from the project root after npm run build. No FAIL: line means clean.

# Reproduces the publish gates in src/lib/plugin-zip.ts (auditBuildZip) that you can check
# locally: declared assets, hashed twins, the HRPlugins marker, base-schema extension and
# declared media. Verified against hr-plugins/pdp-suite 1.3.3, which passes silently.
node -e '
const fs = require("fs");
const m = JSON.parse(fs.readFileSync("manifest.json", "utf8"));
const twins = JSON.parse(fs.readFileSync("dist/manifest.json", "utf8")).files;
const say = (...a) => console.log("FAIL:", ...a);
const media = [m.icon, m.cover, m.readme, ...(m.screenshots ?? [])];
for (const w of m.widgets ?? [{ slug: m.id, ...m }]) {
  media.push(w.icon, w.readme);
  for (const p of [w.assets?.js, w.assets?.css, w.ssr && w.ssr.url].filter(Boolean)) {
    const key = p.replace(/^.*?dist\//, "");
    if (!fs.existsSync(p)) say("declared asset not built:", p);
    else if (!twins[key]) say("no hashed twin for", key);
    else if (!fs.existsSync("dist/" + twins[key])) say("twin missing:", twins[key]);
  }
  if (w.assets?.js && fs.existsSync(w.assets.js) &&
      !fs.readFileSync(w.assets.js, "utf8").includes("HRPlugins"))
    say("never registers on window.HRPlugins:", w.assets.js);
  const sub = w.manifest ? JSON.parse(fs.readFileSync(w.manifest, "utf8")) : m;
  const props = sub.configSchema?.properties ?? {};
  if (!props.colorScheme || !props.spacing)
    say("configSchema does not extend widgetSchema:", w.manifest ?? "manifest.json");
}
for (const ref of media)
  if (typeof ref === "string" && ref.startsWith("media/") && !fs.existsSync(ref))
    say("declared media missing:", ref);
console.log("preflight complete —", m.id + "@" + m.version, "widgetCore", m.runtime?.widgetCore);
'

It does not cover the size thresholds. Check those by eye against the built-zip limits in Packaging and publishing rules — the one worth acting on is the SSR bundle, past which the renderer stops caching your bundle and every cold server render pays the load cost.

Zip it

Zip the source, not the build. Two forms both pass the audit; the first is better.

# Verified: auditPluginZip (src/lib/plugin-zip.ts) returns ok with 0 errors and 0 warnings
# for both commands, run against hr-plugins/pdp-suite 1.3.3.

# Preferred — archives exactly what is committed, at HEAD.
git archive --format=zip -o ../my-plugin-1.0.0-source.zip HEAD

# Or, from the project directory, if you are not using git.
zip -qr ../my-plugin-1.0.0-source.zip . \
  -x 'node_modules/*' 'dist/*' '.git/*' '*/.DS_Store'

git archive is the better default for one reason: it can only include committed files, so it enforces the rule that bites people after approval. If media/ or widgets/ are gitignored, they vanish from the zip and you find out now instead of at publish. The trade-off is the same property in reverse — uncommitted local fixes are silently left out, so commit first and archive second.

zip -r honours no ignore file. It will happily package .env.local, and a human reads your source. Delete your secrets or use git archive.

Two shapes of archive are accepted: files at the zip root, or everything inside a single wrapper folder (what macOS Finder’s Compress produces). __MACOSX/ entries and .DS_Store files are ignored. Zip64 archives are rejected outright, so use zip -r, Finder, or your platform’s archiver rather than a tool that forces zip64.

Upload it

Dashboard ▸ PluginsMy pluginsUpload plugin. Drag the zip onto the drop zone or click to choose one.

Nothing is transmitted while the audit runs. On failure you get a list of exactly what is wrong and no upload happens. On success you get a green summary card — plugin name, version, slug, and either the widget list or the widgetType, with any warnings in amber underneath.

Read the widget list. It is parsed from the manifest.json inside the zip you just selected, so it is the cheapest check that you zipped the tree you think you did: a widget you added but do not see there is a widget the archived manifest does not declare.

Then the optional notes box, then Submit for review. Use the notes: they are the only channel you have to the reviewer, and review notes are the only channel back.

Checked in your browser, before anything transmits

The archive’s structure and entry rules, its size, entry count and compression ratio, the presence and parseability of manifest.json and package.json, and the shape of the manifest — required root fields, the id slug pattern, the version semver pattern, every widgets[] summary and every preset.

Two of the uploader’s own pre-checks fire before the audit even reads the archive: Please choose a .zip file. and The zip is over the 20 MB limit.

Then, still before the bytes move, the dashboard mints a presigned upload URL and makes a best-effort ownership check — so a slug that belongs to someone else can stop you here rather than after the transfer.

Checked after the bytes are in the bucket

Your browser cannot know these; only central can. Each is a coded 4xx surfaced in the uploader, with the exact messages in Packaging and publishing rules.

CodeWhat it means for you
SLUG_TAKENAnother author owns this manifest.id. Pick a different slug.
SLUG_RESERVEDThe slug exists with no owner — a legacy admin-registered row. Ask HomeRunner to hand it over.
VERSION_NOT_GREATERNot strictly greater than your live version. The browser only checks the version’s shape, never its ordering.
INVALID_VERSIONCentral demands exactly three numeric parts, so 1.0 and 1 are refused here even though a looser parser accepts them.
INVALID_MANIFESTA server-side manifest rule the browser does not apply — length caps on id, description and author, plus the ceiling on how many widgets one plugin may declare. (the browser checks the version length first, so you should not reach Central with that one.)
VALIDATION_ERRORThe submission envelope itself: a bad checksum, a size outside the allowed range, or notes over the length cap.
INVALID_ZIP_KEYThe upload key does not sit under your slug’s folder. The dashboard mints that key, so you should never see this.

Before the metadata call, the dashboard checks the bucket itself: the object must be there, its size must be the one you submitted, and the SHA-256 the bucket stored from your signed upload must match. A mismatch is refused with both values in the message. If storage reports no checksum at all, the submission is refused rather than registered on trust — the presigned URL is not remembered between the two calls, so the bucket’s stored checksum is the only thing tying the hash you register to the bytes you uploaded.

If the bucket’s copy does not match, or Central refuses the registration, the object you just uploaded is deleted again. Two cases deliberately delete nothing: if the dashboard cannot reach storage — or storage will not say what it holds — it registers nothing and removes nothing, because it will not destroy an object it could not inspect; and a key that was not minted for you is refused before Central is told anything. In both cases the bytes you already uploaded stay in the bucket, but you cannot reach them: retrying means choosing the zip again, which mints a fresh key and uploads from scratch, and the preserved object is cleaned up on our side. Only the object your own upload created is ever deleted — the upload key names the account it was minted for.

After you upload

Your plugin row now shows a pending badge and a timeline. A reviewer downloads your zip, verifies its checksum against the one your browser recorded, and reads your source.

The three outcomes

OutcomeNotesWhat it means for you
approvedoptionalNothing is live yet. Your submission is waiting for a publish, and public-versus-private visibility was fixed in the approve dialog.
changes_requestedrequiredThe notes appear on your row, followed by “— fix and re-submit the zip above.”
rejectedrequiredSame, and your uploaded zip is deleted from the bucket.

The five timelines you can be in — pending, changes_requested, rejected, approved and published — are rendered in From your laptop to a customer page, along with who is blocked in each.

What the reviewer is looking at

For a first submission, your source. For an update, the dashboard also diffs the live manifest against yours and shows: config option keys added or removed (base keys excluded), a changed description, a changed widgetType, widgets added or removed, and — per widget present in both versions — changes to category, slot names, pages, SSR on/off, deprecated and declared fonts, plus icon and cover changes.

Per-widget configSchema diffs are not in that list. Sub-manifests only exist as built artifacts, so they are diffed later, at publish, against the live version’s copies on the CDN. An option key you quietly removed is reviewed after the human decision, not before it.

Nobody will tell you

Not supported yet. Nothing notifies you of a review outcome, or of a publish. The only channel is opening Dashboard ▸ Plugins ▸ My plugins and reading the badge, the timeline and the review notes yourself — so poll it, and do not build a release process that waits for a message. The gap is described in full in From your laptop to a customer page.

Re-submitting

Uploading a new zip for the same slug replaces whatever is in flight, from any state — including approved-but-not-yet-published. The status returns to pending and the reviewer, notes and timestamp are cleared.

Your published version is never touched by this. It keeps serving throughout review, rejection and resubmission; only a publish or a rollback changes what customers load. So if an approved submission has not shipped yet and you spot a bug, just submit the fix.

Publishing, versions and rollback

Publishing is the only event that changes what a customer’s page loads. Review does not. Approval does not. Uploading a built zip does not — not until central repoints its row at the new version prefix. Everything before that moment is preparation; everything after it is cache propagation.

This page covers that moment: what the publish transaction writes, how the change reaches pages that are already cached, and how to undo it. The pipeline that leads up to it is From your laptop to a customer page; the rules your zip must satisfy are Packaging and publishing rules.

What publishing does

An administrator drives four legs, in order. Only the third changes anything a customer sees.

  1. Upload. The built zip’s publishable files are PUT one at a time into the immutable key prefix {env}/{slug}/{version}/, root manifest.json last. Until that last object exists, nothing is live and the same zip can be retried safely.
  2. Verify. The dashboard fetches the published manifest.json back from its public URL with a cache-buster and refuses to continue unless it serves and its id/version match the submission. “Published” means observed serving, not uploaded.
  3. Repoint. Central records the verified URL and manifest in one database transaction. This is the switch.
  4. Propagate. The dashboard warms the renderer and purges cached pages. Best-effort — see How an update reaches a customer.

The repoint writes exactly this, and each field has a consequence for you:

What central writesRead fromWhat it changes for you
manifest_url, current_versionThe verified URLEvery new render resolves your keywords against the new version prefix
manifest_cacheThe manifest itselfThe widget picker, per-widget summaries, slots, presets, fonts
name, description, author.name, author.urlThe manifest’s root fieldsYour marketplace listing text — it only ever updates at publish
status = approvedFixedA suspended plugin is un-suspended by publishing a fix
submission_status = nullFixedYour submission is cleared; the stepper reads Published
published_at, build_metaNow, and the build hash + file listForensics only; no serving path reads build_meta

Three things publishing does not do, and all three surprise people:

  • It does not re-fetch anything later. Central serves its stored copy of your root manifest to every consumer. There is no manifest-refresh step you or anyone else has to trigger — the repoint is the refresh.
  • It does not touch customer data. Widget settings live on the customer’s widget row, not in your plugin. Nothing is migrated, defaulted or rewritten on their behalf. See Settings and options.
  • It does not delete the old version. The previous prefix stays on the CDN, byte for byte, which is what makes both cached pages and rollback safe.

Your own confirmation is the row in Dashboard ▸ Plugins ▸ My plugins: the badge flips to live v{version} and a Published manifest link appears, pointing at the exact URL central recorded. Nothing emails you — see the notification gap in From your laptop to a customer page.

Version rules, in practice

(Both shapes.) One version number covers the entire plugin. A suite of twelve widgets has one version, ships as one review and one publish, and cannot release a widget on its own.

The three hard rules — three-part MAJOR.MINOR.PATCH, strictly greater than the published version, and never re-publishable once it exists on the CDN — are stated with their exact messages in Packaging and publishing rules. Two practical consequences are worth spelling out here:

  • A version number is spent the moment it publishes. There is no re-publish. A one-line CSS fix to 1.2.0 is a submission of 1.2.1, with a full review.
  • A version number is not spent by being rejected. The greater-than check compares against the version that is live, never against your last submission. If 1.4.0 came back as changes_requested, resubmit the fix as 1.4.0 again.

Pick numbers that describe the config schema, because that is the only part of your plugin a customer’s stored data depends on: patch for fixes, minor for additive optional fields and new widgets, major when you break or remove a field. Nothing enforces this — advisory (unvalidated) — but see Evolving a plugin for what actually breaks.

Not supported yet. Pre-release versions such as 1.0.0-rc.1 pass the browser audit, pass central, and publish — and then silently lose every immutability optimisation. Each detector (the proxy’s, the renderer’s, the SSR bundle cache’s) requires a bare X.Y.Z path segment, so a pre-release prefix falls back to proxied asset URLs and short-TTL caching everywhere. Nothing warns anyone. Ship plain MAJOR.MINOR.PATCH versions. See Keywords, assets and URLs.

How an update reaches a customer

Customer pages are composed once and cached hard, so the platform does not wait for TTLs to expire. Publishing (and rollback, and every kill-switch change) pushes:

// Source: src/app/(protected)/plugins/cache-propagation.ts
central repoint  ──▶  warm       POST {renderer}/api/cache/warm-plugin { slug }
                  │              loads EVERY widget's SSR bundle for the slug; the
                  │              slug-keyed Redis entry it writes overwrites the
                  │              previous version's — warm and eviction are one call
                  │
                  └──▶  purge     GET  /plugins/{id}/feeds        (feeds with the plugin ENABLED)
                                  per feed: renderer Redis page keys + the edge KV page
                                  cache, then one re-render of that feed's listings page
                                  concurrency 4 · 30 s in-request · 150 s after the response

Neither leg can fail the publish. If the warm fails — a broken SSR bundle returns a 502 and stops the loop — the release still goes live and you find out on the first customer render. If the purge budget runs out, the feeds it did not reach fall back to their page TTLs.

What holds a copy of your old build

LayerHoldsWhat releases it
Browser, hashed asset URLOne immutable fileNothing. A new version is a new filename.
/p/ proxy pointerWhich plugin/version a logical filename maps to60 s TTL — see Keywords, assets and URLs
Renderer, evaluated SSR moduleYour executed bundle, in processA versioned URL never expires in process; a new version is a different URL
Renderer, Redis bundle textYour bundle’s sourceThe publish warm overwrites the slug-keyed entry; a 30-day TTL is only a backstop
Renderer, compiled pageThe customer’s rendered HTMLThe per-feed purge, else 24 h
Edge KV, composed page + renderer payloadThe customer’s served HTMLThe per-feed purge, else the page TTL

The page TTL is 24 h on property-detail, checkout and confirmation pages, and 300 s on listings and collection pages, with stale-while-revalidate on top: past the fresh window one request is served the old copy while a refresh runs behind it. So the honest bounds are seconds when the fan-out reaches a feed, and up to 24 h for a detail page on a feed it did not. Every number here is also indexed in Limits and error index.

The old guide claimed a “~5 minute worst case”. That was the legacy SSR-bundle TTL, which no longer applies to pipeline-published plugins at all. The real bound is the composed-page TTL above.

A stale page is stale consistently

On server-rendered pages the renderer writes direct content-addressed CDN URLs into the HTML, so a cached page keeps loading the exact bundles its HTML was rendered with — from the old prefix, which still exists. There is no window in which new markup meets an old bundle, or vice versa. That is the whole reason old prefixes are never overwritten.

It is also why deleting an old version prefix is the one operation that can break a live page. The dashboard refuses to delete the live version outright, and its bulk prune keeps the live version plus the newest previous one; anything older can still be referenced by a page cached within the last day.

What the customer does

Nothing. There is no per-feed version pinning: central holds one current-version pointer per plugin, and every feed, widget instance, SSR render and CSR embed follows it. Customers cannot stay on an old version, and cannot opt into a new one early.

The only visible change on their side is the config panel. A suite’s per-widget schemas live at widgets/{slug}.manifest.json under the version prefix, so a new version is a new URL and the dashboard picks up your new form on the next page load. (Suite.)

Rolling back

Rollback is the first-line fix for a bad release, and it is a single administrator action — not a new review. On the admin plugin page, Versions on the CDN lists every {env}/{slug}/{version}/ prefix R2 still holds (R2, not central, is the record of what was ever published) with its file count, size, publish time and runtime.widgetCore stamp. Any non-live row with a root manifest offers Roll back to this version.

Central then re-verifies the older build before trusting it — it re-fetches that manifest itself over https from a public address with no redirects, and re-applies the manifest validator, the id/version match, the frozen-shape gate and the widget-core floor (widget-core 0.10.0+). The refusals are indexed in Limits and error index; the ones you can actually cause are:

You will hitWhen
ROLLBACK_MANIFEST_UNREACHABLEThe prefix was pruned from the CDN. Rollback needs the files to still be there.
MANIFEST_WIDGET_CORE_MISSING / _TOO_OLDThe target predates the evergreen runtime floor. Old releases can age out of being rollback targets.
MANIFEST_SHAPE_CHANGEDThe target is the other manifest shape — see The two manifest shapes.
ALREADY_LIVEIt is already the live version.

What rollback changes, and what it deliberately leaves alone:

  • Changes: manifest_url, current_version, manifest_cache. Then the same warm and purge fan-out as a publish, so it lands in seconds.
  • Leaves alone: status — rolling back does not un-suspend a suspended plugin, and cannot be used as a kill switch. Use suspension for that (Install, customers and kill switches).
  • Leaves alone: any in-flight submission. A fix you have already submitted keeps its place in review while the rollback protects customers.
  • Leaves alone: customer settings, again. Which is where rollback can still hurt you: if the bad version added a widget or a required config field, rolling back makes those placements resolve against a manifest that no longer declares them, and they fail closed — see the resolution table in The two manifest shapes.

Rolling forward instead — submitting 1.4.1 — is the right move when the breakage is small, the review queue is fast, or the bad version introduced a widget customers have already placed. Rollback is right when the breakage is on a customer’s live page now.

Evolving a plugin without breaking customers

Four rules cover almost every release.

1. The manifest shape is frozen at your first publish. (Both shapes.) A live plugin can never move between the single-widget shape and the suite shape; the escape hatch is a new plugin slug, not a new version. Because a suite may contain one widget, starting there costs nothing — The two manifest shapes.

2. Deprecate a widget; never delete its slug. (Suite.) Setting deprecated: true on a summary removes it from the picker while every existing placement keeps rendering exactly as before. Deleting the entry instead strands every customer widget that stores that keyword: the keyword stops resolving and the widget fails closed on their page. The field’s full behaviour is in Manifest: widget summary. Note this is not a kill switch — a deprecated widget’s assets keep serving.

3. Treat your config schema as a stored data format. Nothing migrates settings, so what you do to a field decides what happens to the value a customer already saved:

Schema changeWhat happens to existing widgets
Add a field with .default()Safe. The key is missing from storage, so parseWidgetConfig supplies the default at render.
Change an existing .default()Invisible to existing widgets. Their value was written at creation and stays.
Rename a fieldThe old value is stranded under the old key. Read the old key off raw props.options and fall back, for at least one release.
Remove a fieldThe value stays in storage and is dropped from your parsed config. Harmless.
Tighten validation (.min(), a narrower enum, a type change)Dangerous. A stored value that no longer parses makes parseWidgetConfig throw, which takes the widget down on a live page. Widen instead, or accept both shapes and normalise.

Nothing validates stored settings at any layer — advisory (unvalidated) — so your own parse is the only gate, and it runs on the customer’s page. See Settings and options and Config schema and UI schema.

4. Adding is cheap; renaming is not. New widgets, new slots and new presets appear the moment you publish and affect nobody who has not used them. Slugs, slot names and config keys are the identifiers customers’ stored rows point at — change one and you have made a breaking release whatever the version number says.

Checking a release yourself

The dashboard already verified the manifest before recording it, so this is for answering “is what I think I shipped what is live?”.

# Source: src/app/(protected)/plugins/actions.ts — the same three facts the publish checks.
# Take the URL from the "Published manifest" link on your row in Plugins ▸ My plugins;
# the {env} segment (prod | dev | local) is never optional.
BASE=https://plugins.homerunner.io/prod/acme-weather/1.4.0

curl -s "$BASE/manifest.json?v=$(date +%s)" \
  | jq '{id, version, core: .runtime.widgetCore, widgets: [.widgets[]?.slug]}'

curl -s "$BASE/dist/manifest.json" | jq '.files | keys'

If the manifest is right but a customer page still shows the old build, it is cache, not publishing: append ?dev=1 to the page to bypass every plugin cache and render fresh, and read the x-hr-render-* headers to see which layer answered. Both are covered in Previewing SSR locally and Troubleshooting. The URL layout itself, and what each object’s cache header is, belong to Keywords, assets and URLs.

Install, customers and kill switches

Publishing puts your plugin in a catalogue. It does not put it on a page. A customer has to find it in their feed’s Marketplace, install it, activate it and create a widget from it — and they can undo every one of those steps. Above them sit two switches HomeRunner controls and your customer cannot.

You drive none of this. There is no author-facing API for installing, activating or suspending, and nothing notifies you when any of it changes (From your laptop to a customer page). Your only levers are shipping a new version and asking for a rollback (Publishing, versions and rollback).

Who can see your plugin

A plugin appears in a customer’s catalogue when it is status = approved and either visibility = public or granted to their customer account. Grants are account-level on purpose: granting one account gives its whole team the plugin. You always see your own rows at any status — that is your submission dashboard, not the marketplace.

Visibility is chosen by the reviewer in the approve dialog (Public — every account can enable it / Private — only granted customer accounts) and an administrator can flip it, or add and remove grants, at any time afterwards. You cannot set or read it yourself.

There is no global plugin browser. Customers reach plugins only from Feed → Plugins, which has two tabs: Installed and Marketplace. Authors get Dashboard → Plugins → My plugins and nothing else. So “your plugin is live” always means live for a feed.

Two consequences that surprise authors:

  • The catalogue row a customer receives is already kill-switch filtered. Central replaces manifest_cache with the withheld-widget version for anyone who is not the owner or an admin, and hides every pipeline column (review notes, zip pointer, submitted manifest). You, the owner, get the raw row — so a widget HomeRunner has disabled is visible to you and simply absent for them. Debug against a second account, not your own.
  • The install count is an active count. active_installs, shown on the marketplace card and in the plugin-page sidebar, counts feeds where the plugin is currently enabled. A customer who deactivates without uninstalling silently decrements it.

What a customer actually does

StepWhereWhat it changesWhat your code sees
InstallMarketplace card → InstallCreates the (feed, plugin) row with enabled = trueYour widgets appear in that feed’s picker
Activate / DeactivateInstalled tab, or the plugin pageFlips enabled onlyDeactivating stops every instance rendering
Per-widget togglesPlugin page → Widgets tab, one switch per widgetThe feed’s own withheld-slug listSuite only; see the kill-switch table
ConfigureInstalled row → ConfigurePer-feed plugin config JSONNothing — see the gap below
Create widgetFeed → Widgets → Create New WidgetA widget row storing your keywordYour component renders
UninstallInstalled row → UninstallDeletes the row: config, sort order and toggles go with itGuarded — see below

Install and Activate are the same call. Installing already activates; the Marketplace button reads Install when there is no row and Activate when there is a disabled one. The upsert is idempotent and preserves an existing row’s stored config and sort order, so re-activating restores whatever the customer had. Central refuses the call with PLUGIN_NOT_APPROVED for a plugin that is not live and PLUGIN_NOT_AVAILABLE (403) for an approved plugin the account is not entitled to.

Deactivate is the customer’s own whole-plugin switch. It keeps the row, the config and the per-widget toggles, and it is not a delete — but the public widget list only attaches your manifest for a plugin that is approved and enabled on that feed, so on a deactivated plugin every widget instance stops resolving and renders the same hidden failure breadcrumb a suspension produces (The two manifest shapes). The rows survive; reactivating brings them all back.

Uninstall is guarded. Central refuses with 409 PLUGIN_IN_USE while any widget built from your plugin still exists on the feed, and returns the count in details.widget_count (Limits and error index). The dialog then switches to a guided path: it lists those widgets with their keywords, makes the customer type your slug to confirm, deletes them one at a time and only then uninstalls. There is no silent cascade, and paused widgets count too.

Not supported yet. Per-feed plugin config (Configure) is offered only when the root manifest carries a configSchema — a v1 field a suite must not have (Manifest: root fields), so for a suite the button never appears. And nothing on any render path delivers the stored value to your component: it is returned by GET /{feed_id}/feed/plugins and read by nothing else (Public API). Treat widget settings as the only configuration surface (Settings and options).

Creating a widget from your plugin

The Create New Widget picker is fed by the public feed-plugin list, so it already has every switch applied: approved, enabled on the feed, and both kill switches subtracted. What is left becomes cards (Both):

  • Single-widget: exactly one card, keyword plugin:{slug}.
  • Suite: one card per widgets[] entry that is not deprecated, keyword plugin:{slug}:{widget}, plus one card per surviving presets[] entry listed ahead of them.

Icons resolve widgets[].icon → the plugin’s root icon → a glyph, and a URL that fails to load falls back to the glyph too. The glyph is a Layers icon for a layout and a Puzzle piece for a content widget — the same pair the plugin page uses. Your plugin’s own avatar falls back to a slug-coloured letter tile, and the cover band to a slug gradient, so an icon-less plugin still looks deliberate. The picker, presets and slot editor are covered end to end in Composing a page.

Your plugin page carries the same shortcut: a Create widget button per widget, enabled only when the plugin is installed and activated on this feed, the widget is not withheld by either kill switch, and the summary is not deprecated.

What your plugin page shows

Feed → Plugins → any plugin name opens a full page — the customer’s whole impression of your work, built from media/. It opens for installed and not-yet-installed plugins.

SurfaceSourceNotes
Cover band + icon + author + versionroot cover, icon, author, current_versionFalls back to a slug gradient and letter tile
Description tabdescription, then root readmeWith no README: This plugin ships no long description.
Widgets tabwidgets[], each with widgets[].readmeBadges: category, SSR/CSR only, deprecated; layouts also list their slots, prefills, accepts and pages
Screenshots tabscreenshotsTab is hidden when empty; click opens a lightbox
Changelog tabmedia/CHANGELOG.mdUndeclared — shipped by convention, tab hidden when absent
Sidebarversion, published_at, runtime.widgetCore, installs, widget names, homepage/support/docs, license, tagsLinks render only when they match ^https?://

Every markdown file is fetched server-side from your immutable version prefix, size-capped again on read, and rendered through a sanitiser — no raw HTML, no scripts. Because they resolve against manifest_url, nothing under media/ renders until your first publish: absolute icon/cover URLs show up in review, media/ paths do not, and screenshots (which only accept media/ paths) never appear before then (Manifest: root fields).

Not supported yet. A single-widget plugin gets a synthetic card on the Widgets tab — plugin icon, name, content badge, plugin:{slug} keyword, description and a Create widget button — but no per-widget README, no SSR/CSR badge, no instance count, no per-feed toggle and no count in the tab label. Every one of those is suite-only (The two manifest shapes).

The three kill switches

Three switches can withhold your code, at three scopes. They compose by subtraction only: each one narrows the manifest the one below it starts from, so nothing a customer does can restore something the platform withheld.

// Source: homerunner-central/app/Models/Plugin.php:145-172 (publicManifest,
// publicManifestForFeed) + app/PublicApi/V1/Controllers/WidgetController.php:64-71
manifest_cache                       the published manifest
  └─ status must be `approved`       (1) suspension — else nothing resolves at all
     └─ minus plugins.disabled_widgets          (2) platform per-widget
        └─ minus feed_plugins.disabled_widgets  (3) per-feed, on top
           └─ requires feed_plugins.enabled     the customer's Deactivate
#SwitchScopeWhoEffect on assetsEffect on SSREffect on the picker
1status = suspendedThe whole plugin, everywhereHomeRunner admin/p/ 404s every file — the public lookup is approved-onlyEvery instance becomes a hidden failure breadcrumb; the page degradesGone from Marketplace; Installed rows keep a Suspended by HomeRunner badge and offer only Uninstall
2disabled_widgets on the pluginNamed widgets, every feedHomeRunner admin404s that widget’s entire dist/{widget}/ directory — bundles, images, fontsThose instances stop resolvingThe widget vanishes; so does any preset that references it
3disabled_widgets on the installNamed widgets, one feedThe customerNo effect/p/ is feed-agnosticThose instances stop resolving on that feed onlyGone from that feed’s picker; the switch stays visible so it can be turned back on

Rules and caps for the two per-widget lists (Suite only — a single-widget plugin has nothing to name, and central rejects the attempt with NOT_MULTI_WIDGET):

RulePlatform listPer-feed list
Maximum slugs2424
Slug shapestring, ≤ 64 charsmust be a declared widget slug of the live manifest, else 422 INVALID_WIDGET_SLUG
Set byPATCH /plugins/{id} (admin) — chips on the admin registry rowThe plugin page’s per-widget switches
Cleared byRemoving the slugSending an empty list

Three consequences worth designing for:

  • A withheld widget is invisible, not broken. Its summary is gone from the manifest a customer’s dashboard receives, so they see no widget, no badge and no explanation — only you and admins can see it was withheld. Existing widget rows survive untouched and come straight back when the slug is removed.
  • Presets are collateral. Central drops any preset whose layout or whose slot children include a withheld slug, so a one-click composition disappears the moment one of its parts is switched off. Design presets out of widgets you expect to keep (Layout widgets).
  • Assets stop with switch 1 and 2, not 3. A CSR embed of a per-feed-disabled widget on some unrelated page keeps loading its bundle from /p/ — the proxy resolves against the platform-filtered manifest and knows nothing about feeds (Keywords, assets and URLs).

Nothing is deleted from the CDN. Every switch works by withholding the manifest, and your published objects stay exactly where they were. That is why the dashboard fires a purge across every feed with the plugin installed on each of these changes: a suspension lands in seconds instead of waiting out the composed-page cache (Publishing, versions and rollback). Publishing a new version sets status = approved again, so a publish also un-suspends.

deprecated is not a kill switch

deprecated: true is yours; the kill switches are not. They look similar in the picker and are opposites everywhere else.

deprecated: trueKill switch
Who sets itYou, in widgets[]HomeRunner (1, 2) or the customer (3)
PickerGoneGone
Existing placementsKeep rendering, unchangedStop rendering
AssetsStill served404 (switches 1 and 2)
Visible to the customerYes — a deprecated badge on the plugin pageNo (1 and 2); yes (3)
Reversible byPublishing a new versionFlipping the switch

Deprecating is how you retire a widget from a suite. Deleting its slug instead breaks every customer widget that stored the keyword (Manifest: widget summary).

Troubleshooting

Organised by what you observe, not by the phase you were in. Every entry is symptom → cause → fix → the page that owns the rule.

Nothing on this page is normative. Every hard number, every enforcement level and every message the platform can emit lives once in Limits and error index; this page routes you into it and into the contract that explains the fix.

Paste your error here

Search this table for a fragment of what you actually saw. {…} marks an interpolated value.

Message containsEmitted byGo to
No matching version found for @homerunner-next/widget-core@ · npm error code ETARGETnpmInstall and scaffold
Incompatible React versionsreact-domInstall and scaffold
Error: directory "{slug}" already exists. · npm install failed — you can run it manually.create-hr-pluginInstall and scaffold · index §11
Error: invalid plugin slug · is reserved — it collides with · Error: unknown template · Error: expected a plugin slug firstcreate-hr-pluginInstall and scaffold · index §11
hr-widget-build: {anything}the SDK build CLIBuild · index §11
[viteHomerunnerWidget] This project declares manifest.widgets[]the Vite presetBuild
Cannot find module '@homerunner-next/widget-core/schema'TypeScriptBuild
__HR_WIDGET_DEV__ is not definedyour bundle at runtimeBuild
dist/manifest.json (build manifest with {buildHash, files}) is missingbuilt-zip auditBuild · index §3
Missing {file} — run `npm run build` first. · a red SSR render failed: panelthe local SSR previewServer rendering
No widget selected — manifest.json declares · Unknown widget "{w}" — manifest.json declares · is CSR-only (`"ssr": false`)the local SSR preview, at bootServer rendering · index §11
ReferenceError: window is not defined · document is not definedthe SSR sandboxServer rendering
ReferenceError: crypto is not defined · TextEncoder is not defined · Response is not definedthe SSR sandboxServer rendering · index §7
Plugin cannot require "{mod}" (not in shared modules whitelist)the SSR sandboxServer rendering · index §7
[plugin] fetch to "{host}" blocked · [plugin] fetch blocked — more than 5 redirects.the SSR sandboxServer rendering · index §7
[plugin] SSR bundle has no default export: {url}the rendererServer rendering
HTTP {status} redirect refused — SSR bundles must be served directly from their published URLthe rendererServer rendering
data-hr-widget-error= in the page sourcethe rendererBlank widgets · index §6
SSR data prep failed: {msg}the rendererServer rendering · index §6
layout SSR data prep failed: {msg} · plugin layout widget rendered as contentthe rendererLayout problems
keyword does not resolve against the plugin manifestthe rendererBlank widgets
Widget ID is requiredmount()Blank widgets
[widget-core] window.HRWidgetRuntime is not available.the SDK runtimeBlank widgets
[hr-widget] "{type}" mount failed for one embed · deferred mount step failedmount()Blank widgets · index §8
[hr-widget] "{type}" render failed · data-hr-error="render-failed"the render boundaryBlank widgets
[widget-core] widget "{type}" did not register within 10000ms · [layout-csr] …a CSR layoutLayout problems
[page-layouts] feed {id} {page}: {reason} — using Automaticthe rendererLayout problems
Unsupported schema type: [object Object]the dashboard config panelConfig panel
No configSchema for this widget. · sub-manifest fetch failed (HTTP {status})the dashboard config panelConfig panel · index §10
Plugin "{slug}" not enabled for this feed. · Widget "{keyword}" is not declared by plugin "{slug}".the dashboardConfig panel
{"error":"Asset not in plugin manifest"} · {"error":"Plugin not found"}the /p/ proxyWorks locally, not live · index §9
VERSION_NOT_GREATER · SLUG_TAKEN · SLUG_RESERVED · INVALID_VERSION · INVALID_MANIFESTcentral, at submitSubmit and publish · index §2
MANIFEST_SHAPE_CHANGED · changes the plugin between single-widget and multi-widget shapescentral, at publishSubmit and publish
MANIFEST_WIDGET_CORE_MISSING · MANIFEST_WIDGET_CORE_TOO_OLD · predates the evergreen runtimepublish gatesSubmit and publish
already exists on the CDN — published versions are immutablethe dashboard uploaderSubmit and publish · index §5

Two attributes are easy to confuse when you grep: data-hr-widget-error is a hidden breadcrumb the server leaves where a widget should have been, and data-hr-error is a marker the browser runtime puts on a live host element. Look for both.


Install and scaffold

SymptomCauseFix
npm install fails with npm error code ETARGET / No matching version found for @homerunner-next/widget-core@…A pin your project asks for does not exist on the registry — usually a hand-edited range, or a private/stale registry in .npmrc. Every version this book documents is publishedInstall and versions — check npm view @homerunner-next/widget-core versions against your pin
hr-widget-build: command not found, or ls node_modules/.bin/hr-widget-build is emptyYour project resolves a widget-core older than 0.11.0 — before 0.11 the package declared no bin at allBump the pin and reinstall. On a pre-0.11 SDK the scaffold’s three-step build:manifest && build:iife && build:ssr is the only build path
Error: directory "{slug}" already exists. (exit 1)The scaffold refuses to write into an existing directoryRemove it or pick another name
npm install failed — you can run it manually. and the CLI still exits 0Install failure is deliberately non-fatalcd {slug} && npm install
Error: expected a plugin slug first — got "{arg}".argv[0] is always the slug; flags must come after itScaffold a project
Error: invalid plugin slug "{slug}" — lowercase letters, digits and hyphens only… · Error: plugin slug "{slug}" is reserved…The slug goes verbatim into manifest.id, every keyword and every CDN path, so the scaffolder applies the publish audit’s own rule up frontRename — Manifest: root fields
Error: unknown template "{t}" — expected "single" or "suite".--template (or --template=) with anything else, or with nothing usable after itThe two shapes are single and suiteThe two manifest shapes
Incompatible React versions: The "react" and "react-dom" packages must have the exact same version.A caret range on react / react-dom. Your client bundle externalises React against the page’s runtime, and the SSR sandbox hands you the renderer’s copiesPin react, react-dom and @tanstack/react-query exactly — The React-family pins
npm run dev:ssr starts fine but ignores every variable in .env.localNode below 20.12 has no process.loadEnvFile, and the harness swallows the TypeErrorUpgrade Node — Node
You wanted a suite and got one flat widgetYou omitted --template suite (the default is single), or you passed --template=suite to create-hr-plugin 0.8.0, which parsed only the space-separated form and silently scaffolded singleRe-scaffold with --template suite on 0.8.1+, or convert before your first publish — Build a suite. The shape is frozen after that (The two manifest shapes)

Build

Run npm run build. It is two steps — schema injection, then the SDK’s hr-widget-build bin — not the three-step chain older guides describe; hr-widget-build runs the iife and BUILD_SSR=1 passes for you and finalizes exactly once. Single-widget: the scaffold still declares build:iife and build:ssr as escape hatches, and a hand-run pass never reaches the finalize step. Suite: --template suite ships neither script, and a bare vite build fails by design. See Build for the single-widget shape and Build for a suite.

SymptomCauseFix
hr-widget-build: no manifest.json in the current directory.Run from somewhere other than the project rootcd to the root
hr-widget-build: vite is not installed in this project.Missing dev dependencynpm install
hr-widget-build: invalid widget slug in manifest.widgets: {json} · widget slug "{s}" is reserved. · is declared more than once.A widgets[] slug fails the slug pattern, is dist/widgets/media, or repeatsManifest: widget summary
hr-widget-build: missing entry for widget "{s}" ({path}).No src/widgets/{slug}/index.tsx for a declared summaryCreate the entry, or drop the summary — Build a suite
[viteHomerunnerWidget] This project declares manifest.widgets[] — build it with `hr-widget-build` (which loops BUILD_WIDGET per widget), not a bare `vite build`.A suite built through a bare vite build — usually the leftover build:iife / build:ssr scriptsUse npm run build; delete those two scripts from a suite
dist/manifest.json missing, or the publish gate says dist/manifest.json (build manifest with {buildHash, files}) is missing — run the widget-core build.Only the last Vite pass hashes dist/, writes the build manifest and stamps runtime.widgetCore. A hand-run single pass never gets thereRe-run the whole npm run build. Never patch dist/manifest.json by hand
A rebuilt widget still serves last build’s filePer-widget passes run with emptyOutDir: false, so a hand-run pass leaves stale output beside the new filesFull npm run build — it clears dist/ once, up front
manifest.json shows up dirty in git after every buildBy design: schema injection then the runtime.widgetCore stamp both rewrite the tracked fileCommit it. The stamp is a publish gate — Packaging and publishing rules
Cannot find module '@homerunner-next/widget-core/schema' (or /runtime, /plugin-schema, …) from tsc, while Vite builds fineYour tsconfig.json resolves modules the pre-exports way, so the package’s subpath map is invisibleSet "moduleResolution": "bundler" (the scaffold’s value). The subpath list is in SDK reference
__HR_WIDGET_DEV__ is not defined at runtimeThe bundle was built without viteHomerunnerWidget, which defines that literalBuild through the preset — /vite and hr-widget-build
mockServiceWorker.js appears in your published dist/Vite copies publicDir into outDir on every build. Both scaffold templates turn it off for builds since create-hr-plugin 0.8.0; a project scaffolded earlier does not have that lineAdd publicDir: command === "build" ? false : "public" to vite.config.ts — but do not delete the file, the dev sandbox needs it. The worker file
npm run preview shows a heading and an empty boxThe dev page’s only script was the Vite dev entry, which the static server 404s, so nothing ever evaluated. Fixed in create-hr-plugin 0.8.0: the entry now carries an onerror that boots the runtime IIFE and your built bundle insteadRe-scaffold, or copy the bootPreview() block and the /p/{slug}/{file} route out of the current template’s index.html / serve.js. Note preview talks to live Central with no mocks, so point the host id="{widgetId}:{feedId}" at a real pair

Before you zip, run the publish gates you can run locally: the script in Run the reviewer’s gate yourself checks declared assets, hashed twins, the HRPlugins marker, base-schema extension and declared media/ files.


Dev server

Everything here is npm run dev — the offline Vite sandbox on :3001 with MSW-mocked data. It is documented in Dev sandbox and mocking.

SymptomCauseFix
Vite reports Port 3001 is in use, trying another oneAnother dev server holds the portHarmless — the dev CSS routes are root-relative, so they follow the new origin. Just use the port Vite prints
The widget mounts unstyledYour CSS import is redirected to a no-op virtual module in dev and served at a URL instead; check /dist/{slug}/{slug}.css (single-widget) or /dist/{slug}/{widget}/{widget}.css (suite) actually returns your rulesDev-server URLs
Saving a .css file changes nothingThe server emits a custom hr-widget-css-update event that cache-busts the <link> inside the shadow root; a hard reload confirms whether the build is staleDev-server URLs
Every request escapes to the networkThe MSW worker did not install, usually after an msw upgradenpx msw init public. Never delete public/mockServiceWorker.jsThe worker file
The scenario buttons do nothing and none of them lights up?scenario= is cast, never validated: an unknown value behaves like default and leaves the row unlitScenarios
Scenario buttons switch but the old rows stay on screenThe panel resets every query whose key does not start with "widget". Your own keys starting with "widget" are sparedNamespace query keys with your plugin slug — Query-key discipline
?mock=off gives data-hr-error="widget-fetch-failed"dev-widget:1 exists on no real feedPut a real {widgetId}:{feedId} in index.htmlReal staging data
In a suite, every layout slot child resolves to the layout itselfstartMockWorker is a page singleton bound to ONE keyword, and the built-in /widget/{id} handler echoes it for every idUse the widget fixture factory, keyed by the requested id — what --template suite ships. Suites
In a suite, two widgets show the same value for a settingOne page, one mock settings object — the suite sandbox seeds it with the union of every widget’s schema defaults, so same-named fields collideRename the field, or check that widget on its own in npm run dev:ssr -- --widget {slug}, which seeds only its schema — Suites
The error scenario shows your fixture, not an errorScenario gating short-circuits before your fixture runs, and only the data endpoints are gatedWhy the widget always mounts

Server rendering

The renderer evaluates your built SSR bundle in a node:vm realm with no DOM. The contract is Component and SSR module; the local harness and its divergences are Previewing SSR locally.

ReferenceError: window is not defined (or document, navigator, localStorage). The sandbox seeds those seven globals as inert Proxy stubs, and a property read off a stub returns another stub, which is truthy. So if (document) and if (window.matchMedia) both pass on the server and then blow up on the real call. Only one guard form works:

// Verified in packages/homerunner-renderer/lib/plugin-federation.ts (makeBrowserGlobalStub)
// and the SSR build's typeof rewrite.
if (typeof window === "undefined") return;   // rewritten to "undefined" at build, then DCE'd

The SSR build rewrites typeof on window, document, self, navigator, localStorage and sessionStorage to the literal "undefined", so esbuild deletes the branch and the imports inside it. Truthiness guards keep the dependency in the bundle. Full table: Browser-global stubs, and the guard that actually works.

SymptomCauseFix
ReferenceError: crypto is not defined (also TextEncoder, TextDecoder, Buffer, Response, structuredClone, queueMicrotask, performance)Not seeded. There is no Web Crypto and no crypto.randomUUID() in the sandboxBundle a pure-JS replacement, or move the work into the browser. Inventory: Globals
It works in npm run dev:ssr and throws TextEncoder is not defined on the first customer pageYou are on widget-core below 0.12.2, which seeded TextEncoder/TextDecoder in the harness while the renderer never had them. 0.12.2 removed both, so the throw now happens locallyUpgrade the SDK, then bundle a pure-JS replacement — Where the preview differs from production
Plugin cannot require "{mod}" (not in shared modules whitelist)Exactly seven specifiers resolve; everything else must be bundled into your SSR fileShared modules (7)
That same throw for @homerunner-next/widget-core/runtime, but only locallyYou are on widget-core below 0.12.2, which omitted /runtime from the harness’s whitelist while the renderer shared it. Fixed in 0.12.2Upgrade the SDK. Keeping server code off /runtime — a browser surface — is still the safer habit
[plugin] fetch to "{host}" blocked — not declared in the widget's externalFetch manifest field.Server-side fetch to a host outside Central ∪ your declared list. Nothing validates that list at publish, so a typo only shows up on a live pageexternalFetch
[plugin] fetch to "{host}" blocked — private/metadata hosts are never allowed.Loopback, private ranges, *.local, link-local and metadata hosts are refused even when declaredSame
[plugin] fetch blocked — more than 5 redirects. · [plugin] fetch blocked — unparseable URL: {raw}Redirect chain too long, or a non-URL passed to fetchSame
A widget renders stale server data for up to a minuteok GET responses are cached across renders outside development modeBound in Runtime bounds; bypass with ?dev=1
HTTP {status} redirect refused — SSR bundles must be served directly from their published URL · a bare HTTP {status}Your ssr.url 3xx’d or returned non-2xxExecution bounds
[plugin] SSR bundle has no default export: {url}ssr-entry.ts exports no default. Mandatory for every widget, layouts includedThe entry file
SSR bundle failed to load from {url} with nothing else in the logOften the module-scope execution timeout: synchronous top-level work exceeded the vm bound. Async work is not bounded by itMove work into getInitialData; give every request an AbortSignal.timeout(…). Bound: Runtime bounds
SSR data prep failed: {msg}A throw in getInitialData, dehydrateState, getStaticAssets or your component’s server render — all four sit in one tryParse ctx.options with parseWidgetConfig and default everything: Failure semantics
[widget] SSR prefetch FAILED — shipping loading markup. and the widget renders a spinnerprefetchQuery swallows errors by design; the page is marked degradedRender a real empty state, not a spinner, when a query has no data — Prefetch errors only warn
Missing {file} — run `npm run build` first. or a red SSR render failed: panel locallynpm run dev:ssr reads dist/ and compiles nothing. {file} is dist-relative, so a suite’s reads {widget}/{widget}-ssr.umd.jsBuild first — Running it
npm run dev:ssr fails at boot with No widget selected… or Unknown widget "{w}"…A suite has no default widget, and the selection is validated against manifest.json before the server startsName one: npm run dev:ssr -- --widget {slug}Naming a widget
npm run dev:ssr fails at boot saying the widget is CSR-onlyThe correct outcome: a widget whose summary declares "ssr": false has no SSR bundle and no ssr-entry.ts. Exact string in index §11Check it in the browser sandbox instead — Dev sandbox and mocking
A layout previews with all its slots emptyNot a bug and not fixable locally: renderPluginSSR passes no renderedSlots, and only the platform renders a layout’s bound childrenVerify composition on a real feed — Suites: one widget at a time

When a widget is simply absent from a rendered page, view-source and search for data-hr-widget-error. The renderer leaves a hidden node whose HTML comment carries the reason; each reason maps to a cause in index §6. A page containing one is flagged degraded and its cache TTL collapses, so it is not stuck — it re-renders shortly.


Hydration mismatches

React discards the whole server DOM on a mismatch and re-renders client-side. The symptom is almost never an error: it is a visible flash, an interaction that only works after a beat, or SSR markup that is silently thrown away. The contract is The hydration contract.

SymptomCauseFix
Server HTML is replaced wholesale on hydrate; useId-derived ids (Radix aria-*, form ids) differThe server renders each widget as an isolated root inside WidgetHydrationTree with identifierPrefix = the host’s data-hr-ssr-id. Any drift in wrapper shape or prefix regenerates the treeNever call hydrateRoot yourself — Why you must not call hydrateRoot yourself
Output differs between server and client for no obvious reasonprops.widgetType is absent on the server and on the hydrate path, and present only on pure CSR. Same for Math.random(), Date.now(), any window readRender deterministically — What each path actually passes
Dark mode is correct on CSR and light forever on a server-rendered pageresolvedTheme derives from a cookie the edge worker never forwards, so it is "light" on every production SSR — and mount() hydrates with the server’s props, so it stays lightBranch in CSS off the host attribute — The theme contract
Visible server content that never becomes interactive, sitting over an invisible second render — and it looks fine in some browsersThe manifest says shadowDOM: true and your mount() passes false. The browser attaches the server shadow root at parse; your light-DOM render is never displayedMake the two agree — When shadowDOM disagrees
The widget client-renders even though the server clearly emitted markupA component whose entire server output is a hoistable tag (<style>, <link>, <script>, <meta>, <title>, <base>) reads as “no server markup”Nest server output inside a real element — Shadow DOM lifecycle
[widget] SSR state could not be inflated; widgets fetch client-side.The page’s dehydrated-state block was malformed or blocked; the page degrades to client fetching rather than breakingCheck the page’s inline scripts are not being rewritten or CSP-blocked — The DOM contract
data your getInitialData returned arrives mangledIt travels as JSON in an inline script; Date, Map and functions do not surviveReturn JSON-serialisable values only
You cannot reproduce a hydration bug locallynpm run dev never takes the SSR branch at all. npm run dev:ssr in hydrate mode does apply WidgetHydrationTree and the identifier prefix since widget-core 0.12.2 — below that it rendered bare with no prefix, so useId drift was neither reproducible nor trustworthyUpgrade the SDK and run HR_SSR_HYDRATE=1 npm run dev:ssr; the remaining gap is Declarative Shadow DOM and the props registry — Where the preview differs

Blank widgets and error states

Start by reading the host element. data-hr-error is set by the browser runtime; its complete vocabulary and what clears each value is The data-hr-error vocabulary.

Value on the hostWhat it meansWhere to look
render-failedYour component threw during render. By far the most common cause is reading props.options without parseWidgetConfigOptions arrive RAW
mount-failedA throw inside mountOne — classically a missing or non-colon-joined id — or a rejected deferred hydrate/render stepIdempotency and per-embed isolation
widget-fetch-failedThe widget-config fetch failed. A non-colon-joined id makes feedId NaN and 404s the fetchAttributes mount reads
rate-limitedThe config fetch returned 429Rate limit
property-not-foundA CSR layout was pointed at a property slug that does not exist on the feedLayout problems

The raw-options trap, spelled out. props.options arrives unparsed on both the server and the client — plugins are deliberately excluded from the renderer’s settings parse. Raw settings carry null for a cleared field, [] for an emptied map, stringified numbers and a legacy width object. Calling configZod.parse() on that throws; parseWidgetConfig strips, coerces and lifts first. Call it in the component and in every SSR export:

// pdp-suite/src/widgets/stay-hero/widget.tsx (real, published)
const cfg = parseWidgetConfig(configZod, props.options ?? {});

Reproduce it locally by clearing a field to null or "" in the dev panel’s settings editor — see Testing options-updated.

SymptomCauseFix
Nothing happens at all: no error, no data-hr-mounted, no console linemount selects on [data-hr-widget="{widgetType}"] inside the container you pass. A wrong keyword matches nothing and returns silentlyCheck the keyword on the host equals config.widgetType exactly — mount(container, config)
[widget-core] window.HRWidgetRuntime is not available. Make sure the runtime.iife.js <script> tag loads before your widget entry.Script order on a hand-written embedRuntime first, then your bundle — Boot order
The bundle 200s in the network tab and never executescrossorigin="anonymous" on a /p/ script tag. /p/ 302s off-host to a target with no ACAO, so the browser rejects the final response in CORS modeDrop the attribute — The crossorigin trap
The bundle executes but the component never rendersThe registry key must be the keyword minus plugin:. The publish audit only greps for the literal HRPlugins, so a bundle registered under the wrong key passes and fails silentlyRegistration
Widget ID is requiredThe host has no id. It must be the colon-joined "{widgetId}:{feedId}"The embed shape
Fixing the DOM and calling mount again does nothingdata-hr-mounted is written before the id check, so a failed embed keeps the markerRemove the attribute, or call el.__hrWidgetUnmount()The unmount handle
The failure node’s reason is keyword does not resolve against the plugin manifestThe stored keyword does not match the manifest shape: a bare plugin:{slug} against a suite, a widget slug against a v1 manifest, or an unknown/kill-switched slugShape mismatches fail closed
A live edit in the dashboard preview does nothingOnly the CSR paths listen for options-updated; a hydrated SSR widget installs no listenerLive preview
One embed broke and the others are fineWorking as designed — each element mounts inside its own tryIdempotency and per-embed isolation
A CSR-only widget leaves a tall gap, or none at allpreloadMinHeightHint returns null for every plugin:* keyword, so nothing is reserved unless you supply a heightSet expectedHeight (Manifest: widget summary) or data-hr-min-heightAnti-CLS preload reservation

Empty or wrong config panel

SymptomCauseFix
An amber banner: No configSchema for this widget. Ask the plugin developer to ship one via zodToManifestSchema(widgetSchema.extend({...})) in the widget's config.ts.The sub-manifest is missing from the CDN, 404’d, timed out, or carries no configSchema. It also appears when the browser cannot rebuild a zod object from your JSON Schema ([plugin-playground] jsonSchemaToZod failed: in the console). This degrades; it is never an errorPer-widget sub-manifests
sub-manifest fetch failed (HTTP {status}) in the consoleThe declared widgets/{slug}.manifest.json is not at that path under the published version prefixIt must be committed and shipped in both zips — What must be committed
The whole config page crashes with Unsupported schema type: [object Object]z.any(), z.unknown(), z.literal(), z.tuple(), z.intersection() or z.discriminatedUnion() survived the JSON-Schema round trip into the dashboard’s unguarded default-value walkerNever ship those six. Model variants as a z.enum() discriminator plus flat optional fields — Breaks the whole panel
Plugin "{slug}" not enabled for this feed.The plugin is not installed on that feedInstall, customers and kill switches
Widget "{keyword}" is not declared by plugin "{slug}".A stored keyword the current manifest no longer resolves — usually a slug you renamed or removedDeprecate instead of deleting — deprecated and replacedBy
A built-in panel (Filter, Spacing, Section…) lost its controlsYou re-declared a base key in your uiSchema. The adapter replaces a top-level key wholesale — it does not deep-mergeNever re-declare a base key
Fields inside a panel are labelled translations.viewDetailsThe default label is the full dotted path; there is no humanisationDeclare label on every nested field — The uiSchema
Your uiSchema key order is ignoredPlugins cannot reorder anything; order is droppedMove the field in the zod schema — The uiSchema
Everything the customer types into a record field fails your runtime parseThe key/value editor writes stringsKeep record values z.string()Degrades silently
A cross-field .refine() never fires in the dashboardzodToJsonSchema erases refinements; the form’s validation is cosmetic anywayEnforce it in parseWidgetConfig and degrade rather than throw — Reading options back
A changed schema default does not reach existing widgetsThe seeded object is autosaved on the customer’s first edit, so your defaults became stored valuesDefaults at widget creation
The panel is fine but standard theme/spacing controls are missingYour configSchema does not extend the base widgetSchema. This is a publish warning, visible only to the reviewerBuild config.ts as widgetSchema.extend({...})The base schema

Layout problems

Layouts are suite-only and need widget-core 0.11.0+. The full contract is Layout widgets.

SymptomCauseFix
Your layout is not offered under Feed → Page URLs → Page layoutsIt appears only when the plugin is approved and enabled on that feed, the widget is not kill-switched, and the summary declares that page in pagespages and page assignment
The customer’s slot picks vanish on saveThere is nowhere to store them: your zod config has no slots object fieldAdd it, keys matching the manifest slot names exactly — The mandatory slots config field
One slot renders and another never doesSlot names must match character for character between the manifest summary, the zod field and (for presets) the preset’s slots keys. Preset slot keys are never validatedsettings.slots · Presets
A slot is empty and renderedSlots.hero is undefinedAn unbound slot is absent, not empty. On an assigned page only the keys present in the stored bindings are rendered — nothing is backfilled for a plugin layoutAlways props.renderedSlots?.[name] ?? nullThe component contract
The layout shell is inert on a server-rendered page: no clicks, no stateKnown gap. The renderer emits no data-hr-ssr-id and no props-registry entry for a shell, and the client mount is a deliberate no-op — this is correct behaviour, not a broken mountPut interactivity in a content widget bound into a slot — SSR: the server composes your children
The whole customer page 500sA layout component that throws during render is not contained: the shell is rendered later, in the page-level renderToString, which has no tryNever throw in a layout’s render path. Parse defensively, default every field and every slot — Failure semantics
A breadcrumb reason of plugin layout widget rendered as content — needs a layout entry with slotsA category: "layout" widget was placed as ordinary contentManifest: category
layout SSR data prep failed: plugin layout widgets require an SSR bundle (`ssr: false` is content-only)The summary declares "ssr": false. The publish audit lets that through; the renderer does notAn SSR bundle is mandatory for a layout — Requirements at a glance
The server render replaces itself with an empty client rendercategory: "layout" was omitted from both registerPluginWidget and mountRegistration and mount
The layout stylesheet restyles the customer’s whole pageOn a server-rendered page the shell is light DOM and its CSS is loaded document-levelClass-namespace every selector, never rely on :host, never paint a background — Layout CSS
A CSR slot child stays empty; [widget-core] widget "{type}" did not register within 10000msIts IIFE 404’d or never registered. The layout still rendersCSR: the layout composes its own children
[layout-csr] Skipping nested plugin layout child "{keyword}" — layouts cannot nest.A layout was bound into a slot. Refused on both render pathsLayouts cannot nest
[layout-csr] Property "{slug}" was not found in feed {id}. and data-hr-error="property-not-found"The one whole-layout failure: a permanent 4xx on the property checkFix the layout’s Property setting or the embed’s data-hr-options filter
accepts: ["layout"] matches nothing in the slot pickerKnown gap — the picker excludes layouts before accepts is evaluated, and accepts is unvalidated at publishKnown gaps, collected
A listings / checkout / confirmation page 404s outrightThose pages pass only a slot named content, and 404 when it resolves to no live widgetName a slot exactly contentWhat the renderer does with an assignment
[page-layouts] feed {id} {page}: {reason} — using AutomaticThe renderer could not honour the assignment and fell back rather than failingRead the reason — Degrading to Automatic
You deprecated an assigned layout and it keeps renderingKnown gap: the dashboard flags it, the renderer does not check deprecatedAsk the customer to switch that page back to Automatic
A dialog inside a layout shell never opensOn a server-rendered page there is no shadow root and no portal container in a shellLayout widgets have no portal container

Styling and dark mode

SymptomCauseFix
The widget renders with no styles at all on a live pageYour assets.css never resolved — a /p/ 404, a wrong dist-relative path, or a crossorigin attribute on the tagTwo serving paths · The crossorigin trap
A widget briefly renders unstyled, then stylesThe shadow CSS gate resolves regardless after its timeout; CSS never blocks hydration. A failed sheet logs [HRWidget] Failed to load CSS in shadow DOM: <href>Shadow DOM lifecycle
The customer’s page loses all its margins, padding and bordersTailwind preflight. On a server-rendered page your stylesheet is also appended to <head>, so its universal selector resets their whole documentImport Tailwind without preflight — The Tailwind stack
Your .card / .title rules collide with the host pageSame reason: on a composed page every selector you ship is a global selectorNamespace every class — Where your CSS actually lands
Dark mode follows the visitor’s OS instead of the configured colour schemeTailwind’s dark: variant compiles to @media (prefers-color-scheme: dark)Key off the host attribute instead — The theme contract
Your dark arm silently never matches:host([data-hr-scheme="auto"]):not([data-hr-theme]) parses fine and matches no shadow host. The :not() must be inside :host(...)Four footguns
The dark rule loses to the light rule:where() holds specificity at zero, so the dark selector has to string-match the light oneSame
auto still flashes light before hydratingYou did not declare mediaSafeAuto, or the request was not in a DSD bucketDark at first paint · mediaSafeAuto
A dialog opens as unstyled browser-default HTML over the pageThe portal target fell through to document.body, which is outside your shadow rootTwo portal targets
A dialog is almost right — a white panel over a dark widgetThe elevated portal clones only link[rel="stylesheet"]. Everything you rendered as an inline <style> (accent, font, customCss) is absent, so every token falls backBuild the token string once and pass it as cssString too — The token trap
Your @font-face is ignored@font-face does not work inside a shadow rootDeclare the stylesheet in assets.fonts, which is injected at document level — The two injection paths
Declared fonts arrive after first paint on a server-rendered pageYou export getStaticAssets, and declared fonts are appended only on the manifest pathOmit getStaticAssets unless you truly need it — Component and SSR module
A font you edited into manifest.json is not loadingThe build bakes the font list into the client banner at config timeRebuild after editing — Web fonts
An imported image ships as a huge data: URLVite library mode inlines everything unless tagged ?no-inline; the preset only tags listed extensionsKeep unusual asset types off the import graph — Bundled assets
spacing, font, accent colours or customCss have no effectThe platform applies none of the base fields for youRender them yourself — Rendering the base fields yourself

Submit and publish rejections

Three gates, and you only watch two of them: Preflight and submit walks all three, and every code and message is in index §1–§5.

SymptomCauseFix
The uploader lists zip problems and never transmitsThe source-zip audit runs in your browser: structure, entries, sizes, and the manifest’s shapeThe source zip · index §1
VERSION_NOT_GREATER after a clean browser auditThe browser checks the version’s shape, never its ordering against the live versionVersion rules
INVALID_VERSION on something a looser parser accepts, e.g. 1.0Central demands exactly three numeric partsSame
SLUG_TAKEN · SLUG_RESERVEDAnother author owns the slug, or it is an unowned legacy rowChecked after the bytes are in the bucket
INVALID_MANIFEST for a manifest your browser acceptedCentral applies rules the browser does not — length caps on id, name, version, description, author, plus the widget count ceilingManifest: root fields
MANIFEST_SHAPE_CHANGED — at publish, after review passedA live plugin can never flip between the single-widget and suite shapes. It is not checked at submit or at reviewPublish the new shape under a new slug — The manifest shape is frozen at first publish
MANIFEST_WIDGET_CORE_MISSING / _TOO_OLD, or manifest has no "runtime.widgetCore" stamp — this build predates the evergreen runtime.The build’s stamp is missing or below the floor. It is written by the last build pass; never by handUpdate widget-core, rebuild, commit manifest.jsonruntime
Declared icon {ref} is missing from the zip. (also cover, screenshot, README, sub-manifest)media/ and widgets/*.manifest.json are authored content. Nothing generates them, and the reviewer only runs your buildCommit them into the source zip — Media references
{slug}@{version} already exists on the CDN — published versions are immutable.Version prefixes can never be overwrittenResubmit with a bumped version — Immutability and retry safety
A file you shipped is simply not on the CDNAnything more than one directory deep under dist/, widgets/ or media/ is dropped silently; an unusual file name is dropped with a reviewer-only warningKeep the SDK’s output layout — The publishable surface
Your plugin shipped with an empty settings form and nobody told youKnown gap: built-zip warnings are visible only in the reviewer’s consoleRun the local gate script before you zip — Run the reviewer’s gate yourself
Silence after submittingKnown gap: there is no notification of any review outcome, everPoll Dashboard ▸ Plugins ▸ My plugins — From your laptop to a customer page
Everything published, and every cache optimisation is goneYou published a pre-release version like 1.0.0-rc.1. Every immutability optimisation keys off a bare X.Y.Z segmentUse plain three-part versions — Known gaps

It works locally but not on the customer site

The single most productive question: which of the two harnesses did you verify in, and what does it not simulate?

It works in……and breaks live becauseRead
npm run devThe sandbox seeds settings with configZod.parse({}), so every field is present and typed. Customers’ stored settings arrive rawOptions arrive RAW
npm run devIt never converts your configSchema back to zod, so a round-trip breaker looks fine here and empties the customer’s panelWhat survives the manifest round-trip
npm run devIt always takes the CSR branch: no DSD, no props registry, no hydrationWhat the sandbox does not cover
npm run dev on a suiteIt composes a layout’s slots the client way, from bindings in mocks/fixtures.ts. A server-rendered page composes them in the renderer and hands the shell renderedSlots in light DOMSuites
npm run dev:ssrThe harness pre-parses options, points __HR_PLUGIN_ASSET_BASE__ at localhost, emits props as an attribute rather than the page registry, and never composes a layout’s slots. On widget-core below 0.12.2, add: seeded TextEncoder/TextDecoder, no /runtime, no externalFetch enforcement, no execution timeoutWhere the preview differs from production
Either harnessNeither emits Declarative Shadow DOM or the page-level props registryHydration mismatches
A CSR embedOn a composed page your stylesheet is also in <head>, unisolatedWhere your CSS actually lands
A CSR embed of a layoutThe CSR layout gets a shadow root and full providers; the server-rendered shell gets neither, and its mount no-opsHow a layout renders

And when the plugin is live but a page still disagrees with you:

SymptomCauseFix
{"error":"Asset not in plugin manifest"} from /p/The file is neither a manifest-declared asset nor a key of your dist/manifest.json. In a suite it must also sit under a directory named for a declared widget slugThe allow-list
{"error":"Plugin not found"} from /p/Unknown slug, or the plugin is not approved — a suspension 404s every fileThe three kill switches
One widget of your suite stopped rendering everywhere, with no message anywhereA platform per-widget kill switch strips its summary from every public surface and 404s its entire dist/{widget}/ directorySame page
One widget stopped rendering on one feed onlyThe customer’s per-feed toggle. Assets keep serving; only that feed’s instances stop resolvingSame page
A widget you retired keeps rendering on existing pagesThat is what deprecated does — it hides the widget from the picker and leaves placements alonedeprecated is not a kill switch
You published and customers still see the old buildCache, not publishing. The publish fan-out purges what it can reach inside its budget; anything it misses waits out the page TTLHow an update reaches a customer
A page mixes new markup with an old bundleIt cannot. Server-rendered pages embed content-addressed URLs, so a cached page keeps loading exactly the bundles it was rendered withA stale page is stale consistently
A live page broke after old version prefixes were prunedDeleting an old prefix is the one operation that can break a cached pageWhat holds a copy of your old build
A 429, or data that stops arriving under loadThe public API is rate limited per IP, shared across every endpointRate limit

Older versions of this guide told you to call a refresh_manifest API to clear a stale manifest. Do not look for it: it is an administrator-only action with no author-facing surface. Publishing records the manifest and triggers the warm-and-purge fan-out; there is nothing for you to invalidate.


Seeing what the server actually did

Four signals, all owned by Seeing what the server actually did on a real page: development mode (?dev=1, which bypasses caching for a feed), the <!-- hr-render … --> stamp at the end of the page source, the x-hr-render-* headers, and view-source for data-hr-widget-error.

Two more things worth knowing while you read a live page:

  • Your own console.* calls inside the SSR bundle go to the renderer’s stdout, not to the browser. Ask an operator to grep the [plugin], [widget] and [layout] prefixes listed in index §8.
  • Check what is actually live before you debug anything else: Checking a release yourself reads your published manifest.json and dist/manifest.json straight off the CDN.

Still stuck

Glossary

Every term this book uses as a term of art, in one alphabetical list. Each entry is a one-line orientation plus the page that owns the term — the rule, the limit and the error string live there, never here. When this page and a linked page disagree, the linked page wins.

Version gates are shown inline, e.g. (0.11.0+), and mean @homerunner-next/widget-core. Which versions you can actually install is in Install and versions.

TermWhat it isDefined in
acceptsPer-slot list of categories and/or exact keywords the dashboard’s slot picker offers. A hint to the customer, read by nothing at render time.Layout widgets
Advisory (unvalidated)Enforcement level: nothing checks it at any layer, so a typo is yours to find in production.Limits and error index
Asset proxy (/p/){proxy}/p/{slug}/{dist-relative file} — the versionless plugin asset endpoint. One 302 to the content-hashed CDN file. Never put crossorigin on a /p/ script tag.Keywords, assets and URLs
assets.fontsStylesheet URLs (or confined relative paths) injected at document level, because @font-face is ignored inside a shadow root. (0.12.0+)Keywords, assets and URLs
Build manifestdist/manifest.json — generated {buildHash, generatedAt, files} mapping every dist-relative name to its hashed twin. Not the registration manifest.Keywords, assets and URLs
Built zipThe zip the reviewer builds from your source and uploads to the CDN — your source plus the whole dist/ tree.Packaging and publishing rules
Bundled assetAn image or font your widget imports; above the inline threshold the SDK emits it as a real file and resolves its URL from the bundle’s own location. (0.12.0+)Keywords, assets and URLs
categorycontent or layout on a widget summary. Anything but the literal layout resolves to content.Manifest: widget summary
Composed pageThe customer page a worker assembles from the renderer’s HTML and caches. Your widget is one island inside it.How a widget renders
Confined relative pathA manifest path that cannot escape the published version prefix: no leading /, no scheme:, no \, no ...Manifest: widget summary
configSchemaThe JSON Schema in your manifest (or sub-manifest) that drives the dashboard form. Emitted by zodToManifestSchema().Config schema and UI schema
Content widgetA widget that renders itself and hosts nothing — the default category.Manifest: widget summary
crossorigin trapAdding crossorigin="anonymous" to a /p/ tag: the off-host 302 target sends no ACAO, so the bundle silently never executes.Keywords, assets and URLs
CSR-only widgetA widget declaring "ssr": false — no SSR bundle, no ssr-entry.ts, mounted in the browser only. Give it an expectedHeight.Manifest: widget summary
data-hr-errorHost-element marker naming why a widget failed: mount-failed, render-failed, rate-limited, widget-fetch-failed, property-not-found.Runtime, mount and the DOM contract
data-hr-schemeHost attribute meaning “follow the OS” — a pre-hydration CSS gate only, stamped by the server and removed the moment the runtime attaches.Styling and theming
data-hr-ssr-idServer-written key into the page’s props registry, and the identifierPrefix used to hydrate. Layout shells never get one.Runtime, mount and the DOM contract
data-hr-themeHost attribute carrying the resolved theme once JS owns theming. Your dark rules must target it, and the media-query arm must yield to it.Styling and theming
Declarative Shadow DOM (DSD)Server-emitted <template shadowrootmode="open">, so the browser attaches and styles your shadow root while parsing — no mount blink.How a widget renders
Degraded pageA server-rendered page carrying any failure breadcrumb or failed prefetch. It still serves; its cache lifetime collapses.Limits and error index
dehydrateStateOptional SSR export: prefetch into the page’s shared React Query client so the browser hydrates without refetching.Component and SSR module
deprecatedSoft retirement of one suite widget: gone from the picker, existing placements keep rendering. Not a kill switch.Manifest: widget summary
Dev panelThe bar npm run dev pins to the page — scenario, theme, latency and a settings textarea driving window.__HR_MOCK__. In a suite it takes a keyword list and pushes to all of them.Dev sandbox and mocking
Dev sandboxnpm run dev (:3001): your widget — or every widget of a suite, with the layout composed — mounted against an MSW-mocked HomeRunner API.Dev sandbox and mocking
dist-relative nameThe normalised form every consumer reduces an asset reference to: everything after the last dist/, else the basename.Keywords, assets and URLs
Elevated portalA portal target with its own shadow root at document.body, for dialogs the widget’s own box would clip. Needs its tokens passed in.Portals and dialogs
Embed snippetThe copyable HTML for a standalone placement: the runtime script, your bundle, and a host div carrying the keyword.Runtime, mount and the DOM contract
Enforcement levelOne of exactly four: publish ERROR, publish warning, silent runtime truncation, advisory (unvalidated). Every rule in the book carries one.Limits and error index
{env} prefixprod, dev or local — the mandatory first segment of every published URL. An example URL without it is wrong and 404s.Keywords, assets and URLs
Evergreen runtimeSince 0.10.0 React, React DOM, React Query and @homerunner-next/widget-core/runtime are externalized onto window.HRWidgetRuntime, so platform mount fixes reach your published bundle with no rebuild.Runtime, mount and the DOM contract
expectedHeightAnti-CLS hint in CSS pixels on a widget summary. Emitted as data-hr-min-height for CSR-only widgets and baked into embed snippets.Manifest: widget summary
externalFetchThe host allow-list your server bundle may fetch beyond HomeRunner’s public API. Unvalidated at publish; enforced only in the sandbox.Component and SSR module
Failure nodeThe hidden data-hr-widget-error breadcrumb the renderer substitutes for a widget that could not render. It degrades the page; it does not break it.Component and SSR module
FeedOne customer site’s content scope. Plugins are installed per feed, and every widget row belongs to exactly one.Install, customers and kill switches
filter.propertyThe property the page (SSR) or the layout (CSR) pushes into every widget’s settings, so a slot child knows which listing it is on.Composing a page
Fixturesmocks/fixtures.ts — per-endpoint overrides for the dev sandbox’s generated data, keyed by endpoint. In a suite the widget factory is what gives each mounted widget its own keyword.Dev sandbox and mocking
getFinalSettingsThe platform’s merge that produces the settings blob your component receives. Precedence chain and __parentColorScheme live with it.Settings and options
getInitialDataOptional SSR export whose return value becomes props.data and is replayed to the browser. Must be JSON-serialisable.Component and SSR module
getStaticAssetsOptional SSR export returning {css, js}. Prefer omitting it — everything it returns is re-resolved, and exporting it drops your declared fonts from <head>.Component and SSR module
Hashed twinThe {base}-{sha8}{ext} copy the build writes beside every dist/ file, recorded in the build manifest and served immutably.Keywords, assets and URLs
hr-widget-buildThe SDK’s build CLI: validates every summary, then runs the (widget × pass) matrix and finalises the manifests. Never run bare vite build on a suite. (0.11.0+)SDK reference
HRPluginswindow.HRPlugins[registryKey] = {component, category?} — the page-global registry your client bundle writes to.Runtime, mount and the DOM contract
HRWidgetRuntimeThe page-level global the runtime IIFE installs: React, React DOM, React Query, widgetCore and the baked base URLs your bundle externalizes against.Runtime, mount and the DOM contract
HydrationAttaching React to server-rendered markup instead of re-creating it, using the props and query cache the server serialised.How a widget renders
IIFE bundleYour client bundle — dist/{slug}.iife.js (v1) or dist/{widget}/{widget}.iife.js (suite).Keywords, assets and URLs
Install / UninstallThe customer’s per-feed lifecycle. Install is Activate; Uninstall is refused while widgets built from your plugin still exist.Install, customers and kill switches
KeywordThe single string that addresses a widget everywhere: plugin:{slug} (v1), plugin:{slug}:{widget} (suite), bare for a system widget.Keywords, assets and URLs
Kill switchOne of three subtractive switches that withhold your code: platform suspension, platform per-widget, per-feed per-widget. Nothing is deleted from the CDN.Install, customers and kill switches
Layout shellThe layout’s own markup around its slots. On a server-rendered page it is static light DOM with no shadow root and no mount — never interactive.Layout widgets
Layout widgetA suite widget with category: "layout" that hosts other widgets in named slots. Needs an SSR bundle, a slots config field and a registered category. (0.11.0+)Layout widgets
LayoutWidgetPropsWidgetProps plus renderedSlots. Treat widgetType and target as optional — SSR supplies neither.Layout widgets
ManifestRoot manifest.json — the registration document central caches and ships with every render.Manifest: root fields
Manifest shapeSingle-widget (v1) or suite (widgets[]). The discriminator is one line, and the choice is frozen at first publish.The two manifest shapes
Manifest URLWhere your published manifest lives: {cdn}/{env}/{slug}/{version}/manifest.json. The {env} segment is never optional.Keywords, assets and URLs
MarketplaceThe Marketplace tab of a feed’s Plugins page — the only catalogue customers see. There is no global plugin browser.Install, customers and kill switches
Media referenceA media/<file> path in the manifest (icon, cover, screenshots, readme). The rules differ per field, and media/ resolves only after a publish.Manifest: root fields
mediaSafeAutoOpts a widget into parse-time auto scheme stamping. It is a promise about your CSS that nothing validates. (0.12.0+)Manifest: widget summary
mount(container, config)The runtime entry your bootstrap calls. Selects [data-hr-widget="{keyword}"] inside the container, hydrates or client-renders each match. Returns void.Runtime, mount and the DOM contract
Namespaced keywordThe suite form plugin:{pluginSlug}:{widgetSlug}, split at the first colon.Keywords, assets and URLs
options (raw)The settings blob your component receives. It is never zod-parsed for plugins, on either side — call parseWidgetConfig yourself, everywhere.Settings and options
options-updatedThe DOM event the dashboard preview dispatches to re-render a mounted widget with new settings.Runtime, mount and the DOM contract
Page assignmentThe per-feed mapping (Feed → Page URLs → Page layouts) of a server-rendered page to a layout. Absent means Automatic.Layout widgets
Page layoutA layout currently assigned to one of a feed’s server-rendered pages, rendered with stored bindings only.Layout widgets
pagesThe layout-only summary field declaring which pages the layout may be assigned to. (0.12.0+)Layout widgets
parseWidgetConfigThe SDK call that strips null leaves, coerces stringified scalars, lifts legacy spacing and applies your schema. The only real gate on stored settings.Config schema and UI schema
PlaygroundThe dashboard page for one widget instance: settings form, live preview, embed code and — for a layout — the Slot Configuration panel.Config schema and UI schema
PluginOne React component (or up to 24 of them) plus SSR modules and a manifest, submitted as a source zip and published by HomeRunner. You host nothing.Anatomy of a plugin
PreflightThe checks you run yourself before zipping — including the reviewer-only gates you would otherwise fail blind.Preflight and submit
prefillPer-slot list of system widget keywords the dashboard binds at creation time, so a new layout opens populated. Creation-time only. (0.12.1+)Layout widgets
PresetA root-level one-click composition: a layout, its children and their slot bindings, created atomically from the widget picker. Max 8. (0.11.0+)Layout widgets
Props registrywindow.HRWidget.__WIDGET_PROPS__, keyed by data-hr-ssr-id — the exact props the server rendered with, replayed at hydration.Runtime, mount and the DOM contract
Public API/public-api/v1 — the read-mostly HomeRunner API your widget fetches from, client-side and inside the sandbox.Public API
PublishThe admin action that uploads the built zip under a new version prefix, verifies it, repoints the live manifest, then warms and purges. It also un-suspends.Publishing, versions and rollback
registerPlugin / registerPluginWidgetThe two spellings of the same registry write. registerPluginWidget(p, w, C, meta) is registerPlugin("{p}:{w}", C, meta). (suite form 0.11.0+)Runtime, mount and the DOM contract
Registry keyEverything after the plugin: prefix — {slug} or {slug}:{widget}. The key you register under and the key mount looks up.Runtime, mount and the DOM contract
renderedSlotsThe pre-rendered children a layout receives per slot. SSR omits an empty slot’s key entirely; CSR emits []. Always write renderedSlots?.[name] ?? null.Layout widgets
resolvedThemeThe light/dark prop your component receives. On a server-rendered page an auto widget is handed light and never corrected — branch in CSS, not on this prop.Styling and theming
ReviewA human read of your source zip ending in approved, changes requested or rejected. Nothing notifies you — poll My plugins.From your laptop to a customer page
RollbackRepointing the live version at an earlier published prefix. It does not un-suspend and it cannot target a pre-0.10.0 build.Publishing, versions and rollback
runtime.widgetCoreThe widget-core version your build stamps into the manifest. Generated output — never hand-write it. Publishing requires ≥ 0.10.0.Manifest: root fields
Scaffoldnpx create-hr-plugin <slug> — the non-interactive project generator. --template single (default) writes a v1 project, --template suite a three-widget suite with a layout. Both ship a dev sandbox and an SSR preview.Install and versions
ScenarioOne of the dev sandbox’s six data states — default, empty, single, large, loading, error. Config endpoints are never gated by it.Dev sandbox and mocking
settings.slotsRecord<slotName, widgetId[]> stored on the layout instance — the customer’s bindings, in render order. Not part of the manifest.Layout widgets
Shadow rootThe isolated DOM tree each content widget renders into. Also where your stylesheet is linked, and why @font-face and Radix’s a11y probes behave oddly.Runtime, mount and the DOM contract
shadowDOMRoot-level manifest flag, whole-plugin, no per-widget override. It must agree with what your bootstraps pass to mount().Manifest: root fields
Shared query clientThe single React Query client a rendered page owns. Prefetch into it from dehydrateState; the ["widget", …] and ["feed", …] key spaces are the platform’s.Fetching data
SlotA named region a layout declares in slots[] and renders from renderedSlots.Layout widgets
Slot bindingOne widget id stored under a slot name. Bindings pointing at deleted widgets are dropped at render; nothing prunes them.Layout widgets
Slot editorThe Slot Configuration panel the dashboard injects into a layout’s own form, over your zod slots field.Composing a page
SlugYour plugin’s id, used verbatim as the CDN slug, the keyword stem and the /p/ path segment.Manifest: root fields
Source zipWhat you submit: manifest, package.json, src/, widgets/, media/ — never dist/, node_modules/ or .git/.Packaging and publishing rules
spacingThe base schema’s compound margin/padding/width/maxWidth field (0.9.0+). It replaced the old width: {maxWidth, unit} pair; apply it with resolveWidgetSpacingStyle.Config schema and UI schema
SSRServer-side rendering: the renderer evaluates your SSR bundle, runs the data hooks, and embeds the markup and dehydrated cache into the page.How a widget renders
SSR bundleThe CommonJS file at dist/[{widget}/]{name}-ssr.umd.js. The umd in the name is historical.Component and SSR module
ssr-entry.tsThe re-export barrel the SSR pass compiles. It exists iff the widget’s summary declares ssr.url.Component and SSR module
SSR previewnpm run dev:ssr (:3003) — a local harness for the server path, one widget at a time. A suite names it: --widget {slug} (0.12.2+). It is not the renderer’s envelope; know the divergences before trusting it.Previewing SSR locally
Sub-manifestwidgets/{slug}.manifest.json — one suite widget’s configSchema + uiSchema, generated by your own inject-schema script, fetched by the dashboard only. (0.11.0+)Config schema and UI schema
SubmissionYour source zip on Dashboard ▸ Plugins ▸ My plugins, and the five-state timeline it moves through. Re-submitting replaces whatever is in flight.From your laptop to a customer page
SuiteThe modern manifest shape: up to 24 widgets under one version, one review, one publish. New plugins should start here, even with one widget. (0.11.0+)The two manifest shapes
Suspensionstatus = suspended — the platform-wide kill switch. /p/ 404s every file and every instance becomes a failure node.Install, customers and kill switches
Symbolic component tokenThe uiSchema component name the dashboard resolves to a real form control. The canonical list — and what each must be paired with — is one table.Config schema and UI schema
System widgetA first-party HomeRunner widget, addressed by a bare keyword (gallery, reviews) with no plugin: prefix; its assets are served from /w/, not /p/.Keywords, assets and URLs
uiSchemaThe symbolic UI declaration beside your configSchema. It covers your own fields only; a top-level entry replaces the platform’s control wholesale.Config schema and UI schema
usePortalContainerHook returning the portal node inside your shadow root — the default target for dialogs and tooltips.Portals and dialogs
useShadowHostHook returning your widget’s host element (the [data-hr-widget] div).Portals and dialogs
useTopLevelPortalHook returning a portal target outside every shadow root, for dialogs the widget’s own box would clip. Pass it your token CSS.Portals and dialogs
Version prefix{env}/{slug}/{version}/ — the immutable key prefix every published object lives under. Bumping the version changes the prefix, not the /p/ URL.Keywords, assets and URLs
Visibilitypublic (every account) or private (granted accounts only). Chosen by the reviewer; you cannot set or read it.Install, customers and kill switches
viteHomerunnerWidgetThe Vite preset from @homerunner-next/widget-core/vite that supplies plugins, define, build and server. Drop it into vite.config.ts.SDK reference
vm sandboxThe node:vm context the renderer evaluates your SSR bundle in: seven shared modules, stubbed browser globals, bounded execution. Not a security boundary.Component and SSR module
/w/The system-widget asset path (including runtime.iife.js). Plugin assets never live here; the scaffold’s local server only mirrors the shape.Keywords, assets and URLs
WidgetOne renderable unit on a page. Plugin widgets and system widgets share the same runtime, DOM and settings contracts.Anatomy of a plugin
Widget iconOptional per-widget icon (media/<file> or an absolute URL) shown on picker cards and the plugin page; falls back to the root icon, then a glyph. (0.12.0+)Manifest: widget summary
Widget slugA widget’s id, unique inside its plugin, ^[a-z0-9][a-z0-9-]*$, never dist / widgets / media. It names the dist/ directory and the keyword tail.Manifest: widget summary
Widget summaryOne object in widgets[] — the whole per-widget contract the renderer ever sees. (0.11.0+)Manifest: widget summary
WidgetPropsThe props every widget component receives. Import it from /contracts rather than hand-rolling it; what each path actually supplies differs.Component and SSR module
widgetSchemaThe nine-field base zod schema every widget extends (spacing, colorScheme, filter, lightModeColors, darkModeColors, font, language, section, customCss). The platform renders none of them for you.Config schema and UI schema
widgetTypeA required, non-empty single-widget root field whose value is read by nothing. Routing uses the plugin id.Manifest: root fields

Numbers, caps and verbatim error strings are not repeated here — they live in Limits and error index, and symptoms are indexed in Troubleshooting.