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

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:

StepScriptWhat it does
1build: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.
2hr-widget-buildwidget-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.