{{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
DataSourceinuseDataSource.tsxand the resolver invariables.ts. This doc covers what each field is for and when you'd reach for it.
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.
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:
provider + collection. Fetched at SSR, refetched client-side when stateInputs change.scope: "item.images". Iterates a path INTO the parent repeater item. No fetch.scope + splitBy. Splits a delimited string into pseudo-items.{{...}} resolvesInterpolation runs through replaceVariables. It is not universal — only these surfaces resolve:
| Where | How to write it |
|---|---|
Text node props.text | Inline <span data-variable="item.title" class="variable-node">{{item.title}}</span> (matches TipTap output). |
Button / Link text | Bare {{item.title}}. Item context is auto-injected. |
Button / Link action.href | Bare {{item.slug}} — works in any href shape: ref:page_product/{{item.slug}}, /shop?id={{item.id}}, mailto:{{item.email}}. |
Button / Link / Container / FormElement attrs values | Bare {{item.*}}. String values only. |
Image src and alt | Bare {{item.src}} / {{item.image}}. |
FormElement placeholder / value / options | Bare {{...}}. |
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.
All of these work everywhere interpolation runs. Pick by what you're reading:
| Prefix | Resolves against | Example |
|---|---|---|
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}} |
year | Current 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.
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 emptySame 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}}→ resolvesa, or the literal text"b"ifais empty{{a || b || c}}→ resolvesa, 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_2ifitem.image_1is missing" via||.For "fallback to another variable" (e.g. hover image → primary when only one image exists), add an
itemcondition on the node —{ type: "item", key: "images.1", operator: "exists" }— and hide it when the second image isn't there. Conditions use the samewalkPath, 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.
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:
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/.../stripedoes NOT apply it (intentional: editor surfaces the full catalog).
stateInputs and publishStateKeysConnector-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.
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.
item actually isWhen SSR resolves a Data node, it hands each child render the matching item via ItemProvider. Inside that subtree:
{{item.*}} reads from that item.Data node with scope: "item.images" opens a new item context for its descendants (the parent item is no longer reachable through item.*).{ 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}}.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.href / text / conditional triggers → actions-and-handlers.md