Fetching data
Your widget fetches in two places: inside the renderer’s server sandbox during SSR, and in the
browser after it mounts. Both talk to the same HomeRunner public API, both share one React
Query cache per page, and the whole point of doing the server half is that the browser half
does not repeat it. One rule holds them together: the key you prefetch under on the
server must be the key you read on the client. Everything else here is detail around that.
It all applies to both manifest shapes; the one shape-dependent rule is where you declare
externalFetch.
The base URL
Never hard-code a Central origin. The same plugin bytes are served to production, staging and
local rigs, so the base URL is resolved at runtime by homerunnerApiBaseUrl() from
@homerunner-next/widget-core/runtime, in this order:
window.HRWidgetRuntime.apiBaseUrl— baked into the env-matched runtime IIFE the host page loads. This is what answers on every real customer page and in the dashboard playground.process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URL— Vite-injects it from your.env.localduringnpm run dev, and the renderer whitelists it into the SSR sandbox’sprocess.env.- A hard fallback origin.
No host sniffing, nothing baked into your build. For local work, copy .env.example to
.env.local and point that variable at whichever Central has your test data. The verbatim
implementation and the fallback value live in Public API.
A fetcher you can actually ship
Put your fetchers and your query keys in one file so the server and client halves cannot
drift. This is the shipped featured-stays fetcher, corrected:
// Corrected from hr-plugins/featured-stays/src/data.ts:30-45 (real, published).
// Three changes: `limit` (the shipped file sends `per_page`, which /feed/properties
// ignores), a WidgetFetchError instead of a bare Error, and an abort timeout.
import { homerunnerApiBaseUrl, WidgetFetchError } from "@homerunner-next/widget-core/runtime";
export async function fetchProperties(
feedId: number,
opts: { limit?: number; platform?: string } = {},
): Promise<PropertiesResponse> {
const url = new URL(`${homerunnerApiBaseUrl()}/public-api/v1/${feedId}/feed/properties`);
if (opts.limit) url.searchParams.set("limit", String(opts.limit));
if (opts.platform) url.searchParams.set("platform", opts.platform);
const res = await fetch(url.toString(), { signal: AbortSignal.timeout(8000) });
// Read the STATUS before parsing: a 429 can be answered by a gateway and need
// not be JSON, and the status is what the retry predicates classify on.
if (!res.ok) throw new WidgetFetchError(res.status, `properties: HTTP ${res.status}`);
return res.json();
}
export const queryKeys = {
properties: (feedId: number, limit: number, platform?: string) =>
["featured-stays:properties", feedId, limit, platform] as const,
};
Why each change matters:
- Page-size parameter names differ per endpoint family.
per_pageis silently ignored on the property endpoints and you get the default page — three of the four published reference plugins ship that bug. The per-endpoint table is in Public API. AbortSignal.timeoutmatters most on the server. The vm timeout bounds only synchronous top-level work, so a hanging fetch insidedehydrateStateholds the whole page render.- Throw
WidgetFetchError, notError. It carriesstatusas an own property, which is what both the browser and the server retry policies read.
Route image URLs from the API through getProxiedImageUrl(url, width, height) from
@homerunner-next/widget-core/utils, passing real dimensions — it defaults to 64 x 64 and
its normalisation rules are in Public API.
The SDK’s fetch vocabulary
Available from @homerunner-next/widget-core/runtime (0.10.0+, so present in the version on
npm today): homerunnerApiBaseUrl, fetchFeed, fetchWidget, useFetchWidget,
WidgetFetchError, isRateLimitError (429), isPermanentError (any 4xx), retryTransient,
and WidgetErrorState for a visible failure. From /utils: getFeedQuery, getWidgetQuery,
getProxiedImageUrl. Signatures are in SDK reference.
retryTransient(max) returns a TanStack retry callback that retries a network error or a 5xx
and never a 4xx; max counts retries, not attempts, so retryTransient(1) makes at most
two requests.
Two names the old guide used do not exist in widget-core and will not resolve:
okOrThrow and retryUnlessRateLimited. Both live in HomeRunner’s private packages.
Not supported yet.
fetchPropertyBySlugexists in widget-core — it is how the CSR layout renderer validates a pushed property once instead of 404-ing in every slot — but it is not exported from/runtime. Call/public-api/v1/property-detailsyourself withfeed_idandproperty.
The shared query client
Every widget on a page — first-party and plugin alike — renders inside one React Query
client. In the browser it is the getSharedQueryClient() singleton on window.HRWidget, and
it is created with these defaults:
| Default | Value | Why it matters to you |
|---|---|---|
staleTime | 5 minutes | Data is fresh for five minutes after it was fetched. |
refetchOnMount | false | A widget never refetches on mount, even when its data is stale. |
refetchOnWindowFocus, refetchOnReconnect | false | Tabbing back or coming back online does nothing. |
retry | retryTransient(3) | Up to three retries for network/5xx, none for a 4xx. |
refetchOnMount: false is the load-bearing one. Server-rendered pages are cached for hours
(see Limits and error index), so by the time a visitor
loads one the hydrated dataUpdatedAt is usually far past staleTime — and nothing refetches
anyway. Hydrated SSR data is what the visitor sees until they navigate. If your widget
genuinely needs live data, override it on your own query (refetchOnMount: "always", an
interval, an explicit refetch()) and pay for it against the shared rate budget knowingly.
Get the client with useQueryClient(), not by calling getSharedQueryClient() inside your
component: on the server that function returns a fresh throwaway client and anything you write
to it is discarded.
The renderer’s server-side client is a different object with different defaults —
staleTime: Infinity, and a retry policy that does retry a 429 (with backoff) because a
page’s parallel prefetch burst can trip the limiter and bake skeleton markup into a cached
page. It classifies errors by reading error.status structurally before falling back to
instanceof, which is the second reason to throw WidgetFetchError.
Query-key discipline
The dehydrated state carries queryKey, queryHash and the data — never your queryFn.
Hydration matches on the hash, which is JSON.stringify of the key with object keys sorted.
So:
- Build the key from one factory imported by both
ssr.tsandwidget.tsx. A key assembled twice by hand is a key that will drift. - Every element counts.
["k", 42, 6, undefined]hashes as["k",42,6,null], which is not["k", 42, 6]. Dropping a trailing argument on one side is the classic silent double-fetch. Keep keys to primitives. - Namespace with your plugin slug —
["pdp-suite:stay-hero", feedId, …]. The cache is page-wide, so an un-namespaced["properties", 42]can collide with another vendor’s widget and silently serve their data. - The
["widget", …]and["feed", …]key spaces belong to the platform. The renderer pre-seeds them before your hook runs and the CSR layout renderer writes them too. Do not write to them. See Component and SSR module.
Parse before you build a key. ctx.options arrives RAW in dehydrateState, exactly as it
does in your component, so a key built from raw options can differ from the key the parsed
component asks for — schema defaults, null/"" save artifacts and lifted legacy values all
move the value. Run parseWidgetConfig(configZod, ctx.options) first, on both sides. The rules
are in Config schema and UI schema.
The SDK’s own helpers disagree with each other and are not interchangeable:
useFetchWidget(feedId, widgetId) caches under ["widget", feedId, widgetId],
getWidgetQuery(widgetId) under ["widget", widgetId], and getFeedQuery(feedId) under
["feed", feedId]. Pick one per resource and use it on both sides.
Prefetch on the server, reuse in the browser
dehydrateState(queryClient, ctx) runs before your component renders on the server. Write into
the client it hands you; the renderer serialises the whole cache into the page and
getSharedQueryClient() rehydrates it before the first widget mounts.
// hr-plugins/featured-stays/src/ssr.ts:10-25 (real, published) — unchanged
// shape; the fetcher it calls now sends `limit`.
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { fetchProperties, queryKeys } from "./data";
import { configZod, type PluginConfig } from "./config";
export async function dehydrateState(
queryClient: { setQueryData: (key: unknown, data: unknown) => void },
ctx: { options: Record<string, unknown>; feedId: number; widgetId: string },
) {
// Parse RAW stored settings with the component's own schema — the prefetch
// key must equal the key the hydrated component asks for.
const options = parseWidgetConfig(configZod, ctx.options) as PluginConfig;
const limit = options.limit ?? 6;
const platform = options.filter?.platform ?? undefined;
try {
const res = await fetchProperties(ctx.feedId, { limit, platform });
queryClient.setQueryData(queryKeys.properties(ctx.feedId, limit, platform), res);
} catch (err) {
console.error("[featured-stays] dehydrateState failed:", err instanceof Error ? err.message : err);
}
}
The component makes the ordinary call, and on a server-rendered page it resolves from the hydrated cache with no request:
// hr-plugins/featured-stays/src/widget.tsx:39-45 (real, published) — the same
// key factory, the same arguments, derived from the same parsed options.
const limit = o.limit ?? 6;
const platform = o.filter?.platform ?? undefined;
const q = useQuery({
queryKey: queryKeys.properties(props.feedId, limit, platform),
queryFn: () => fetchProperties(props.feedId, { limit, platform }),
});
prefetchQuery({queryKey, queryFn}) is the fire-and-forget alternative: it swallows the
failure and caches an errored query. setQueryData inside a try/catch — what all four
published reference plugins do — is the shape where you decide what a failure looks like,
at the cost of an uncaught throw removing the widget.
Either way, only successful queries are dehydrated — TanStack’s default
shouldDehydrateQuery is status === "success". A query that errored on the server ships no
data, so your component renders its no-data branch into the HTML. If that branch is a spinner,
crawlers get a spinner; return a real empty state instead. The renderer warns and marks the
page degraded, which collapses its cache lifetime; failure semantics are normative in
Component and SSR module.
getInitialData or dehydrateState?
Use dehydrateState. It is the one that gives you a single code path.
getInitialData(ctx) returns a value that becomes props.data, and the renderer ships it to
the browser in the page’s props registry, so hydration does not refetch it either. But
props.data is only ever populated on the SSR path: on the CSR path mount() passes
data={undefined} explicitly, so a CSR-only widget, a hand-written customer embed and the
dashboard playground all get nothing and you write the fetch twice anyway. dehydrateState +
useQuery behaves the same everywhere — cache hit after SSR, live fetch on CSR, one component
code path.
Reach for getInitialData only for a small non-query value the server render cannot proceed
without, and handle it being undefined. Both receive the same three-key ctx, normative in
Component and SSR module. Do not use initialData
on your useQuery to paper over the difference; it competes with the hydrated entry.
What is already in the cache when you hydrate
On a server-rendered page the renderer seeds the widget config before any plugin code runs, so
useFetchWidget and getWidgetQuery resolve without a network call. One trap comes with it:
the seeded feed record is a projection, not the API response. To keep operator fields out
of the page source, the renderer seeds only id and additional_info — under ["feed", id]
and as the feed half of the ["widget", feedId, widgetId] pair. On a CSR page those keys
hold whatever /details actually returned. So a component that reads feed.name works when
you test it as a standalone embed and renders blank on a real server-rendered page. Read feed
data only from additional_info (branding, route config), or fetch it under your own key.
Rate limits, and what a 429 does to a page
The public API is rate-limited per client IP across every endpoint, and on a
server-rendered page every widget shares the renderer’s IP — one cold property page can spend a
dozen calls before your widget’s first request. The number, the 429 envelope and the fact
that you cannot read your remaining budget are in Public API.
- Do not retry a 429 in the browser.
retryTransientalready refuses to, because retrying while throttled only stretches a visible loading state. Do not override it. - Detect it and say so.
isRateLimitError(error)is true only for a 429; render a temporary, retry-later state —WidgetErrorStatefrom/runtimegives you an amber one. - Treat
isPermanentError(error)as a misconfiguration, not a blip: a 404, a disabled or archived feed, a bad id. Stop spinning and render a final state. - Batch on the server. One wide
/feed/propertiescall indehydrateStatecosts the page one request; N per-property calls from N widgets cost N.
A 429 during SSR is retried by the renderer; if it still fails the widget ships no-data markup
and the page is degraded. In the browser a failed top-level config fetch also stamps the host
element — the data-hr-error vocabulary is in
Runtime, mount and the DOM contract.
Third-party hosts: externalFetch
In the browser there is no restriction — an ordinary fetch under ordinary CORS reaches
any host that allows you. In the SSR sandbox fetch is wrapped in an allowlist. Central’s
host is always reachable; every other host must be declared in the widget’s externalFetch
array — on the widgets[] entry for a suite, at the manifest root for a single-widget
plugin. Undeclared hosts, and private / loopback / link-local / cloud-metadata hosts even when
declared, throw before the socket opens; redirects are re-validated at every hop up to a cap.
The block messages, the host versus host:port form and the cap are in
Component and SSR module; the field row is in
Manifest: widget summary.
Nothing validates externalFetch at publish time (advisory (unvalidated)), so a typo’d
host survives review and is discovered on a live page. The block throws inside your hook, so
you choose the outcome: catch it for optional enrichment and render without the extra data;
let it propagate for primary content, and the widget is replaced by a failure breadcrumb
rather than rendering something wrong.
Two sandbox behaviours to design around:
- Successful GETs are cached in-process for a short window (duration in
Limits and error index), keyed by URL alone. Your
server-side data can be that stale, and two widgets requesting the same URL make one
request.
?dev=1renders bypass the cache but stay fully enforced — you see a block locally, not in review. - A cached GET loses its response headers. The wrapper rebuilds the
ResponsewithContent-Typeonly, so header-driven logic (ETags, rate-limit counters, pagination links) is unavailable server-side on a normal render.
Mutations
Writes run client-side only. Use useMutation; the shared client’s retry default applies to
queries, not mutations, so a mutation makes exactly one attempt unless you say otherwise.
// Corrected from the previous data-fetching page, which called an undefined
// `apiBase()`. The write surface is the quote/reservation flow — see
// contracts/public-api.md for bodies and the reservation envelope.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { homerunnerApiBaseUrl, WidgetFetchError } from "@homerunner-next/widget-core/runtime";
const queryClient = useQueryClient();
const createQuote = useMutation({
mutationFn: async (payload: QuotePayload) => {
const res = await fetch(`${homerunnerApiBaseUrl()}/public-api/v1/quotes`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new WidgetFetchError(res.status, `quote: HTTP ${res.status}`);
return res.json();
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["my-plugin:availability"] }),
});
invalidateQueries refetches any active subscriber immediately, which is the supported way
to refresh data past the refetchOnMount: false default. Never send cookies or credentials —
the API is supports_credentials: false and would reject them.
Developing against this offline
npm run dev intercepts your fetches with a mock service worker so you can drive empty, large,
loading and error states without a live feed, and npm run dev:ssr runs your SSR bundle the
way the renderer does, dehydrateState included. Both have real limits — the mock worker is a
page singleton bound to one keyword, and the SSR dev server renders one widget per run (a suite
names it: npm run dev:ssr -- --widget {slug}, widget-core 0.12.2+), never a composed page. See
Dev sandbox and mocking and
Previewing SSR locally.
Related
- Public API — endpoints, parameters, pagination, envelopes, the rate limit.
- Component and SSR module — SSR exports,
ctx, the sandbox,externalFetch, failure semantics. - SDK reference — signatures for every helper named here.
- Config schema and UI schema —
parseWidgetConfig. - How a widget renders — where the two halves sit in the pipeline.
- Composing a page — how a layout pushes
filter.propertydown to the slot children that fetch with it.