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.optionsarrives RAW — on the server and in the browser. Nothing schema-parses it for you. CallparseWidgetConfig(configZod, props.options ?? {})yourself, in your component and ingetInitialData,dehydrateStateandgetStaticAssets. 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
parseWidgetConfigcall 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.
| Artifact | Cause | What you get back |
|---|---|---|
"" becomes null | ConvertEmptyStringsToNull 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 list | A record field the customer emptied arrives as [], not {} |
| Scalars arrive stringified | Older blobs and feed-level writes survive a JSON round trip as text | font.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:
- Feed branding —
colorScheme, the light and dark accents,font.size,font.family. - 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. widget.settings— the stored blob.optionsOverride— the per-embeddata-hr-optionspayload, 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. __parentColorSchemeis 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 pickeddarkkeepsdark. Never declare it in your schema or read it as a setting; see Layout widgets.colorSchemealways resolves to something concrete.global,nulland absent all mean “inherit”: the pushed parent scheme wins, else feed branding, else the terminal defaultlight— notauto. A visitor’s OS preference is honoured only when someone explicitly choseauto.
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 claim | Reality |
|---|---|
“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
- Config schema and UI schema — base
widgetSchemafields, round-trip rules,parseWidgetConfigstep by step. - Component and SSR module —
WidgetProps, the SSR exports, and what a parse throw does on each path. - SDK reference — signatures for
parseWidgetConfig,getFinalSettingsand/spacing. - Layout widgets —
settings.slotsand the pushed payload. - Limits and error index — the enforcement-level legend used above, every hard number, and every message.
- Troubleshooting — “my widget renders blank”.