# How Instatic Loops Render Collections from Data Sources with Variants

> Discover how Instatic loops render collections from data sources using variants. Learn how requestDependent and perVisitor flags control static HTML generation, request caching, and unique visitor rendering.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: internals
- Published: 2026-07-31

---

**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`](https://github.com/CoreBunch/Instatic/blob/main/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:

```typescript
// 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:

```typescript
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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:

```typescript
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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:

```typescript
// 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> }}`:

```tsx
/* 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:

```typescript
// 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:

```tsx
/* 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:

```typescript
// 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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/loops/types.ts) that implement `fetch` and `preview` methods.
- The **Loop Source Registry** ([`src/core/loops/registry.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts).
- **Render Loop** ([`src/core/publisher/renderLoop.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/canvas/useLoopPreviewItems.ts) and allows designers to see layout previews while editing component properties.