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

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:

  1. 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.
  2. process.env.NEXT_PUBLIC_HOMERUNNER_BASE_URL — Vite-injects it from your .env.local during npm run dev, and the renderer whitelists it into the SSR sandbox’s process.env.
  3. 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_page is 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.timeout matters most on the server. The vm timeout bounds only synchronous top-level work, so a hanging fetch inside dehydrateState holds the whole page render.
  • Throw WidgetFetchError, not Error. It carries status as 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. fetchPropertyBySlug exists 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-details yourself with feed_id and property.

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:

DefaultValueWhy it matters to you
staleTime5 minutesData is fresh for five minutes after it was fetched.
refetchOnMountfalseA widget never refetches on mount, even when its data is stale.
refetchOnWindowFocus, refetchOnReconnectfalseTabbing back or coming back online does nothing.
retryretryTransient(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.ts and widget.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. retryTransient already 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 — WidgetErrorState from /runtime gives 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/properties call in dehydrateState costs 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=1 renders 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 Response with Content-Type only, 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.