How Instatic Loops Render Collections from Data Sources with Variants

Instatic loops iterate over collections supplied by a Loop Entity Source, injecting items into the page tree during the publishing walk while variant flags (requestDependent and perVisitor) determine whether the output is baked into static HTML, cached per request, or rendered uniquely for each visitor.

In the CoreBunch/Instatic headless CMS, Instatic Loops serve as the primary engine for rendering dynamic collections from any data backend. The system decouples data fetching from presentation through a plugin-based architecture built around three core concepts: the Loop Entity Source that produces data, the Loop Source Registry that manages available sources, and the Render Loop routine that expands collection nodes into rendered output.

The Loop Entity Source Architecture

The architecture separates data acquisition from rendering through a clean contract defined in src/core/loops/types.ts. A Loop Entity Source must implement an identifier, label, field schema, and fetch and preview methods that return arrays of LoopItem objects.

Registering a Source via the LoopEntitySource Contract

Sources are TypeScript objects that adhere to the LoopEntitySource interface. The most common implementation is a prefetched source that queries a database at build or request time:

// src/core/loops/sources/dataRows.ts
import type {
  LoopEntitySource,
  LoopFetchResult,
  SourceFetchContext,
  SourcePreviewContext,
} from '@core/loops/types';

export const dataRowsSource: LoopEntitySource = {
  id: 'data.rows',
  label: 'Data rows',
  description: 'Rows from a user‑defined table',
  requestDependent: false,
  filterSchema: {/* … */},
  orderByOptions: [{ id: 'createdAt', label: 'Created At' }],
  fields: [
    { id: 'title', label: 'Title', format: 'plain' },
    { id: 'body', label: 'Body', format: 'html' },
  ],
  async fetch(ctx: SourceFetchContext): Promise<LoopFetchResult> {
    const { rows } = await ctx.db`SELECT id, title, body FROM data_rows`;
    return { items: rows.map(r => ({ id: r.id, fields: r })), totalItems: rows.length };
  },
  preview(ctx: SourcePreviewContext) {
    return [{ id: 'demo‑1', fields: { title: 'Demo', body: '<p>…</p>' } }];
  },
};

export default dataRowsSource;

When the module is imported, it self-registers with the singleton registry:

// src/core/loops/sources/index.ts
import { loopSourceRegistry } from '@core/loops/registry';
import dataRowsSource from './dataRows';
loopSourceRegistry.register(dataRowsSource);

The registry in src/core/loops/registry.ts holds all available sources and provides the get and getOrThrow methods used by the publisher to resolve source IDs at runtime.

Variant Classification: Static, Request-Dependent, and Per-Visitor

A source declares its variant through boolean flags that dictate when and how the loop renders:

  • Static (Layer A) — requestDependent: false and perVisitor: false. The source resolves at publish time and its items are baked into the static HTML.
  • Request-Dependent (Layer B) — requestDependent: true. The loop renders at request time and is cached in the render cache. The source receives ctx.request (query string) but no cookies.
  • Per-Visitor (Layer C) — perVisitor: true. The loop renders on every page load, bypassing all caches. The source receives the full request context including cookies, and the response is sent with Cache-Control: no-store.

The publisher classifies loops by inspecting these flags:

// src/core/publisher/dynamicDetection.ts
import { loopSourceRegistry } from '@core/loops/registry';

export function isDynamicLoop(sourceId: string): boolean {
  const src = loopSourceRegistry.get(sourceId);
  return !!src && (src.requestDependent || src.perVisitor);
}

Rendering Collections in the Publishing Pipeline

During a publish run, the publisher invokes renderLoop for every base.loop node in the page tree.

Fetching Items from the Source

The renderLoop function in src/core/publisher/renderLoop.ts retrieves the source from the registry, invokes its fetch method with the current context, and receives a LoopFetchResult containing the collection items:

// src/core/publisher/renderLoop.ts
import { LoopItem } from '@core/loops/types';
import { loopSourceRegistry } from '@core/loops/registry';

export async function renderLoop(
  loopNode: LoopNode,
  ctx: RenderContext,
) {
  const src = loopSourceRegistry.getOrThrow(loopNode.source);
  const fetchResult = await src.fetch({
    db: ctx.db,
    site: ctx.site,
    filters: loopNode.filters,
    orderBy: loopNode.orderBy,
    direction: loopNode.direction,
    limit: loopNode.limit,
    offset: loopNode.offset,
    request: ctx.request, // undefined for static renders
  });

  for (const item of fetchResult.items) {
    const childCtx = { ...ctx, loopItem: item };
    await renderChildren(loopNode.children, childCtx);
  }

  return { totalItems: fetchResult.totalItems };
}

