Previewing SSR locally
npm run dev proves your widget works in a browser. It proves nothing about the server.
On a customer page the renderer evaluates your built SSR bundle inside a node:vm
sandbox with no DOM, calls your data hooks there, and ships the resulting HTML before any
client code runs — see How a widget renders.
npm run dev:ssr runs that path on your laptop. It is a port of the renderer’s loader,
shipped in the SDK’s /testing subpath (widget-core 0.8.0+, so the npm latest build has
it too — Install and versions). Three things
it gained recently matter enough to gate every claim below. In 0.12.2: it previews one
named widget of a suite, and it stopped diverging from the renderer’s sandbox in five
places. In 0.12.3: it links a stylesheet for a widget that exports no getStaticAssets,
which before then previewed unstyled.
Both shapes. A single-widget project needs nothing but a build. A suite has no default widget, so you name the one to preview — see Suites: one widget at a time. What has no local equivalent, in either shape, is a layout’s server-side slot composition.
Running it
npm run dev:ssr is tsx scripts/dev-ssr.mts, which calls createSSRDevServer from
@homerunner-next/widget-core/testing. It reads dist/ and never compiles anything,
so a build must come first.
# packages/create-hr-plugin/template/package.json and template-suite/package.json —
# the same "dev:ssr": "tsx scripts/dev-ssr.mts" script in both shapes.
npm run build # Single-widget: dist/{slug}-ssr.umd.js + .iife.js + .css
npm run dev:ssr # Single-widget — serves http://localhost:3003
npm run build # Suite: dist/{widget}/{widget}-ssr.umd.js per widget
npm run dev:ssr -- --widget intro-card # Suite — name the widget to preview
Skip the build and every scenario tab renders a red SSR render failed: panel naming the
missing bundle by its dist-relative path; the exact string, and the four boot-time
selection errors below, are in the error
index.
Naming a widget — Suite
The selection is resolved before the server starts, against the root manifest.json’s
widgets[], so an unusable choice fails at boot with a message naming the widgets that do
work — never a red panel on every scenario tab. The suite scaffold’s scripts/dev-ssr.mts
runs that lookup itself and prints the message before exiting 1; call createSSRDevServer
yourself and it throws the same string. Three ways to make the selection, highest
precedence first (widget-core 0.12.2+):
| How | Example |
|---|---|
--widget {slug} in argv | npm run dev:ssr -- --widget intro-card |
--widget={slug} in argv | npm run dev:ssr -- --widget=intro-card |
HR_SSR_WIDGET | HR_SSR_WIDGET=intro-card in .env.local |
Four conditions fail instead of booting: naming no widget at all, naming one the manifest
does not declare, naming one on a single-widget project (there is nothing to name), and
naming a CSR-only widget — a summary carrying "ssr": false ships no SSR bundle, so
there is nothing to preview. That last one is the correct outcome, not a fault; verify a
CSR-only widget in the sandbox instead.
Everything the selection drives comes from that one lookup: the keyword
(plugin:{slug}:{widget}), the nested dist/{widget}/{widget}-ssr.umd.js, .iife.js and
.css bundles, and the widget’s declared externalFetch hosts
(Manifest: widget summary). A single-widget project
resolves the flat plugin:{slug} keyword and dist/{slug}-* files exactly as it always
has.
Routes: / redirects to /preview?scenario=default, which shows the scenario tabs, the
rendered envelope and an inspector (raw server HTML, the props your component received,
the dehydrated React Query cache). /__render.json?scenario=… returns the same render as
JSON — html, props, dehydratedState, assets — which is the scriptable one.
/dist/* and /mockServiceWorker.js are static, and /p/{anything}/{file} is a local
stand-in for the plugin asset proxy served out of dist/
(Keywords, assets and URLs).
The SSR bundle is re-read from disk on every request, so npm run build in another
terminal plus a refresh picks up new code with no restart. The mock handlers, the widget
selection and the seeded settings are fixed at boot — changing a config.ts or your
fixtures needs a restart.
Configuration
scripts/dev-ssr.mts calls process.loadEnvFile(".env.local") then
process.loadEnvFile(".env"), each in a try/catch so a missing file is fine. Node’s
loader never overwrites a variable that is already set, which gives you this precedence
(verified on Node 22): shell environment › .env.local › .env › the defaults below.
--widget is the only CLI flag the script reads; npm run dev:ssr -- --hydrate still does
nothing.
| Key | Default | Effect |
|---|---|---|
PORT | 3003 | Server port |
HR_SSR_WIDGET | unset | Suite only. Which widget to preview, when you do not pass --widget (0.12.2+) |
HR_SSR_HYDRATE | unset | 1 turns on client hydration; anything else is server-render only |
HR_RUNTIME_URL | ${assetBaseUrl}/w/runtime.iife.js, hydrate mode only | The runtime IIFE that defines window.HRWidgetRuntime. In server-only mode it stays undefined and is never used |
NEXT_PUBLIC_HOMERUNNER_BASE_URL | https://beta.homerunner.io | Exposed inside the sandbox as process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URL, and to the client as HRWidgetRuntime.apiBaseUrl |
NEXT_PUBLIC_WIDGET_ASSET_BASE_URL | https://assets-dev.homerunner.io | Exposed as process.env.NEXT_PUBLIC_WIDGET_ASSET_BASE_URL; also the origin HR_RUNTIME_URL defaults from |
.env.example covers most of that, but not all of it and not uniformly. Both templates
ship the two NEXT_PUBLIC_* keys uncommented, as live values, plus HR_SSR_HYDRATE and
HR_RUNTIME_URL commented out; the suite’s file adds a commented HR_SSR_WIDGET, which the
single-widget one has no use for. PORT is in neither file — set it in the shell or add
it to .env.local yourself. The two base URLs barely matter for data: the mock
handlers match any origin, so the value only shows up if you read it in your own code.
Scenarios, and the loading downgrade
The tabs are the same six scenarios the browser sandbox uses; their meanings and the
fixture keys are in Dev sandbox and mocking. Two behaviours
are specific to this server. loading is silently downgraded to default for the server
render — the loading handler never resolves, so an un-downgraded server render would
hang forever instead of producing HTML. The real scenario is restored immediately
afterwards, so in hydrate mode the client sees loading over a populated server render.
And latency defaults to 0 ms here, not to the sandbox’s default.
Settings come from configZod.parse({}) at boot — the whole default object — so
options.<field> reads never crash. In a suite that is the selected widget’s own
src/widgets/{widget}/config.ts, not the page-wide union npm run dev has to seed, so
there is no cross-widget field collision here. See the
divergences for why pre-parsed settings are
also a trap.
Hydrate mode
Set HR_SSR_HYDRATE=1 and the page additionally loads, in document order: the dehydrated
cache as window.HRWidget.__REACT_QUERY_STATE__, the runtime IIFE from runtimeUrl, a
config block setting HRWidgetRuntime.apiBaseUrl and pointing proxyBaseUrl at
window.location.origin, then the selected widget’s client IIFE — dist/{slug}.iife.js
for a single-widget project, dist/{widget}/{widget}.iife.js for a suite. Your mount()
finds the server markup, builds a shadow root, migrates the SSR content into it and hydrates
(Runtime, mount and the DOM contract). Because
proxyBaseUrl is the local server, CSS resolves through the local /p/ route, not the CDN.
Two things to know before you trust it. Hydrate mode is not offline — the runtime IIFE
comes from the asset origin, and with hydrate on and no runtimeUrl resolvable the page
prints an amber warning and shows the server HTML only. And client-side requests are not
mocked: the page registers mockServiceWorker.js directly, but MSW only intercepts for
clients that have sent it a MOCK_ACTIVATE message, which only startMockWorker() does,
and no handlers are registered in the page at all. Every fetch your component makes after
hydration goes to the real apiBaseUrl with the harness’s placeholder ids.
The server render is always resolvedTheme: "light" unless you pass branding. For a dark
one, add branding: { colorScheme: "dark" } to the mock option in
scripts/dev-ssr.mts — the harness derives the theme from nothing else.
What the preview genuinely reproduces
renderPluginSSR is a port of the renderer’s plugin-federation.ts loader, kept in the
SDK so it stays versioned with the contract. Faithfully reproduced:
- The same vm contract — your UMD is IIFE-wrapped and evaluated in a fresh
node:vmcontext, with the same “no default export” check and the same module-scope execution timeout (0.12.2+), whose value is in the limits index. - The same
requirewhitelist — all seven specifiers, including@homerunner-next/widget-core/runtime(0.12.2+) — refusing everything else with the same message. An import your build did not bundle fails here. - The same browser-global stubs for
window,document,self,navigator,location,localStorageandsessionStorage— inert proxies, not real objects. A top-levelwindow.fooread survives; a call that expects a real DOM does not. And the same absences: noTextEncoder, noTextDecoder, nocrypto(0.12.2+ removed the two encoders the harness used to seed and the renderer never had). - The same
process.envwhitelist (three keys), the same__HR_PLUGIN_ASSET_BASE__global for bundled assets (0.12.2+), and the same call order: evaluate →getInitialData→dehydrateState(freshQueryClient, retries off) →getStaticAssets→renderToString, samectxshape. Component and SSR module owns that contract. - The same
externalFetchenforcement (0.12.2+) — the widget’s declared hosts are read out ofmanifest.jsonand the sandboxfetchis wrapped in a port of the renderer’s allow list, so a call to an undeclared host fails here instead of first in review. An absent field declares nothing, exactly as in production. - The same hydration tree (0.12.2+) — the server render runs inside
WidgetHydrationTreewithidentifierPrefixset to the SSR id, and the preview envelope carries that id indata-hr-ssr-id, somount()hydrates with the same tree and the same prefix (Runtime, mount and the DOM contract). - Mocked server-side
fetch.msw/nodepatchesglobalThis.fetchand the sandbox reads through to it at call time. Unmatched requests are bypassed to the real network, not failed. - The same stylesheet fallback (0.12.3+) —
getStaticAssetsis optional, and the renderer serves the widget’s manifestassetswhen a bundle omits it. The harness takes the same branch: it links the widget’s declaredassets.css(or, absent one, the conventionaldist/name) when that file is actually indist/, links nothing when it is not, and defers togetStaticAssetsverbatim whenever the export is there — including when the export returns an empty list, which production also honours. The fallback reachesdata-hr-csstoo, so hydrate mode is styled as well. Below 0.12.3 there was no fallback, so a hook-less widget — the shape--template suitescaffolds — previewed with an empty<head>and no CSS in either mode.
Where the preview differs from production
Take this seriously: code can pass here and fail on the renderer, and the reverse.
Component and SSR module carries the summary
table; what follows is what each difference does to you. Everything here is measured
against widget-core 0.12.3 — five divergences that used to live on this list (the
missing /runtime shared module, seeded TextEncoder/TextDecoder, an undefined
__HR_PLUGIN_ASSET_BASE__, unenforced externalFetch, no execution timeout) were closed
in 0.12.2, and a sixth (no stylesheet for a widget without getStaticAssets) in 0.12.3.
On an older SDK they all still apply.
__HR_PLUGIN_ASSET_BASE__points at your laptop. It is defined, but ashttp://localhost:{port}/dist/— the renderer derives it from the bundle’s CDN URL ({origin}/{env}/{slug}/{version}/dist/). Bundled-asset URLs therefore have the right shape here and the wrong origin. Check the real ones against Keywords, assets and URLs.- The
externalFetchallow list has one local loophole. Private, loopback and metadata hosts are refused even when declared — except when yourapiBaseUrlis itself loopback, because a fully local rig’s whole world is loopback. Point the harness at a local Central and a private host you declared will succeed here and be blocked in production. - Options are pre-parsed. The scaffold seeds settings from
configZod.parse({}), so every field is present and correctly typed. Customers’ stored settings arrive raw. A component or data hook that skipsparseWidgetConfigcannot fail here and will fail there. Paste real settings JSON intomock.widgetSettingsto reproduce. - The envelope is close, not identical. It carries
data-hr-ssr-id— the part hydration depends on — but ships the props as adata-hr-ssr-propsattribute where production writes a page-level props registry, and it never emits a declarative shadow root. Server-only mode has no shadow root at all and links your stylesheet into the harness page’s own<head>, so:hostrules match nothing and your styles can bleed onto the harness chrome. None of that is a bug in your widget — and that last part is useful rather than misleading: it is the closest local view of the unisolated, document-level condition every server-rendered page puts your CSS in (Styling and theming). - Data is fixtures, not a feed. Unmatched requests bypass to the real network, and in hydrate mode the browser’s own fetches are not mocked at all (see above). A shape your fixtures get wrong is a shape you have not tested.
- There is no server-side slot composition. A layout renders its shell with empty
slots — the platform, not this harness, renders the bound children and passes them as
renderedSlots. See Suites: one widget at a time.
Suites: one widget at a time
A suite puts each widget’s bundle at dist/{widget}/{widget}-ssr.umd.js under the
namespaced keyword plugin:{slug}:{widget}, and createSSRDevServer resolves both from the
widget you name (widget-core 0.12.2+; the scaffold’s suite dev:ssr script needs
create-hr-plugin 0.8.1+):
# packages/create-hr-plugin/template-suite/scripts/dev-ssr.mts — what the script accepts
npm run build
npm run dev:ssr -- --widget intro-card # → http://localhost:3003
npm run dev:ssr -- --widget=page-frame # the layout shell, slots empty
HR_SSR_WIDGET=intro-card npm run dev:ssr # or set it in .env.local
Run it once per widget. There is no “preview the whole suite” mode, and there does not need to be: every SSR question except composition is per-widget.
The one thing you cannot preview is a composed page. renderPluginSSR builds the props
itself and passes no renderedSlots, so page-frame renders its shell with every slot
empty. That is still a useful smoke test — a layout that throws during render 500s the
whole customer page (Layout widgets) — but it is not a preview
of the composed result. npm run dev exercises the client half of layout composition
(Dev sandbox and mocking); the server half needs a real feed.
If you want a composed render locally, build it yourself, in two steps. renderPluginSSR
gives you each child’s HTML; evaluatePluginSSR gives you the layout’s module, which you
render with react-dom/server yourself, passing those children as your own renderedSlots.
Only the second step needs the hand-rolled call — renderPluginSSR builds its props
internally and has no renderedSlots field, so it can never do this. The two lookups the dev
server boots with are exported for exactly this (widget-core 0.12.2+):
// packages/homerunner-widget-core/src/testing/ssr.ts (renderPluginSSR, evaluatePluginSSR),
// src/testing/dev-server.ts (resolvePluginSSRTarget) and src/contracts.ts, which types
// `renderedSlots` as Record<string, ReactNode>. Save as scripts/ssr-compose.mts and run it
// with tsx after `npm run build`.
import fs from "node:fs";
import React from "react";
import { renderToString } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
renderPluginSSR,
evaluatePluginSSR,
resolvePluginSSRTarget,
createNodeMockServer,
} from "@homerunner-next/widget-core/testing";
import { setMockState } from "@homerunner-next/widget-core/mock";
const SLUG = "acme-suite";
const LAYOUT = "page-frame";
const FEED_ID = 1;
// Your own binding table. On a real page this comes from the customer's stored page
// schema; nothing here checks it, so a slot name your layout does not declare renders
// nothing at all.
const BINDINGS: Record<string, string[]> = { main: ["intro-card"] };
// Same lookup createSSRDevServer boots with: keyword, dist paths, declared hosts.
function resolve(widget: string) {
const { target, error } = resolvePluginSSRTarget({
rootDir: process.cwd(), slug: SLUG, widget,
});
if (!target) throw new Error(error); // CSR-only, undeclared, or no widgets[]
return target;
}
// 1. Render every slot child on its own — the full production order, per widget.
const renderedSlots: Record<string, React.ReactNode> = {};
for (const [slot, widgets] of Object.entries(BINDINGS)) {
const nodes: React.ReactNode[] = [];
for (const widget of widgets) {
const target = resolve(widget);
const mock = createNodeMockServer(target.keyword, {
widgetId: widget, feedId: FEED_ID, delayMs: 0,
});
setMockState({ scenario: "default" }); // never "loading" — that handler never resolves
const { html, ssrId } = await renderPluginSSR({
ssrCode: fs.readFileSync(`dist/${target.ssrFile}`, "utf-8"),
filename: target.ssrFile,
ctx: { options: {}, feedId: FEED_ID, widgetId: widget },
resolvedTheme: "light",
externalFetch: target.externalFetch,
});
mock.close();
// Each child reaches the layout as its own host element, not a bare string.
nodes.push(React.createElement("div", {
key: widget,
"data-hr-widget": target.keyword,
id: `${widget}:${FEED_ID}`,
"data-hr-ssr-id": ssrId,
dangerouslySetInnerHTML: { __html: html },
}));
}
renderedSlots[slot] = React.createElement(React.Fragment, null, ...nodes);
}
// 2. Evaluate the LAYOUT in the same sandbox and render the shell yourself.
const layout = resolve(LAYOUT);
const mod = evaluatePluginSSR(
fs.readFileSync(`dist/${layout.ssrFile}`, "utf-8"),
"https://beta.homerunner.io",
"https://assets-dev.homerunner.io",
{ filename: layout.ssrFile, externalFetch: layout.externalFetch, pluginAssetBase: "" },
);
const ctx = { options: {}, feedId: FEED_ID, widgetId: LAYOUT };
const layoutMock = createNodeMockServer(layout.keyword, {
widgetId: LAYOUT, feedId: FEED_ID, delayMs: 0,
});
setMockState({ scenario: "default" });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const data = mod.getInitialData ? await mod.getInitialData(ctx) : undefined;
await mod.dehydrateState?.(queryClient, ctx);
// No WidgetHydrationTree and no identifierPrefix here, deliberately: on a server-rendered
// page the shell is static light DOM whose mount is a no-op, so there is no hydration to
// line up with (Layout widgets covers that).
console.log(renderToString(
React.createElement(QueryClientProvider, { client: queryClient },
React.createElement(mod.default, { ...ctx, resolvedTheme: "light", data, renderedSlots }),
),
));
layoutMock.close();
Every divergence above still applies, and the settings are whatever you pass in ctx.options
— {} is not what a customer sends. Three things this deliberately does not reproduce: the
keys a layout pushes down onto each child, and the Declarative Shadow DOM template and
props-registry entry every real slot child gets
(Layout widgets).
An SSR checklist
Run before you zip — Preflight and submit has the rest.
A content widget with an SSR bundle — Both. Every scenario except loading renders
non-empty markup; error paints your error state rather than throwing. The inspector’s
Props panel shows the options you expect, and the dehydrated cache is non-empty if you
export dehydrateState. Nothing in the terminal says window is not defined. Fetch
/__render.json?scenario=default twice and diff: any difference means a non-deterministic
render (a clock read, a random value), which costs you the SSR DOM on hydration.
A CSR-only widget ("ssr": false) — Suite. Nothing to preview and no ssr-entry.ts to
build; naming it exits 1 and that is the expected outcome. Verify it in
the sandbox instead, and set expectedHeight
(Manifest: widget summary) so the empty server host
reserves the right space.
A layout widget — Suite. SSR is mandatory, and --widget {layout} previews the shell
with empty slots — enough to prove it evaluates, renders and does not throw, which is the
failure that 500s a whole customer page. It is not a preview of the composed result. Give
every slot and every config field a default, keep the shell non-interactive, and
class-namespace the CSS: on a server-rendered page the shell is light DOM and its
stylesheet is document-level.
Seeing what the server actually did on a real page
Once your plugin is live on a feed, three signals answer “which layer served this, and when”.
Development mode turns off caching for one feed. The feed owner enables it from the
dashboard for a time-boxed window; the Cloudflare worker then appends ?dev=1 to its
render request, and the renderer serves fresh, writes nothing to cache and returns
Cache-Control: no-store… plus X-HR-Dev-Mode: 1. Ask for it while you iterate —
otherwise you are looking at a cached page and cannot tell a stale render from a broken
one. The sandbox’s server-side GET cache is bypassed on those renders too (still enforced),
per the limits index.
The stamp. Every fresh render appends an HTML comment to the compiled page —
<!-- hr-render page=… build=… manifest-age=…s rendered-at=<ISO> key=… -->. View-source
and read the last one; a page-level stamp follows every widget. A cached page
deliberately keeps the stamp of the render that produced it, so an old rendered-at is
the staleness evidence.
The headers, on the render API response (the JSON the worker fetches, not the customer
page): x-hr-render-cache is hit-fast, hit, miss or bypass (bypass = development
mode), x-hr-render-key is the compiled-page key, and x-hr-render-build /
x-hr-render-at are read out of the stamp inside the payload being served — so on a hit
they describe the original render. curl -sD - -o /dev/null answers all four.
If a widget is missing from the page entirely, search the source for
data-hr-widget-error: the renderer leaves a hidden node carrying an HTML-comment reason
where the widget should have been. Troubleshooting maps those
reasons to causes.
Related
- Dev sandbox and mocking — scenarios, fixtures, the dev panel
- Component and SSR module — the sandbox contract
- Runtime, mount and the DOM contract — hydration and shadow DOM
- SDK reference —
/testingsignatures - Limits and error index — every bound and message quoted here