How Instatic Loop Blocks Render Dynamic Content from Data Tables

Instatic loop blocks iterate over collections of LoopItem objects by registering a LoopEntitySource, prefetching data during the publish run, and rendering child templates for each item with isolated context stacks.

In the CoreBunch/Instatic codebase, loop blocks serve as the primary mechanism for transforming static templates into data-driven experiences. Understanding how these blocks resolve and render dynamic content from data tables requires examining the three-stage pipeline that connects source registration to iterative HTML generation.

Stage 1: Registering LoopEntitySource Definitions

Every loop block relies on a registered LoopEntitySource that defines where items originate and what fields they expose. Sources are registered through the plugin SDK and stored in a singleton registry located at src/core/loops/registry.ts.

The registration process uses the registerSource function to declare metadata including filter schemas, sort options, and the asynchronous fetch implementation:

// src/core/loops/registry.ts (usage pattern)
import { registerSource } from '@core/loops/registry';

registerSource({
  id: 'data.rows',
  label: 'Data Table Rows',
  requestDependent: false,
  filterSchema: { /* field definitions */ },
  orderByOptions: [{ id: 'createdAt', label: 'Created' }],
  fields: [{ id: 'title', label: 'Title', format: 'plain' }],
  async fetch(ctx) {
    // Returns a LoopFetchResult containing items and totalItems
    return { items: [], totalItems: 0 };
  }
});

Each source implementation must conform to the contracts defined in src/core/loops/types.ts, ensuring consistent handling of LoopItem objects across the rendering pipeline.

Stage 2: Prefetching and Data Resolution

During a publish run, the server walks the page tree and prefetches data for loops using pre-fetchable sources. This logic resides in src/core/publisher/renderConfig.ts, which populates RenderConfig.loopData with a Map<string, ResolvedLoopRenderData> keyed by the loop node ID.

When the publisher encounters a base.loop node, it invokes resolveLoopData() from src/core/publisher/renderLoop.ts. This function determines whether to use pre-fetched data or resolve a field-based source dynamically by reading the current entry-stack:

  • Pre-fetched sources: Data is retrieved from the RenderConfig.loopData map using the node's unique identifier.
  • Field-based sources: The system resolves filters by accessing the current context stack, allowing loops to derive data from parent entry fields.

Stage 3: Iterative Rendering with Context Isolation

The core rendering logic in src/core/publisher/renderLoop.ts transforms resolved data into HTML through a strict iteration protocol:

  1. Data retrieval: Obtain the LoopFetchResult containing the items array and totalItems count.
  2. Context stacking: For each item, build a fresh templateContext.entryStack using the spread operator ([...baseStack, item]) to ensure inner bindings resolve against the current item only.
  3. Variant cycling: Select child templates using modulo arithmetic (variantId = children[i % children.length]), enabling alternating layouts within the same loop.
  4. Delegation: Render each child by calling the internal renderNode function with the isolated context.

Pagination is handled natively within this file. When pagination='infinite' is configured, the renderer injects sentinel attributes like data-instatic-loop-id and records the loop ID in acc.infiniteLoopIds. This allows the runtime script to fetch subsequent pages client-side via the /_instatic/loop/<loopId>?page=N endpoint.

The final output is wrapped in an HTML tag determined by resolveHtmlTag (defaulting to <div>), with class IDs and inline styles injected via injectNodeClassIds and injectNodeInlineStyles. If no data resolves, the publisher emits an HTML comment for diagnostic purposes.

Dynamic vs. Static Loop Classification

Not all loops are baked into the static Layer A artifact. The detection logic in src/core/publisher/dynamicDetection.ts classifies loops as dynamic when their source sets requestDependent: true or perVisitor: true.

Dynamic loops bypass static generation and render on-demand in Layer C holes. These responses can be cached in Layer B based on request parameters, while Layer A remains a static shell. Static loops, conversely, have their entire HTML output generated at publish time.

Practical Implementation Examples

Registering a Custom External Source

To pull data from a third-party API, implement the fetch method with requestDependent: true for dynamic rendering:

import { registerSource } from '@core/loops/registry';
import type { SourceFetchContext, LoopFetchResult } from '@core/loops/types';

