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

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