The base.loop module — iterates a loop entity source and renders its child variants per item. Powers post listings, product grids, related-articles sections, media galleries, anything that displays a collection.
Loop sources are pluggable: built-in sources (data.rows, site.pages, site.media) cover the universal store; plugins can register more via the SDK.
- Loop source registry:
loopSourceRegistryinsrc/core/loops/registry.ts. First-party sources self-register fromsrc/core/loops/sources/index.tsat boot. LoopEntitySourceshape:{ id, label, fields, filterSchema?, orderByOptions?, fetch, preview? }insrc/core/loops/types.ts.- The
base.loopmodule's children are variants — different per-item layouts (e.g. "Card", "Featured"). The walker round-robins across them as it iterates. - At publish time,
loopPrefetch.tscalls each loop'sfetch()and stores results on the render context. The walker is then purely synchronous. - Each iteration renders against a fresh
entryStacksnapshot ([...baseStack, item]) carried in a childRenderConfig; nodes inside the loop resolvecurrentEntry.<field>against that item via dynamic bindings. The stack is never mutated in place.
src/core/loops/
├── index.ts — public barrel: types + pageToLoopItem + filterPagesForLoop
├── types.ts — LoopItem, LoopEntitySource, LoopSourceField, LoopFetchResult, ...
├── registry.ts — LoopSourceRegistry singleton (`loopSourceRegistry`)
└── sources/
├── index.ts — register the three built-ins at boot
├── dataRows.ts — data.rows (any data_table)
├── sitePages.ts — site.pages (+ shared helpers re-exported via barrel)
└── siteMedia.ts — site.media
src/modules/base/loop/ — the base.loop module definition
src/core/publisher/renderLoop.ts — render-time walker (round-robin variants)
server/publish/loopPrefetch.ts — server-side pre-fetch before render
interface LoopEntitySource {
/** Namespaced id — 'data.rows', 'site.pages', 'site.media', 'acme.products' */
id: string
label: string
description?: string
/** Field metadata — what's available to dynamic bindings inside the loop. */
fields: LoopSourceField[]
/** PropertySchema of filter controls shown in the Properties panel. */
filterSchema: PropertySchema
/** Allowed `orderBy` values; first entry is the default. Each uses `id`, not `value`. */
orderByOptions: { id: string; label: string }[]
/** Server-side fetch — runs at publish time (and live at editor render time). */
fetch(ctx: SourceFetchContext): Promise<LoopFetchResult>
/** Editor canvas preview — returns synthesized items (no DB access). */
preview(ctx: SourcePreviewContext): LoopItem[]
/**
* Default `false`. Set `true` when the source returns data that varies per
* request (live API, time-of-day data). Loops using a request-dependent source
* become Layer C "holes" — the publisher emits a placeholder + a tiny client
* runtime fetches the rendered fragment lazily via `/_instatic/hole/<nodeId>`.
*
* A `requestDependent` (non-perVisitor) hole is rendered at request time and
* cached by Layer B per `(nodeId, query, publishVersion)`.
*
* Built-in sources (`data.rows`, `site.pages`, `site.media`) are
* publish-time-deterministic — leave this unset. Plugin sources that hit
* live external APIs should set it.
*/
requestDependent?: boolean
/**
* Default `false`. Implies `requestDependent`. Output varies per individual
* visitor (cookies, randomization). Bypasses the Layer B cache; `fetch()`
* runs on every page load. Use sparingly.
*/
perVisitor?: boolean
}
interface LoopSourceField {
id: string // 'title', 'slug', 'featuredMedia', ...
label: string
format?: 'plain' | 'html' | 'url' | 'media'
}
interface LoopItem {
id: string // unique within the loop result
fields: Record<string, unknown>
}
interface LoopFetchResult {
items: LoopItem[]
totalItems: number // total across all pages — used for hasMore + paginators
}Sources are stateless — they receive everything they need via the ctx argument. The publisher and editor can call fetch independently.
Iterates rows in any data_table. The user picks the table in the Properties panel; filters narrow by status, author, category-like fields, date.
fetch({ db, filter, orderBy, limit }) {
const rows = await listDataRows(db, filter.tableId, { ... })
return { items: rows.map(rowToLoopItem), total: rows.length }
}This covers blog posts, products, anything in the universal store.
Iterates pages in the site. Filters by template inclusion/exclusion.
Used by sitemaps, "All pages" indexes.
The source exports two helpers through the @core/loops barrel:
pageToLoopItem(page)— projects aPageto aLoopItem. Normalizes the slug to a leading-slash permalink (/index→/). Exposestitle,slug,permalink,isTemplate, andtemplateTableSlug.filterPagesForLoop(pages, filters)— appliestemplateOnly/excludeTemplatesfiltering.
Both the publisher (SitePagesSource.fetch / .preview) and the editor canvas hook (useLoopPreviewItems) import these from @core/loops — they never re-implement the logic. Parity is gated by src/__tests__/loops/sitePagesLoopItemParity.test.ts.
The author-facing fields list exposes only title, slug, and permalink. Internal fields (id, isTemplate, templateTableSlug) are present in LoopItem.fields for code paths that need them but are not offered in the binding picker.
Iterates media_assets. Filters by MIME type prefix.
Used by galleries.
Its author-facing fields list exposes filename, path/URL/source URL, MIME type, and upload date. Internal uploader ids stay in LoopItem.fields for code that needs them, but they are not binding-picker rows.
A plugin with loops.register registers a custom source via the SDK at activation. The source runs inside the QuickJS sandbox — it can use api.cms.storage.collection(...) to fetch plugin-owned data or fetch(...) (with network.outbound permission) for external APIs.
See docs/features/plugin-system.md and the loop-sources section.
Each source declares its filter and order options through filterSchema and orderByOptions. The editor's Properties panel renders the matching controls when a base.loop node is selected and its sourceId is set.
filterSchema: {
status: {
type: 'select',
label: 'Status',
options: [
{ value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' },
{ value: 'any', label: 'Any' },
],
defaultValue: 'published',
},
category: {
type: 'select',
label: 'Category',
options: [/* populated dynamically — see below */],
},
}
orderByOptions: [
{ value: 'publishedAt:desc', label: 'Newest first' },
{ value: 'publishedAt:asc', label: 'Oldest first' },
{ value: 'title:asc', label: 'Title A→Z' },
]The base.loop node carries props.filter: Record<string, unknown> and props.orderBy: string. The publisher passes them to fetch(ctx) as ctx.filter and ctx.orderBy.
A base.loop node has N child nodes, each a "variant". The walker round-robins across them:
Loop with 2 variants ('A', 'B') and 5 items:
Item 0 → variant A
Item 1 → variant B
Item 2 → variant A
Item 3 → variant B
Item 4 → variant A
Variants are useful for:
- Featured + standard — first item uses the "featured" variant, others use the "standard" variant.
- Heading + items — a heading variant that renders once between groups.
- A/B layouts — alternating layouts for visual variety.
A loop with one variant is the common case (every item uses the same layout).
renderLoop(loopNode, config, acc, renderNode):
│
├─→ prefetched = config.loopData.get(loopNode.id)
│ (results already resolved by loopPrefetch.ts at publish time)
│
├─→ variants = loopNode.children ← N variant subtrees
│ baseStack = config.templateContext.entryStack ← immutable snapshot
│
├─→ const out: string[] = []
│ for each (item, index) of prefetched.items:
│ variant = variants[index % variants.length]
│ childConfig = { ...config, templateContext:
│ { ...config.templateContext, entryStack: [...baseStack, item] } }
│ out.push(renderNode(variant, childConfig, acc)) ← fresh per-iteration snapshot
│
└─→ return out.join('')
Each iteration builds a new entryStack array ([...baseStack, item]) inside a fresh child config — there is no in-place push/pop on a shared array, so iterations are independent and a nested loop or VC ref in the body sees a stable per-item snapshot.
The renderNode callback is the publisher's normal walker — so a variant's subtree renders exactly like any other tree, including:
currentEntry.<field>bindings resolve against the iteration's item (the top of the per-iteration stack).- Nested loops can push a deeper item; the outer loop's item becomes
parentEntry. - VC refs inside variants render with their own slot fills, with
currentEntrystill pointing at the loop item.
See docs/features/publisher.md → "renderLoop" for the broader pipeline.
The walker is purely synchronous — async data (loop sources, media) is resolved up-front so the publisher doesn't have to await per node.
server/publish/loopPrefetch.ts:
// collectLoopNodes uses walkRenderTree (server/publish/renderTreeWalk.ts) so
// base.loop nodes inside Visual Component definition trees are included —
// a loop inside a VC body is fetched and rendered with real data.
async function prefetchLoops(page, site, db) {
const loopNodes = collectLoopNodes(page, site) // descends page tree + all VC trees
const results = await Promise.all(
loopNodes.map(async (node) => {
const source = loopSourceRegistry.get(node.props.sourceId)
const result = await source.fetch({ db, filter: node.props.filter, ... })
return [node.id, result] as const
})
)
return new Map(results)
}The map is passed into RenderConfig.loopData. The walker reads from it; no async at render time.
In the editor, useLoopPreviewItems (src/admin/pages/site/canvas/useLoopPreviewItems.ts) provides loop iteration data for the canvas. It dispatches per source:
| Source | Canvas path |
|---|---|
data.rows |
GETs /data/tables/:id/loop-preview — same projection as the publisher. Falls back to synthetic items from the table's field definitions when no published rows exist yet. |
site.pages |
Reads pages from the in-memory site document via selectSitePagesLoopItems. Applies filterPagesForLoop + pageToLoopItem imported from @core/loops — identical to the publisher path. |
site.media |
Fetches via listCmsMediaAssets(), filters by MIME prefix, sorts + slices client-side. |
| Plugin sources | Calls source.preview(ctx) synchronously. |
The canvas caps preview results at 6 items (CANVAS_MAX_ITEMS) regardless of the loop's configured limit. Published pages render the full set.
Subscription granularity: the hook never subscribes to the whole site document for built-in sources. site.pages loops subscribe through selectSitePagesLoopItems, which keeps the items array (and each LoopItem) referentially stable across site mutations that don't change the loop's actual items — so typing in an unrelated text node doesn't re-render loop body subtrees. Only the plugin-source fallback subscribes to site (its preview() contractually receives the full document), and only while such a source is selected. Stability is gated by src/__tests__/loops/loopPreviewItemStability.test.ts.
- Insert a
base.loopnode into the page. - In the Properties panel, set
sourceId = 'data.rows', pick thedata_table(e.g. "Posts"). - Set filters (
status: published,category: 'tech'). - Set order (
publishedAt:desc). - Configure variants:
- Drop a
base.containeras the loop's first child — this is variant A. - Add nodes inside: a heading bound to
currentEntry.title, content bound tocurrentEntry.body, an image bound tocurrentEntry.featuredMedia.
- Drop a
- Publish. Each iteration renders the variant with the item's fields substituted.
The site-scope AI agent stays on the HTML-native edit surface. It calls list_loop_sources to get valid source ids, table ids, order options, and {currentEntry.field} tokens, then inserts an <instatic-loop> marker through insertHtml / replaceNodeHtml:
<instatic-loop data-source-id="data.rows" data-table-id="tbl_posts" data-order-by="publishedAt" data-direction="desc" data-limit="3">
<article>
<a href="{currentEntry.permalink}">
<img src="{currentEntry.featuredMedia}">
<h3>{currentEntry.title}</h3>
</a>
</article>
</instatic-loop>The HTML importer maps the marker to a real base.loop node, preserving classes and styles the same way it does for ordinary imported HTML. Token syntax is single-brace {currentEntry.field}; {{post.title}} and other alias-style tokens are not valid.
// plugin server/index.js
import { permissions } from '@core/plugin-sdk'
export function activate(api) {
const products = api.cms.storage.collection('products')
api.cms.loops.registerSource({
id: 'acme.products',
label: 'Acme products',
fields: [
{ id: 'name', label: 'Name', format: 'text' },
{ id: 'price', label: 'Price', format: 'number' },
{ id: 'image', label: 'Image', format: 'media' },
],
filterSchema: {
category: {
type: 'select',
label: 'Category',
options: [
{ value: '', label: 'All' },
{ value: 'new', label: 'New arrivals' },
{ value: 'clearance', label: 'Clearance' },
],
},
},
orderByOptions: [
{ id: 'createdAt:desc', label: 'Newest' },
{ id: 'price:asc', label: 'Price low → high' },
],
async fetch(ctx) {
const all = await products.list()
const items = all
.filter((p) => !ctx.filters?.category || p.category === ctx.filters.category)
.sort(/* by ctx.orderBy */)
.slice(0, ctx.limit)
.map((p) => ({ id: p.id, fields: p }))
return { items, totalItems: items.length }
},
preview(ctx) {
return [
{ id: 'preview-1', fields: { name: 'Example product', price: 99 } },
]
},
})
}Manifest:
{
"permissions": ["cms.storage", "loops.register"],
"resources": [{ "id": "products", "label": "Products", "fields": [...] }],
"entrypoints": { "server": "server/index.js" }
}Drop multiple children inside the base.loop node. The walker round-robins them. Use a small icon overlay or DOM-panel label to remember which variant is which.
Use two base.loop nodes side by side, one filtered by featured: true and the other by everything else. Loops can't merge results.
Two modes are available via the loop node's pagination prop:
pagination: 'none' (default) — renders up to limit items at publish time. No load-more affordance.
pagination: 'infinite' — renders the first pageSize items and appends a "Load more" button. Each click fetches the next page from /_instatic/loop/<loopId>?page=N&pagePath=<path> and appends the returned HTML before the button. When hasMore is false the button is removed automatically.
To enable infinite loading:
- Set
props.pagination = 'infinite'on the loop node. - Set
props.pageSize(items per click; defaults to 10). - The publisher auto-injects
<script type="module" src="/_instatic/assets/loop-runtime.js">when at least one infinite loop exists on the page (seeserver/publish/loopRuntime.ts). The runtime is < 2 KB and ships only when needed.
For static multi-page navigation (no JS required):
- Use separate
base.loopnodes with anoffsetfilter — one per "page" — and static links between pages.
| Pattern | Use instead |
|---|---|
await fetch(...) inside the loop walker |
Pre-fetch via loopPrefetch.ts |
| Plugin sources that hit the host DB directly | Use api.cms.storage.* |
| Reaching across loop iterations (e.g. "the previous item") | Items are independent. Use a server-side fetch + materialize the relation. |
| Per-iteration state (e.g. counter) | Loop iterations are independent. The walker doesn't preserve state. |
| Rendering a loop without prefetched data | RenderConfig.loopData must be populated — otherwise the loop renders a marker comment. |
Cycling variants by index % items.length instead of % variants.length |
Round-robin is by variants. Read node.children.length. |
Source ids without a namespace (just products) |
Namespace by plugin (acme.products) — collisions otherwise |
- docs/architecture.md — universal
entryStack - docs/features/publisher.md —
renderLoopis one of two specialized renderers - docs/features/templates.md —
currentEntryresolves the same way for templates and loops - docs/features/content-storage.md —
data_tables+data_rowsis the source fordata.rows - docs/features/plugin-system.md — plugin loop sources
- Source-of-truth files:
src/core/loops/index.ts— public barrel (pageToLoopItem,filterPagesForLoop, types)src/core/loops/types.ts—LoopEntitySource,LoopItem,LoopFetchResultsrc/core/loops/registry.ts— registry singletonsrc/core/loops/sources/dataRows.ts,sitePages.ts,siteMedia.ts— built-in sourcessrc/modules/base/loop/— thebase.loopmodulesrc/core/publisher/renderLoop.ts— render walkerserver/publish/loopPrefetch.ts— pre-fetchserver/publish/loopRuntime.ts,server/handlers/cms/loop.ts— runtime asset + live-fetch endpoint
- Gate tests:
src/__tests__/architecture/loop-source-id-format.test.tssrc/__tests__/architecture/loop-source-sql-safety.test.tssrc/__tests__/loops/sitePagesLoopItemParity.test.ts— canvas preview ↔ publisher parity forsite.pages