registerSource({
  id: 'acme.products',
  label: 'Acme Product Catalog',
  requestDependent: true,  // Forces Layer C rendering
  filterSchema: { /* UI filters */ },
  orderByOptions: [{ id: 'price', label: 'Price' }],
  fields: [
    { id: 'name', label: 'Product Name', format: 'plain' },
    { id: 'image', label: 'Image', format: 'media' },
    { id: 'price', label: 'Price', format: 'plain' }
  ],
  async fetch(ctx: SourceFetchContext): Promise<LoopFetchResult> {
    const { filters, limit, offset } = ctx;
    const resp = await fetch(`https://api.acme.com/products?${new URLSearchParams(filters)}`);
    const data = await resp.json();
    
    const items = data.products.slice(offset, offset + limit).map(p => ({
      id: p.id,
      fields: {
        name: p.title,
        image: p.mediaUrl,
        price: `$${p.price}`
      }
    }));
    
    return { items, totalItems: data.total };
  },
  preview() {
    // Return static data for the editor canvas
    return [{ id: 'demo', fields: { name: 'Demo', image: '/demo.png', price: '$0' } }];
  }
});

Loop Module Configuration

The editor UI defines loop blocks in src/modules/base/loop/index.ts using a structured schema:

// src/modules/base/loop/index.ts
export const LoopModule = {
  name: 'Loop',
  propsSchema: {
    sourceId: { type: 'string' },
    filters: { type: 'object' },
    pagination: { enum: ['none', 'infinite'] },
    tag: { type: 'string' },
    customTag: { type: 'string' }
  },
  defaults: {
    pagination: 'none',
    tag: 'div'
  }
};

Resulting HTML Structure

A rendered loop with infinite pagination and multiple child variants produces markup with data attributes for runtime hydration:

<div data-instatic-loop="loop-123" 
     data-instatic-loop-page="1" 
     data-instatic-loop-mode="infinite"
     data-instatic-loop-has-more="true" 
     data-instatic-loop-page-size="10">
  <!-- Child variant 0 -->
  <section class="card">Content bound to item 0</section>
  <!-- Child variant 1 -->
  <section class="card">Content bound to item 1</section>
  <!-- Child variant 0 (cycled) -->
  <section class="card">Content bound to item 2</section>
</div>

Summary

  • LoopEntitySource objects define data origins and are registered in src/core/loops/registry.ts via registerSource.
  • The publisher prefetches static loop data during the render configuration phase in src/core/publisher/renderConfig.ts.
  • resolveLoopData() in src/core/publisher/renderLoop.ts handles both pre-fetched and field-based data resolution.
  • Each iteration creates an isolated entryStack to prevent context leakage between items.
  • Dynamic loops marked with requestDependent: true are detected by src/core/publisher/dynamicDetection.ts and rendered in Layer C rather than static Layer A.
  • Infinite pagination injects sentinel attributes and loop IDs to enable client-side page fetching.

Frequently Asked Questions

What is the difference between a LoopEntitySource and a LoopItem?

A LoopEntitySource is the configuration object that defines how to fetch and structure data (including filters, sort options, and the fetch method), while a LoopItem represents a single row or record returned by that source's fetch implementation. The source provides the blueprint; items are the instantiated data objects rendered by the loop block.

How does pagination work in Instatic loop blocks?

When pagination='infinite' is set in the loop properties, src/core/publisher/renderLoop.ts injects data attributes like data-instatic-loop-id and records the loop identifier in acc.infiniteLoopIds. The runtime JavaScript uses these markers to call the /_instatic/loop/<loopId>?page=N endpoint, fetching additional items and appending them to the DOM without reloading the page.

When should a source set requestDependent to true?

Set requestDependent: true (or perVisitor: true) when the loop data varies by request context, such as personalized user content, geolocation-specific data, or real-time API results. According to src/core/publisher/dynamicDetection.ts, this classification moves the loop from static Layer A generation to dynamic Layer C rendering, executing the fetch logic on each request rather than at build time.

How does the publisher handle empty loop data?

When resolveLoopData() returns no items, the rendering pipeline in src/core/publisher/renderLoop.ts emits an HTML comment instead of generating wrapper tags or child elements. This provides a diagnostic marker in the output while keeping the DOM clean, and prevents styling issues that would occur with empty container elements.

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 →