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

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.