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:
- Scaffold one —
npx create-hr-plugin <slug> --template suite(create-hr-plugin 0.8.0+). The next section. - 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:
| Widget | Slug | Category | SSR | Why it is there |
|---|---|---|---|---|
| Page Frame | page-frame | layout | required | Declares slots with prefill, renders renderedSlots, declares pages |
| Intro Card | intro-card | content | yes | The ordinary server-rendered content widget |
| Quick Facts | quick-facts | content | no | CSR-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:
| File | Why it needs no change |
|---|---|
vite.config.ts | It never names an entry. viteHomerunnerWidget({ slug, command }) reads the entry from the environment. |
scripts/inject-schema.mjs | It already has a multi-widget branch — step 4 below. |
tsconfig.json | include 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
| Path | Fate |
|---|---|
src/index.tsx, src/widget.tsx, src/config.ts, src/widget.css | move into src/widgets/{slug}/ |
src/ssr-entry.ts | replace with a per-widget one, and only for widgets that server-render |
src/ssr.ts | move 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.json | rewrite (step 1) |
index.html, src/dev/, mocks/fixtures.ts | replace 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.json | replace 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.js | keep — 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.ts | delete — a flat /w/ shim, and a demo-only one |
package.json scripts build:iife, build:ssr | delete — they are the bare vite build a suite refuses |
package.json scripts dev:ssr, serve, preview | keep, if you took the harness with them |
vite.config.ts, scripts/inject-schema.mjs, tsconfig.json | no 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
| File | Required? | Notes |
|---|---|---|
index.tsx | yes | hr-widget-build refuses to start if any declared slug has no src/widgets/{slug}/index.tsx. |
config.ts | in practice yes | build:manifest imports it by exact path and exits 1 if it is missing. |
widget.tsx | convention | Any filename works; index.tsx and ssr-entry.ts import it. |
{slug}.css | optional | The reference suite imports it from both index.tsx and widget.tsx. |
ssr-entry.ts | iff ssr.url | One 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.tsximports./components/ui; fromsrc/widgets/forecast/that becomes../../components/ui. - The
@/*alias is TypeScript-only.tsconfig.jsonmaps@/*→./src/*, but nothing adds the matching Viteresolve.alias, so@/components/uitype-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": falseis a deliberate, explicit value. Omittingssrentirely is a publish error — it is not the same asfalse.hr-widget-buildskips the SSR pass for that widget, sodist/snapshot/never gets a-ssr.umd.js, and anssr-entry.tsleft in the folder is simply never built. The reverse also fails: declaressr.urlwith nossr-entry.tsand the vite pass dies on a missing entry.- On a server-rendered page the renderer emits an empty host div carrying
data-hr-min-heightfromexpectedHeight. Set it, or the page reserves nothing and shifts when your bundle paints (Manifest: widget summary). - A
category: "layout"widget must declaressr.url. The publish audit acceptsssr: falseon 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:
- It validates every summary before any pass runs — slug shape, the reserved names
dist/widgets/media, duplicate slugs, and the presence ofsrc/widgets/{slug}/index.tsx. Each failure exits non-zero with its own message (Limits and error index). - It deletes
dist/once. Per-widget passes then run withemptyOutDir: false, so running a single pass by hand leaves stale output from a previous build. - 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 withBUILD_WIDGET={slug}(andBUILD_SSR=1on SSR passes). - It marks the last pass
HR_BUILD_FINALIZE=1. Only that pass hashesdist/, writes the shareddist/manifest.json, and stampsruntime.widgetCoreinto your tracked root manifest. Expectmanifest.jsondirty 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
settingsobject are a page singleton innpm run dev:src/dev/main.tsxseedssettingswith 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 atitleand 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:ssrrenders 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:ssrrenders 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 asrenderedSlots, and no local harness does that.npm run devcomposes 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 buildis clean,dist/manifest.jsonexists, andmanifest.jsonnow carries aruntime.widgetCorestamp.- Every declared
assets.js/assets.css/ssr.urlpath matches whatdist/actually contains, including the{widget}/directory. widgets/*.manifest.jsonis committed, and so is everymedia/file you reference.- No
dist/,node_modules/or.git/in the zip;package-lock.jsonis present. - Every widget declaring
ssr.urlhas anssr-entry.ts; every"ssr": falsewidget has none and has anexpectedHeight.
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 — the capability only a suite has.
- Manifest: widget summary — every
widgets[]field. - Composing a page — presets, slots and page assignment.
- Troubleshooting — build failures, empty config panels, blank widgets.