How Instatic Auto-Detects Dynamic Nodes During Publishing: Core Rules and Implementation

Instatic classifies every node in a page tree as static or dynamic using a single walker in src/core/publisher/dynamicDetection.ts, which applies five detection rules to determine whether content can be baked at publish time or must be deferred to request-time <instatic-hole> placeholders.

The Instatic publisher relies on auto-detection of dynamic nodes to decide which parts of a page tree can be rendered as static HTML files versus which require runtime hydration. This classification system, implemented entirely within src/core/publisher/dynamicDetection.ts, analyzes module definitions, binding sources, and component references to build a definitive set of node IDs that must be treated as dynamic.

The Dynamic Detection Walker

The core architecture centers on a classification walker that traverses the page tree and applies a consistent rule set to every node. According to the Instatic source code, this walker ensures that Layer A (static baking) and Layer C (placeholder emission) use identical logic, preventing drift between build-time and runtime behavior.

The detection process begins with the findDynamicNodesWithReasons function, which executes a pre-pass for loop promotion followed by the main classification pass. A simpler wrapper, findDynamicNodeIds, returns only the set of dynamic node IDs for production use.

The Five Detection Rules

The walker applies five specific rules to classify nodes. Each rule targets a different mechanism by which content can become request-dependent.

Rule 1: Explicit Dynamic Flag

If a module’s definition in the registry contains dynamic: true, the node is immediately marked as dynamic. In src/core/publisher/dynamicDetection.ts, the classifyNode function checks def?.dynamic at lines 87–91 to enforce this rule.

// Conceptual implementation from the source
if (moduleDef?.dynamic === true) {
  return { dynamic: true, reason: 'Module explicitly marked as dynamic' };
}

Rule 2: Dynamic Bindings and Inline Tokens

Nodes may declare a dynamicBindings map or contain inline binding tokens in string properties. The system examines binding sources and fields to determine if they depend on request-time data.

Structured bindings: The checkDynamicBindings function iterates over node.dynamicBindings and uses isBindingSourceRequestDependent (lines 107–115) to test whether the source is request-dependent, such as route.query.*.

Inline tokens: For string properties containing {source.field} tokens, checkInlineTokens parses the strings, detects tokens, and calls isBindingSourceRequestDependent (lines 121–129). If any token’s source is request-dependent, the node becomes dynamic.

Rule 3: Request-Dependent Loop Sources

For nodes of module base.loop, the walker examines the sourceId property. If the referenced loop source in loopSourceRegistry is marked with requestDependent: true or perVisitor: true, the node is classified as dynamic. The checkLoopSource function implements this check at lines 140–150.

Rule 4: Visual Component References

A base.visual-component-ref node is dynamic if any node inside the referenced visual component (VC) definition tree is dynamic. The algorithm recursively walks the VC’s tree using collectSubtreeReasons, guarding against cycles, and propagates the first inner reason found. This logic appears in classifyNode at lines 200–218.

// VC reference handling in classifyNode
if (node.moduleId === 'base.visual-component-ref') {
  const vcTree = loadVisualComponent(node.refId);
  const innerReason = collectSubtreeReasons(vcTree, visited);
  if (innerReason) {
    return { dynamic: true, reason: innerReason };
  }
}

Rule 3.5: Loop Body Promotion (Pre-Pass)

Before the main classification pass, the walker executes a promotion phase for loop bodies. Static loops whose bodies contain request-dependent nodes are promoted to a single dynamic hole. Their inner node IDs are suppressed so the publisher does not emit separate holes for each iteration. This pre-pass logic in findDynamicNodesWithReasons builds promotedLoops and suppressed sets at lines 113–124.

Public API and Publisher Integration

The detection system exposes two public helpers consumed by the publisher orchestrator in src/core/publisher/render.ts:

findDynamicNodeIds(page, site, registry) – Returns a Set of page-level node IDs that must become holes. This lightweight function is used for the static-versus-dynamic shortcut decision.

findDynamicNodesWithReasons(page, site, registry) – Returns both the ID set and a list of human-readable diagnostic reasons.

The publisher orchestrator invokes these functions during page rendering:

import { findDynamicNodeIds } from './dynamicDetection';

const dynamicIds = findDynamicNodeIds(page, site, registry);
if (dynamicIds.size === 0) {
  // Page is fully static → bake complete HTML file (Layer A)
} else {
  // Render with placeholders (Layer C)
}

For Layer C’s renderNode, the same dynamicIds set drives placeholder emission:

