Skip to content

Compose a record-backed catalog grid

Build a catalog as a grid container with a repeated card inside it. The card’s binding.repeat_over selects model records, binding.item_var gives each iteration a stable item name, and child content interpolates fields such as {{ item.name }}.

  • Create or look up the catalog model first and replace <model-id> with its real per-tenant UUID. Publish the model schema and configure the public read policy required by the page.
  • Under a publicAccessToken, if the server render did not supply block.runtime.records, a source: "model" repeat_over resolves to an empty list with no error. Include a visible empty state and verify that the published page server-renders records; a successful editor preview is not sufficient.
  • A per-record image must be a child image block interpolating content.src. record-list and data-table never render an image_url value as an <img>.
  • External per-record image origins require trusted_image_origins. It is default-closed, and set_trusted_image_origins replaces the complete allowlist rather than appending an origin.

The following tree has an outer grid and repeats one card container for each matching record. fields explicitly allows the interpolated values into each public runtime record. filter is part of the source and can be empty, as shown; replace it with the model’s supported field filter when narrowing the catalog.

{
"blocks": [
{
"id": "catalog-section",
"type": "container",
"display": {"layout": "flex", "direction": "column", "gap": 24, "full_width": true},
"style": {"class": "mx-auto max-w-6xl px-6 py-12"},
"blocks": [
{"id": "catalog-heading", "type": "heading", "content": {"text": "Catalog", "level": "h1"}},
{"id": "catalog-empty-state", "type": "text", "content": {"text": "No catalog items are available yet."}, "style": {"class": "text-muted-foreground"}},
{
"id": "catalog-grid",
"type": "container",
"display": {"layout": "grid", "columns": 3, "gap": 24, "full_width": true},
"blocks": [
{
"id": "catalog-card",
"type": "container",
"display": {"layout": "flex", "direction": "column", "gap": 12},
"style": {"class": "rounded-lg border p-4"},
"binding": {"item_var": "item", "repeat_over": {"source": "model", "model_id": "<model-id>", "fields": ["name", "description", "image_url"], "page_size": 24, "sort": "name", "order": "asc", "filter": {}}},
"blocks": [
{"id": "catalog-image", "type": "image", "content": {"src": "{{ item.image_url }}", "alt": "{{ item.name }}"}, "display": {"fit": "cover"}, "style": {"class": "h-48 w-full rounded-md"}},
{"id": "catalog-name", "type": "heading", "content": {"text": "{{ item.name }}", "level": "h2"}},
{"id": "catalog-description", "type": "text", "content": {"text": "{{ item.description }}"}}
]
}
]
}
]
}
]
}

Use the repeated card when the catalog needs images or custom composition. If a compact textual list is enough, record-list.content.columns is a comma-separated string split on ,, for example "name,description"; it is not a JSON array. data-table normalises columns, so do not transfer the record-list format assumption to it.

The existing repeat_over catalog example in docs/page-builder-blocks.md demonstrates interpolation and an image child, but leaves out prerequisites, the public-token empty state, and the record-list/data-table columns-format distinction supplied here.