The fetch method receives a SourceFetchContext containing the database connection, site configuration, pagination parameters, and—depending on the variant—request data.

Injecting LoopItem into Child Templates

For each LoopItem returned by the source, renderLoop creates a child context that injects the item as a loopItem variable. Inside templates, fields are accessed via {{ loopItem.fields.<fieldId> }}:

/* Example: a loop that renders the latest 5 blog posts */
<base.loop
  source="data.rows"
  orderBy="createdAt"
  direction="desc"
  limit={5}
>
  <article class="post">
    <h2>{{ loopItem.fields.title }}</h2>
    <div class="body">{{ loopItem.fields.body }}</div>
  </article>
</base.loop>

The format hint defined in LoopSourceField.format (plain, html, url, or media) instructs the publisher's escapeProps helper whether to HTML-escape the value, pass it through raw, or rewrite it for asset optimization.

Editor Preview and Development Experience

The administrative interface uses the source's preview method to generate synthetic data without database queries. This allows designers to see representative content while editing:

// src/admin/pages/site/canvas/useLoopPreviewItems.ts
import { loopSourceRegistry } from '@core/loops/registry';

export function useLoopPreviewItems(sourceId: string) {
  const src = loopSourceRegistry.get(sourceId);
  return src?.preview?.({ site: currentSite, filters: {}, limit: 5 }) ?? [];
}

Per-visitor sources require special handling in previews since they depend on request cookies:

/* Example: a per‑visitor loop that shows personalized recommendations */
<base.loop
  source="acme.recommendations"
  perVisitor
  limit={3}
>
  <div class="rec">{{ loopItem.fields.title }}</div>
</base.loop>

When registering a per-visitor source, you must implement the full request context handling:

// Registering a per‑visitor source (plugin side)
await api.cms.loops.registerSource({
  id: 'acme.recommendations',
  label: 'Acme Recommendations',
  perVisitor: true,
  requestDependent: true,
  filterSchema: {/* … */},
  orderByOptions: [{ id: 'score', label: 'Score' }],
  fields: [{ id: 'title', label: 'Title' }],
  async fetch(ctx) {
    const userId = ctx.request?.cookies?.session;
    const items = await fetchFromAcmeApi(userId);
    return { items, totalItems: items.length };
  },
});

Summary

  • Loop Entity Sources are plugin-backed data backends defined in src/core/loops/types.ts that implement fetch and preview methods.
  • The Loop Source Registry (src/core/loops/registry.ts) maintains a singleton map of all registered sources, allowing runtime resolution by ID.
  • Variants (requestDependent, perVisitor) determine caching behavior: static (Layer A), request-cached (Layer B), or per-visitor (Layer C) as implemented in src/core/publisher/dynamicDetection.ts.
  • Render Loop (src/core/publisher/renderLoop.ts) invokes the source, iterates over LoopItem results, and renders child nodes with a loopItem context variable.
  • Field formatting hints (plain, html, etc.) control how the publisher escapes or transforms values during the render pass.

Frequently Asked Questions

What is the difference between requestDependent and perVisitor in Instatic Loops?

requestDependent renders the loop at request time and caches the result in the render cache (Layer B), making it suitable for content that varies by URL but not by user. perVisitor bypasses all caching and renders on every page load (Layer C), receiving the full request context including cookies, which is required for personalized content.

How does Instatic handle HTML escaping for loop item fields?

According to the LoopSourceField definition in src/core/loops/types.ts, each field declares a format hint (plain, html, url, or media). The publisher uses this hint in its escapeProps helper to determine whether to HTML-escape the value, pass it through raw, or rewrite asset URLs.

Can I register a custom data source for Instatic Loops?

Yes. Plugins call api.cms.loops.registerSource with an object implementing the LoopEntitySource interface, including id, label, fields, fetch, and optionally preview methods. The source is typically defined in its own file and auto-registers via the registry in src/core/loops/sources/index.ts.

How does the Instatic editor preview loop data without database access?

The editor surface calls the source's preview method (defined in SourcePreviewContext), which synthesizes a handful of representative LoopItem objects without querying the database. This logic lives in src/admin/pages/site/canvas/useLoopPreviewItems.ts and allows designers to see layout previews while editing component properties.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →