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.loopDatamap 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:
- Data retrieval: Obtain the
LoopFetchResultcontaining theitemsarray andtotalItemscount. - Context stacking: For each item, build a fresh
templateContext.entryStackusing the spread operator ([...baseStack, item]) to ensure inner bindings resolve against the current item only. - Variant cycling: Select child templates using modulo arithmetic (
variantId = children[i % children.length]), enabling alternating layouts within the same loop. - Delegation: Render each child by calling the internal
renderNodefunction 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.tsviaregisterSource. - The publisher prefetches static loop data during the render configuration phase in
src/core/publisher/renderConfig.ts. resolveLoopData()insrc/core/publisher/renderLoop.tshandles both pre-fetched and field-based data resolution.- Each iteration creates an isolated
entryStackto prevent context leakage between items. - Dynamic loops marked with
requestDependent: trueare detected bysrc/core/publisher/dynamicDetection.tsand 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →