Instatic's Three-Layer Publishing Pipeline: Static-First Rendering with Dynamic Islands

Instatic renders pages through a three-layer pipeline that combines atomic disk baking, versioned in-memory LRU caching, and client-side dynamic islands to deliver static HTML performance with runtime flexibility.

Instatic, the open-source static site generator maintained by CoreBunch, implements a sophisticated three-layer publishing pipeline that separates static content generation from dynamic fragment rendering. This architecture enables aggressive caching strategies while preserving the ability to inject request-dependent content through isolated "holes" hydrated at runtime.

Architecture Overview

The pipeline separates concerns across three distinct layers, each handled by specific modules in the server/publish/ directory.

Layer A — Disk Bake

Layer A performs the static site generation during the publish phase. The system walks the page tree, renders each node, and writes the resulting HTML to uploads/published/current/<route>.html.

In server/publish/staticArtefact.ts, the write operation uses an atomic two-slot symlink swap to ensure readers never encounter a half-written file. When a publish completes, the system swaps the symlink atomically, making the new content immediately available without downtime. This layer handles static shells and pre-rendered pages that require no request-time processing.

Layer B — In-Memory LRU Cache

Layer B caches dynamic pages—those containing request-dependent nodes—in a versioned least-recently-used map. The cache key combines (urlPath, canonicalQuery) where canonicalQuery is normalized by canonicalRenderQuery() to strip non-loop pagination parameters.

Implemented in server/publish/renderCache.ts, this layer stores rendered HTML in memory. When bumpPublishVersion() increments the global publishVersion after a new deployment, all Layer B entries expire lazily. This ensures cache consistency across publishes while maintaining sub-millisecond response times for repeated requests.

Layer C — Dynamic-Island Runtime

Layer C handles hydration of request-dependent fragments at runtime. During the Layer A bake, nodes classified as dynamic are replaced with an <instatic-hole> placeholder containing approximately 1KB of IntersectionObserver script.

The client-side runtime, shipped from server/publish/holeRuntime.ts, observes these placeholders. When they enter the viewport, the browser fetches the fragment from /_instatic/hole/<nodeId>, and the server re-renders just that subtree using the same publishPage logic. This bypasses the Layer B cache for per-visitor fragments while keeping the initial HTML payload static.

How the Layers Interact

The publishing flow orchestrates these layers through a coordinated sequence:

  1. Publish initiation: publishDraftSite writes a new SiteDocument snapshot and iterates every page.

  2. Page rendering: For each page, publishPage in src/core/publisher/render.ts performs a full render:

  3. Post-processing: The HTML passes through publishedHtmlPipeline.ts for plugin filters, CSP generation, and runtime asset injection.

  4. Atomic deployment: The document writes to disk (Layer A) via the two-slot symlink swap in staticArtefact.ts.

  5. Cache invalidation: After the swap, bumpPublishVersion() increments the version, causing Layer B entries to expire on next access.

Request-time resolution follows this chain:

  • The router normalizes queries using canonicalRenderQuery to generate the Layer B key
  • If the request lacks loop pagination parameters, the router serves the disk file directly (Layer A fast-path)
  • Otherwise, it checks the Layer B LRU; a miss triggers a single-flight render that caches the result
  • Any <instatic-hole> tags hydrate via Layer C fetches to /_instatic/hole/<nodeId>

Dynamic Node Detection

The pipeline's decision to treat content as static or dynamic depends on findDynamicNodeIds in src/core/publisher/dynamicDetection.ts. This single-pass classifier applies four primary rules plus a promotion rule:

  1. Explicit dynamic flag: Modules flagged with dynamic: true
  2. Dynamic bindings: Nodes with dynamicBindings sources reading request parameters
  3. Request-dependent loops: Loop sources declared requestDependent or perVisitor
  4. Visual component recursion: Component references whose definitions contain any dynamic node

Rule 3.5 (Promotion): If a static loop contains a dynamic child, the entire loop promotes to a single hole to prevent duplicate placeholders.

Practical Implementation

Rendering a Page Programmatically (Layer A)

