Your first widget
Twenty minutes, end to end: scaffold a project, run it against mocked data, change its settings schema and its component, build it, and read what the build produced.
This tutorial produces a single-widget plugin — --template single, the CLI’s default.
The other shape, --template suite, is scaffolded the same way and covered in
03-build-a-suite.md; the component, schema and build steps below are
identical in both. The choice is frozen at your first publish — the last section says what
to do about that. This page also assumes a working toolchain; get one from
01-install-and-versions.md, which owns the Node floor, the React
pins and the dated note on what npm serves versus what the template pins.
1. Scaffold
# packages/create-hr-plugin/bin/create-hr-plugin.js — the real flags
# --template defaults to "single"; pass it explicitly if you like being explicit
npx create-hr-plugin acme-weather --author "Acme Corp"
cd acme-weather
The CLI never prompts; the full argv surface and every failure mode are in
01-install-and-versions.md. What matters here is what it derives
from the slug: the display name (Acme Weather), the component name (AcmeWeatherWidget), and
the keyword your widget is addressed by everywhere — plugin:acme-weather. The manifest’s id
is the slug; nothing re-slugifies it later.
The tree
acme-weather/
├── manifest.json # plugin identity + the v1 widget fields; the build rewrites it
├── package.json # scripts, and exact react / react-dom / react-query pins
├── vite.config.ts # viteHomerunnerWidget({ slug, command }) + your overrides
├── tsconfig.json # include is ["src/**/*", "vite.config.ts"] only
├── index.html # DEV sandbox page — not a production artifact
├── serve.js # zero-dep node:http static server (npm run serve / preview)
├── vercel.json + api/ # optional self-hosted demo shim; never needed to publish
├── mocks/fixtures.ts # mock data for npm run dev AND npm run dev:ssr
├── public/ # holds the MSW service worker
├── scripts/ # inject-schema.mjs (build:manifest), dev-ssr.mts (dev:ssr)
└── src/
├── index.tsx # client IIFE entry — registerPlugin() + mount()
├── widget.tsx # your React component (and getInitialData)
├── ssr.ts # dehydrateState + getStaticAssets
├── ssr-entry.ts # SSR bundle entry — re-exports widget.tsx + ssr.ts
├── config.ts # zod schema → manifest configSchema + uiSchema
├── widget.css # Tailwind entry + the .hr-widget-section styles
├── data.ts # sample public-api fetcher + a query-key factory
├── components/ui/ # shadcn-style: you own these, edit them freely
└── dev/ # dev sandbox entry + control bar — never published
Which of those are yours, which the build generates, and which never leave your laptop is laid out in ../concepts/anatomy-of-a-plugin.md.
2. Run the dev sandbox
npm run dev # http://localhost:3001
npm run dev is mocked by default. src/dev/main.tsx starts an MSW worker bound to
plugin:acme-weather, seeds the widget’s settings with configZod.parse({}) so every
options.<field> read is defined, turns React Query retries off so the error scenario shows
its error state instead of a long spinner, mounts every matching container, and renders a
control bar along the bottom for scenario, theme, latency and a live settings JSON editor.
?scenario=empty|single|large|loading|error picks the fixture shape at boot and ?mock=off
disables the worker. .env.local is optional — it is consulted only on the ?mock=off
path (and by npm run dev:ssr), so you never need a live HomeRunner feed to build the widget.
The full sandbox contract is in
../local-dev/dev-sandbox-and-mocking.md.
3. Add a setting
src/config.ts is the whole settings surface. You extend the shared base schema, and you
export a JSON Schema built from it:
// packages/create-hr-plugin/template/src/config.ts — the scaffold, with one field added
import { z } from "zod";
import { widgetSchema } from "@homerunner-next/widget-core/schema";
import {
zodToManifestSchema,
type PluginUiSchema,
} from "@homerunner-next/widget-core/plugin-schema";
export const configZod = widgetSchema.extend({
title: z.string().default("Acme Weather").describe("Widget title"),
units: z.enum(["metric", "imperial"]).default("metric").describe("Units"),
});
/** Symbolic UI for plugin-specific fields only. */
export const uiSchema: PluginUiSchema = {
title: { component: "TextInput" },
units: {
component: "Select",
optionLabels: { metric: "Celsius", imperial: "Fahrenheit" },
},
};
/** JSON Schema shipped in `manifest.configSchema`. */
export const configSchema = zodToManifestSchema(configZod);
export type PluginConfig = z.infer<typeof configZod>;
Three things are doing real work. widgetSchema brings in the base fields every widget shares
— spacing, colour scheme, fonts, section header, custom CSS and the rest — so the dashboard
renders those controls without you declaring anything. .default(...) and .describe(...) on
your own fields are what make the customer’s form open clean and self-explaining. And
uiSchema names only your fields: a plugin entry replaces a top-level key of the base UI
schema wholesale, so re-declaring a base key destroys that panel.
The base field list, the component vocabulary, and which zod constructs survive the round trip
to JSON Schema and back (versus which ones take the whole config panel down) are in
../contracts/config-and-ui-schema.md. Read the
round-trip tables before you reach for a union, a tuple or z.any().
4. Edit the component
src/widget.tsx is the component the browser and the server both render. The scaffold already
shows the two calls that are not optional:
// packages/create-hr-plugin/template/src/widget.tsx — the scaffold's real component,
// with <WidgetSection>, the new `units` setting, and the imported prop type
import React from "react";
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { resolveWidgetSpacingStyle } from "@homerunner-next/widget-core/spacing";
import type { WidgetProps } from "@homerunner-next/widget-core/contracts";
import { configZod, type PluginConfig } from "./config";
import { cn, WidgetSection } from "./components/ui";
import "./widget.css";
type WeatherData = { message?: string };
export default function AcmeWeatherWidget(
props: WidgetProps<PluginConfig, WeatherData>,
) {
// `props.options` is the RAW stored settings blob on BOTH server and client:
// parseWidgetConfig strips the dashboard's null/'' save artifacts, coerces
// stringified scalars and applies your defaults. Never read it directly.
const options = React.useMemo(
() => parseWidgetConfig(configZod, props.options) as PluginConfig,
[props.options],
);
const isDark = props.resolvedTheme === "dark";
return (
// The base schema's `spacing` renders on the outer element; the helper does
// its own defaulting and repair, so raw or parsed options both work.
<div style={resolveWidgetSpacingStyle(options)}>
<WidgetSection show={options.section.show} title={options.section.title} />
<div
className={cn(
"rounded-lg border p-4 font-sans",
isDark ? "border-zinc-700 bg-zinc-900" : "border-zinc-200 bg-white",
)}
>
<h3 className="mb-2 text-lg font-semibold">{options.title}</h3>
<p className="text-sm text-zinc-500">
{props.data?.message ?? "Loading…"} ({options.units})
</p>
</div>
</div>
);
}
WidgetSection comes from src/components/ui/ — shadcn-style components the scaffold copies
into your project, not a package import. Its real props are {show, title, className}; it
renders the heading only, is not a wrapper, takes no options, and returns null when show
is false or title is empty.
props.options arriving raw is the rule that catches most first-time authors, and it applies
to getInitialData and dehydrateState in src/ssr.ts too — parse there as well, or your
server-side query keys will not match the ones your hydrated component builds.
Two corrections to the scaffold, both worth making now. The template declares its own
WidgetProps interface inline; import the real one from
@homerunner-next/widget-core/contracts instead, because the inline copy omits fields. And
props.resolvedTheme is fine for a class swap after mount but is not something to key first
paint on — dark at first paint has to come from CSS. See
../contracts/component-and-ssr-module.md for the
full prop contract and the SSR module, and
../recipes/styling-and-theming.md for theming.
Save with npm run dev running: Vite hot-reloads the component, and the sandbox’s settings
editor pushes new options at the mounted widget without a rebuild.
5. Build
npm run build
build is npm run build:manifest && hr-widget-build — two steps, not the three-step
build:manifest → build:iife → build:ssr chain older documentation describes:
| Step | Script | What it does |
|---|---|---|
| 1 | build:manifest (tsx scripts/inject-schema.mjs) | Imports src/config.ts, writes configSchema and uiSchema into the tracked manifest.json, and logs manifest.json updated — configSchema: N properties, uiSchema: M field specs. |
| 2 | hr-widget-build | widget-core’s bin. For a single-widget project it runs two Vite passes, labelled iife build and ssr build. |
build:iife and build:ssr still exist in package.json, but build no longer calls them —
they are single-widget escape hatches. On a suite, a bare vite build refuses to run at all.
(hr-widget-build needs widget-core 0.11.0+.)
Expect manifest.json to be dirty in git after every build, and commit it. The build
rewrites that tracked file twice: build:manifest injects the schemas, and the final Vite pass
stamps runtime.widgetCore with the SDK version it built against. Never hand-write that stamp
— but do commit it, because the publish audit gates on it. See
../contracts/packaging-and-publishing.md.
Read dist/
# a real built single-widget plugin (hr-plugins/featured-stays); .map files omitted here
dist/
├── featured-stays.iife.js # client bundle
├── featured-stays.iife-cb11ab13.js # its content-hashed twin
├── featured-stays.css
├── featured-stays-d4188b32.css
├── featured-stays-ssr.umd.js # SSR bundle — CommonJS despite the name
├── featured-stays-ssr.umd-122b8a33.js
├── manifest.json # the BUILD manifest: {buildHash, generatedAt, files}
├── mockServiceWorker.js # built by an OLD scaffold — see the note below
└── mockServiceWorker-7b2c8364.js
Three files are the plugin: the client IIFE named in assets.js, the stylesheet in
assets.css, and the SSR bundle in ssr.url. The hashed twin beside each is what a customer
page actually loads, and dist/manifest.json is the plain-name → hashed-name map the renderer
and the asset proxy read (source maps are excluded from it but still ship). Filenames are fixed
by the SDK; renaming an output is a publish error. All normative in
../contracts/assets-and-urls.md.
Your own dist/ will not have those last two files. public/mockServiceWorker.js — the
dev-only MSW worker — used to be copied into dist/ by Vite on every production build, hashed
and recorded in the build manifest. (create-hr-plugin 0.8.0+) both templates’ vite.config.ts
pass publicDir: command === "build" ? false : "public", so the worker stays out of the build
while npm run dev is untouched. featured-stays was built before that fix; if your scaffold
predates 0.8.0, make the same one-line change.
6. Preview the server render
npm run build # dev:ssr reads dist/, so build first
npm run dev:ssr # http://localhost:3003 → redirects to /preview?scenario=default
scripts/dev-ssr.mts loads .env.local then .env, then runs your built SSR bundle through
the same node:vm contract the production renderer uses, against the same mocks, with a
scenario tab strip and an inspector showing the server HTML, the props and the dehydrated query
cache. HR_SSR_HYDRATE=1 additionally runs real client hydration and surfaces mismatch
warnings. Without a prior build it tells you the SSR bundle is missing.
It is close to production, not identical. What still differs is the environment around the sandbox — the envelope, the asset base, the pre-parsed settings — and each difference is enumerated in Where the preview differs from production.
npm run preview — the built bundles, no mocks
npm run preview is npm run build && node serve.js. serve.js is a zero-dependency
node:http server on port 3002 (not Express, and not port 3000) that serves
/manifest.json and /dist/* with Access-Control-Allow-Origin: *, answers /w/{file} with
a 302 to the hashed twin, and answers /p/{slug}/{file} from dist/ — the plugin-asset proxy
path your built IIFE resolves its stylesheet through.
(create-hr-plugin 0.8.0+) the page renders. index.html still loads the Vite dev entry
/src/dev/main.tsx, which does not exist under serve.js; its onerror boots the preview
path instead — the runtime IIFE first (your built bundle externalises React, React Query and
widget-core against window.HRWidgetRuntime), then proxyBaseUrl pointed at the local server,
then /dist/acme-weather.iife.js. Earlier scaffolds had no such fallback and rendered an empty
mount div.
There are no mocks on this path — it talks to whichever Central the runtime IIFE points at.
Before you use it, change the host div’s id="{widgetId}:{feedId}" in index.html to a real
pair, and point HR_RUNTIME_URL at the environment you are testing against. For mocked visual
work stay in npm run dev; for the server path use npm run dev:ssr.
curl -s localhost:3002/manifest.json | head -20
curl -sI localhost:3002/w/acme-weather.iife.js # expect 302 → /dist/<hashed>
Next: choose your shape, while it is still free
You now have a widget that renders, a settings form the dashboard can draw, an SSR bundle and a
built dist/. You have not created media/ (icons, cover, README — authored content nothing
generates) and you have not submitted anything; both belong to
../ship/preflight-and-submit.md.
One decision is still open, and only until your first publish. A plugin is either the single-widget shape you just built or the modern suite shape — one plugin, many widgets, one version, one review, one publish. A suite may hold a single widget, so “suite” is not the same as “big”. That shape is frozen at first publish: flipping it later passes submit and even passes review, then dies at publish with a shape-change rejection, and the only remedy is a brand-new slug — losing every stored keyword and every embed URL a customer has copied. Today it costs you a re-scaffold, or a file move.
- Ever going to ship a second widget, a layout or a preset? Start over with
npx create-hr-plugin <slug> --template suite, or convert the project you just built — both routes are in 03-build-a-suite.md. - Want the decision spelled out with its costs on both sides: ../concepts/manifest-shapes.md.