if (dynamicIds.has(node.id)) {
  // Emit <instatic-hole> instead of fully rendering
}

Layer Architecture Impact

The auto-detection results feed into three distinct publishing layers:

  • Layer A: Bake fully-static pages to uploads/published/current/. Checks if dynamicIds is empty to decide between shell (static) and complete (requires holes) rendering.
  • Layer B: In-memory LRU cache for dynamic routes. Stores cached results containing hole placeholders generated by Layer C.
  • Layer C: Emit <instatic-hole> placeholders. Walks the dynamicIds set to insert runtime-fetch holes for each dynamic node.

Practical Implementation Examples

Debugging Dynamic Classification

Use findDynamicNodesWithReasons to inspect why specific nodes are classified as dynamic:

import { findDynamicNodesWithReasons } from '@core/publisher/dynamicDetection';

function logDynamicReasons(page, site, registry) {
  const { dynamicPageNodeIds, reasons } = findDynamicNodesWithReasons(page, site, registry);
  console.log('Dynamic node IDs:', [...dynamicPageNodeIds]);
  console.log('Why they are dynamic:');
  reasons.forEach(r => console.log(' –', r));
}

Extending Request-Dependent Sources

When introducing new binding sources that should be treated as request-dependent, extend the isBindingSourceRequestDependent function:

import { isBindingSourceRequestDependent } from '@core/publisher/dynamicDetection';

function registerMySource() {
  const original = isBindingSourceRequestDependent;
  isBindingSourceRequestDependent = (source, field) => {
    if (source === 'myCustomSource') return true;
    return original(source, field);
  };
}

Server Handler Integration

Implement publish-time logic that branches based on dynamic detection:

import { findDynamicNodeIds } from '@core/publisher/dynamicDetection';
import { renderPage } from '@core/publisher/render';

export async function handlePublish(req, res) {
  const { page, site, registry } = await loadPageContext(req);
  const dynamicIds = findDynamicNodeIds(page, site, registry);

  if (dynamicIds.size === 0) {
    const html = renderPage(page, site, registry);
    await writeStaticFile(page.path, html);
  } else {
    const html = renderPage(page, site, registry); // Automatically inserts holes
    await writeStaticFile(page.path, html);
  }
}

Summary

  • Single source of truth: All dynamic node detection logic resides in src/core/publisher/dynamicDetection.ts, ensuring Layer A and Layer C use identical classification rules.
  • Five rule categories: Detection covers explicit dynamic flags, structured bindings, inline tokens, loop sources, and recursive visual component references.
  • Pre-pass optimization: Loop body promotion prevents excessive hole emission by consolidating dynamic inner nodes into a single parent hole.
  • Dual API surface: findDynamicNodeIds provides production-ready ID sets, while findDynamicNodesWithReasons offers diagnostic capabilities for debugging.
  • Request-time dependency: The core distinction between static and dynamic hinges on whether a node depends on request-time data such as route.query.* or per-visitor loops.

Frequently Asked Questions

How does Instatic handle nested visual components with mixed static and dynamic children?

When a base.visual-component-ref node is encountered, the walker recursively analyzes the entire referenced component tree using collectSubtreeReasons (lines 200–218 in dynamicDetection.ts). If any node within that tree is dynamic, the VC reference itself is marked as dynamic. This prevents partial static baking of components that contain dynamic elements, ensuring runtime consistency.

What happens if a static loop contains a dynamic node in its body?

The system runs a pre-pass (Rule 3.5) before main classification that detects static loops with request-dependent inner nodes. These loops are "promoted" to dynamic status, and their inner node IDs are added to a suppression set. This ensures the publisher emits a single <instatic-hole> for the loop rather than separate holes for each iteration, optimizing the output structure.

Can I override the auto-detection for specific nodes?

While the core walker does not provide a bypass mechanism, you can influence classification by adjusting module definitions in the registry. Setting dynamic: true in a module definition (Rule 1) forces dynamic classification, while ensuring no request-dependent bindings or sources are present keeps a node static. For custom binding sources, extend isBindingSourceRequestDependent to include your source as request-dependent.

Where does the publisher actually insert the <instatic-hole> placeholders?

Layer C of the publisher, implemented in src/core/publisher/render.ts, consumes the dynamicIds set returned by findDynamicNodeIds. During the rendering traversal, when renderNode encounters a node ID present in this set, it emits an <instatic-hole> placeholder instead of the fully rendered content, deferring that node's rendering to request time.

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 →