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

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