Data bindings & repeaters

{{item.*}} interpolation, dataSource scope, client refetch via stateInputs.

Repeaters, connector queries, and {{variable}} interpolation. Everything you need to make a section iterate over Stripe products, customer orders, or a nested array on its parent item — and to drop live values into Text, Buttons, Images, and Links.

Looking for the full TypeScript type? See DataSource in useDataSource.tsx and the resolver in variables.ts. This doc covers what each field is for and when you'd reach for it.


The smallest binding that works

Wrap repeating children in a Data node, point it at a provider/collection, then use {{item.*}} inside its descendants:

{
  "type": "Data",
  "props": {
    "dataSource": { "provider": "stripe", "collection": "products", "limit": 12 }
  },
  "nodes": ["card_id"]
}

Inside card_id (or anything under it):

{ "type": "Text", "props": { "text": "<span data-variable=\"item.title\" class=\"variable-node\">{{item.title}}</span>" } }
{ "type": "Image", "props": { "src": "{{item.image}}", "alt": "{{item.title}}" } }
{ "type": "Button", "props": { "text": "Buy {{item.title}}", "action": [{ "type": "link", "href": "ref:page_product/{{item.slug}}" }] } }

That's it. The SSR scanner fetches the collection, hydrates each child with one item, and the resolver swaps in the values.


A fully wired Data node

Everything Data can carry, in one block:

{
  "type": "Data",
  "props": {
    "dataSource": {
      // ─── Pick a source ─────────────────────────────────
      "provider": "stripe",              // or "customer", or any host-registered provider
      "collection": "products",
      "filter": { "demo": "true" },
      "ids": ["prod_abc", "prod_def"],   // optional pinning
      "sort": "newest",                  // "newest" | "oldest" | "alpha" | "price_asc" | "price_desc"
      "offset": 0,
      "limit": 12,

      // ─── Stable binding id (multi-section pages) ───────
      "bindingKey": "demo-products",

      // ─── Nested repeater off the parent item ──────────
      // (Mutually exclusive with provider/collection — pick one mode)
      "scope": "item.images",
      "splitBy": ",",                    // when scope resolves to a CSV string

      // ─── Client refetch wiring ─────────────────────────
      "stateInputs": {
        "q": "url:q",
        "category": "url:category",
        "page": "url:page",
        "sort": "url:sort"
      },
      "publishStateKeys": {
        "totalPages": "connector:demo-products:totalPages",
        "totalCount": "connector:demo-products:totalCount",
        "page": "connector:demo-products:page",
        "count": "connector:demo-products:count"
      },

      // ─── Route param plumbing ──────────────────────────
      "ignoreUrl": false                 // set true to skip merging visitor query into this binding
    }
  }
}

Three modes, exactly one per node:

  1. Connector-backedprovider + collection. Fetched at SSR, refetched client-side when stateInputs change.
  2. Scopescope: "item.images". Iterates a path INTO the parent repeater item. No fetch.
  3. Comma-split scopescope + splitBy. Splits a delimited string into pseudo-items.

Where {{...}} resolves

Interpolation runs through replaceVariables. It is not universal — only these surfaces resolve:

WhereHow to write it
Text node props.textInline <span data-variable="item.title" class="variable-node">{{item.title}}</span> (matches TipTap output).
Button / Link textBare {{item.title}}. Item context is auto-injected.
Button / Link action.hrefBare {{item.slug}} — works in any href shape: ref:page_product/{{item.slug}}, /shop?id={{item.id}}, mailto:{{item.email}}.
Button / Link / Container / FormElement attrs valuesBare {{item.*}}. String values only.
Image src and altBare {{item.src}} / {{item.image}}.
FormElement placeholder / value / optionsBare {{...}}.

Does NOT resolve in: plain text inside Container children (write a Text node), arbitrary non-attrs props, or className. If you find yourself wanting {{item.x}} inside a className, you want a stateModifier or a different node shape.


Variable namespaces

All of these work everywhere interpolation runs. Pick by what you're reading:

PrefixResolves againstExample
item.*The current repeater item (set by the enclosing Data node).{{item.title}}, {{item.metadata.color}}
connector.*Global server data: connector.<provider>.bindings.<bindingId>.0.<field> — useful outside a repeater.{{connector.stripe.bindings.demo-products.0.title}}
state.*Central state registry. Splits on first . after the last :. JSON values walk deeper.{{state.url:page}}, {{state.pdp:abc:matching-variant.formatted}}
auth.*Site-customer auth (set via setAuthState in the host app).{{auth.status}}, {{auth.customer.email}}
company.*ROOT.props.company — falls back to a sensible default ("Acme Inc." etc.) so empty templates render.{{company.name}}, {{company.email}}
variables.*Custom variables on ROOT.props.variables[], or runtime overrides via window.PageHub.setVar(...).{{variables.brandName}}
anchor.*Wrapper-supplied anchor ids. Resolved in a pre-pass so nested forms like {{state.{{anchor.chat}}:title}} work.{{anchor.chat}}
yearCurrent year (no prefix needed).{{year}}