import { publishPage } from '@/core/publisher/render';
import { site, registry } from '@/server/context';

// `page` is a NodeTree<PageNode> from the draft site
const { html, filename } = await publishPage(page, site, registry);

console.log('Published to:', filename);
// html contains full document or static shell with <instatic-hole>

This entry point in src/core/publisher/render.ts orchestrates the tree walk and asset collection.

Triggering a Full Site Publish

// POST /admin/api/cms/publish/site
await fetch('/admin/api/cms/publish/site', { method: 'POST' });

The handler in server/publish/publishSite.ts writes the snapshot, bakes every page, swaps the active slot, and calls bumpPublishVersion() to clear Layer B.

Accessing Cached Dynamic Pages (Layer B)

import { renderCache } from '@/server/publish/renderCache';

const cached = await renderCache.getOrRender(
  { urlPath: '/blog', queryString: '?loop_posts_page=2' },
  async () => {
    const { html } = await publishPage(blogPage, site, registry);
    return { body: html, headers: {}, status: 200 };
  },
);

console.log(cached.body); // Instant hit on subsequent requests

The renderCache.ts implementation handles single-flight rendering and LRU eviction automatically.

Hydrating Dynamic Islands (Layer C)

// Runtime injected automatically on pages with holes
new IntersectionObserver((entries) => {
  entries.forEach(async (e) => {
    if (e.isIntersecting) {
      const nodeId = e.target.dataset.instaticHoleId;
      const version = e.target.dataset.instaticVersion;
      const url = `/_instatic/hole/${nodeId}?v=${version}&u=${location.pathname}`;
      const res = await fetch(url);
      e.target.replaceWith(await res.text());
    }
  });
}).observe(document.querySelectorAll('instatic-hole'));

This ~1KB IIFE from server/publish/holeRuntime.ts defers loading of dynamic fragments until they approach the viewport.

Summary

  • Layer A provides atomic, static file deployment via symlink swapping in staticArtefact.ts, ensuring zero-downtime updates
  • Layer B maintains a versioned LRU cache in renderCache.ts for dynamic pages, keyed by canonical query strings and invalidated globally on publish
  • Layer C enables request-dependent content through <instatic-hole> placeholders hydrated by the client-side runtime in holeRuntime.ts
  • Dynamic detection in dynamicDetection.ts automatically promotes nodes to holes based on binding sources and component definitions
  • All layers coordinate through publishVersion to ensure cache consistency while maximizing static-file cacheability

Frequently Asked Questions

How does Layer A ensure atomic updates without downtime?

Layer A writes files to a staging directory and performs an atomic two-slot symlink swap. As implemented in server/publish/staticArtefact.ts, the system maintains two publication slots under uploads/published/. The active symlink points to the current slot; when a new publish completes, the system writes to the inactive slot and atomically swaps the symlink. Readers always see a complete file, never a partially written document.

What triggers invalidation of the Layer B in-memory cache?

The bumpPublishVersion() function in server/publish/publishState.ts increments a global publishVersion counter after each successful disk swap. Layer B entries store the version at creation time; when a request hits the cache with a mismatched version, the entry invalidates automatically. This lazy eviction strategy ensures consistency without blocking the publish operation.

When should content use Layer C dynamic islands versus static baking?

Use Layer C dynamic islands when content depends on request-time parameters that cannot be determined at build time, such as user authentication state, A/B test variants, or personalized data. Mark nodes as dynamic via dynamic: true flags or perVisitor loop sources in dynamicDetection.ts. Static content that renders identically for all visitors should remain in Layer A to maximize CDN cacheability and minimize server load.

How does the pipeline handle CSS and JavaScript assets?

During Layer A baking, siteCssBundle.ts assembles four hashed CSS bundles (reset, framework, style, userStyles) and deduplicates module CSS via CssCollector in cssCollector.ts. Module JavaScript accumulates in renderAccumulators.jsMap during node rendering and writes to /_instatic/module-js/<moduleId>.js. All assets receive content hashes and Cache-Control: immutable headers, allowing browsers to cache them indefinitely while the HTML documents handle cache-busting through versioned URLs.

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 →