Unresolved item.* / connector.* / auth.* / variables.* render as empty string (not the literal {{...}}) so pre-hydration repeater skeletons look clean. Other unresolved tokens render as-is.


Path & fallback quirks (load-bearing)

The resolver uses walkPath which splits paths on . only. These rules trip people up:

Array indexing uses dot-digit, not brackets.

  • {{item.images.1}} — second image
  • {{item.images[1]}}does not resolve, renders empty

Same rule applies inside item-typed conditions: key: "images.1" works, key: "images[1]" does not.

|| fallback is one level deep, and the fallback is a literal string.

  • {{a || b}} → resolves a, or the literal text "b" if a is empty
  • {{a || b || c}} → resolves a, or the literal text "b || c" (the second || is not parsed)
  • The fallback is never resolved as another variable. There is no "fall back to item.image_2 if item.image_1 is missing" via ||.

For "fallback to another variable" (e.g. hover image → primary when only one image exists), add an item condition on the node — { type: "item", key: "images.1", operator: "exists" } — and hide it when the second image isn't there. Conditions use the same walkPath, so the dot-digit rule applies there too.

The resolver also understands a narrow ternary syntax — {{auth.status == logged-in ? Account : Sign in}} — with spaces required around the : to avoid clobbering ref: and https:. See replaceVariables for the exact regex.


Connector data shape

Server data lives under connector.<provider>.bindings.<bindingId>. bindingId defaults to a hash of (provider, collection, filter, sort, limit). Set dataSource.bindingKey: "demo-products" to pin a stable id when:

  • Multiple sections share the same fetch and you want one binding (not duplicated requests).
  • You want variable paths to stay stable across edits (connector.stripe.bindings.demo-products.0.title).

Pagination totals + hasMore live alongside on bindingsMeta. Read them via getBindingMeta(provider, bindingId) from the SDK, or via publishStateKeys (below).

Stripe storefront filter: filter: { demo: "true" } on storefront blocks is load-bearing — it segments storefront items from platform SKUs. Don't drop it. The MCP / API editor search at /api/v1/sites/.../stripe does NOT apply it (intentional: editor surfaces the full catalog).


Client refetch — stateInputs and publishStateKeys

Connector-backed Data nodes can subscribe to state keys and refetch when they change. This is how storefront filter forms, pagination chrome, and search inputs stay live without a page reload.

stateInputs — map fetch options to state keys:

"stateInputs": {
  "q": "url:q",                // search box state → fetch option "q"
  "category": "url:category",  // facet chip state → "category"
  "page": "url:page",          // pagination state → "page"
  "sort": "url:sort"
}

When any subscribed key changes, the connector calls the host-registered client fetcher with the merged snapshot. The URL ↔ state bridge (urlQueryBridge.ts, mounted by useViewerSetup) keeps URLSearchParams and state["url:*"] in lockstep automatically — so ?q=hat&page=2 round-trips through the registry without bespoke code.

publishStateKeys — write fetch metadata back into the registry after each fetch:

"publishStateKeys": {
  "totalPages": "connector:demo-products:totalPages",
  "totalCount": "connector:demo-products:totalCount",
  "page":       "connector:demo-products:page",
  "count":      "connector:demo-products:count"
}

Pagination chrome reads these via {{state.connector:demo-products:totalPages}} or gates visibility through state-type conditions (e.g. hide a "Next" button when connector:demo-products:page equals connector:demo-products:totalPages).

Scope-only dataSources (scope: "item.images") ignore both fields — there's no fetch to subscribe or publish.


Registering a client fetcher (host app)

For client-side refetch to work at all, your host app needs to register a fetcher once at boot. Same pattern as registerSubmissionHandler / registerMediaUploadHandler:

import { registerClientDataFetcher } from "@pagehub/sdk";

registerClientDataFetcher(async (provider, collection, options) => {
  // options carries the merged snapshot — { q, category, page, sort, ... }
  const res = await fetch(`/api/connectors/public-data?provider=${provider}&collection=${collection}`, {
    method: "POST",
    body: JSON.stringify(options),
  });
  return res.ok ? (await res.json()).items : null;
});

See extensibility.md for the full signature and error semantics.


Repeater context — what item actually is

When SSR resolves a Data node, it hands each child render the matching item via ItemProvider. Inside that subtree:

  • {{item.*}} reads from that item.
  • A nested Data node with scope: "item.images" opens a new item context for its descendants (the parent item is no longer reachable through item.*).
  • Primitive scope values get wrapped as { id, value, title, slug, src, image } so the same templates work for arrays of strings and arrays of objects. Bind chip text via {{item.value}}, slugs via {{item.slug}}, image tiles via {{item.src}}.
  • Variable interpolation in Button attrs (e.g. data-product-id="{{item.id}}") resolves against the closest item ancestor — handy for click handlers that need to read which row got clicked.

Where to go from here