# How Instatic's Three-Layer Publishing Pipeline Works: From Static Bake to Dynamic Islands

> Discover Instatic's three layer publishing pipeline. Learn how static bake, dynamic caching, and client hydration deliver static first performance with request dependent content.

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

---

**Instatic's three-layer publishing pipeline separates static generation, dynamic caching, and client-side hydration to deliver static-first performance while supporting request-dependent content through atomic disk writes, versioned in-memory LRU caching, and runtime dynamic islands.**

Instatic, an open-source static site generator maintained at CoreBunch/Instatic, renders page trees to complete HTML documents through a sophisticated three-layer architecture. This pipeline maximizes cacheability by isolating dynamic fragments, ensuring visitors receive pre-rendered content instantly while request-dependent elements hydrate on demand.

## The Three Layers: Disk, Memory, and Runtime

The pipeline organizes publishing into three distinct layers, each handling a specific phase of the content lifecycle:

| Layer | Function | Primary File |
|-------|----------|--------------|
| **Layer A – Disk Bake** | Renders the page tree and writes HTML to disk via atomic symlink swap | [`server/publish/staticArtefact.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/staticArtefact.ts) |
| **Layer B – In-Memory LRU** | Caches dynamic pages keyed by canonical query strings | [`server/publish/renderCache.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/renderCache.ts) |
| **Layer C – Dynamic-Island Runtime** | Hydrates request-dependent fragments client-side | [`server/publish/holeRuntime.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/holeRuntime.ts) |

### Layer A: Disk Bake (Static Generation)

**Layer A** handles the initial static generation. The `publishPage` function in [`src/core/publisher/render.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/render.ts) walks the node tree recursively ([`renderNode.ts`](https://github.com/CoreBunch/Instatic/blob/main/renderNode.ts)), producing HTML strings for each page. The system writes these files to `uploads/published/current/<route>.html` using an **atomic two-slot symlink swap** implemented in [`staticArtefact.ts`](https://github.com/CoreBunch/Instatic/blob/main/staticArtefact.ts). This ensures readers never encounter half-written files during deployment.

Static shells may contain `<instatic-hole>` placeholders where dynamic content will eventually render, but the disk version represents the fastest possible delivery path.

### Layer B: In-Memory LRU (Dynamic Page Cache)

**Layer B** manages dynamic pages—those containing request-dependent nodes—in a versioned least-recently-used cache. The cache keys combine `urlPath` with a `canonicalQuery` normalized by `canonicalRenderQuery()`, which strips all non-loop pagination parameters to maximize cache hits.

When `bumpPublishVersion()` increments the global `publishVersion` after a new deployment, Layer B entries expire lazily, preventing stale content without invalidating the entire cache. This single-flight render cache lives in [`server/publish/renderCache.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/renderCache.ts).

### Layer C: Dynamic-Island Runtime (Client Hydration)

**Layer C** activates in the browser. During the Layer A bake, nodes classified as request-dependent are replaced with `<instatic-hole>` placeholders containing a ~1KB IntersectionObserver script from [`holeRuntime.ts`](https://github.com/CoreBunch/Instatic/blob/main/holeRuntime.ts). When these elements enter the viewport, the client fetches fragments from `/_instatic/hole/<nodeId>`, and the server re-renders just that subtree using the same `publishPage` logic, optionally bypassing Layer B for per-visitor personalization.

## How the Layers Interact During Publishing

The publishing flow orchestrates all three layers through a coordinated sequence:

1. **Publish Initiation**: `publishDraftSite` writes a new `SiteDocument` snapshot server-side.
2. **Page Rendering**: `publishPage` performs a full render, using [`dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/dynamicDetection.ts) to identify nodes that become holes.
3. **Post-Processing**: The [`publishedHtmlPipeline.ts`](https://github.com/CoreBunch/Instatic/blob/main/publishedHtmlPipeline.ts) applies plugin filters, generates CSP headers, and injects runtime assets.
4. **Atomic Write**: The HTML moves through [`staticArtefact.ts`](https://github.com/CoreBunch/Instatic/blob/main/staticArtefact.ts), which executes the two-slot symlink swap to disk.
5. **Cache Versioning**: `bumpPublishVersion()` increments the global version, causing Layer B to expire old entries.

When a visitor requests a URL:

- The router normalizes the query with `canonicalRenderQuery` to generate the Layer B cache 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 hit returns cached HTML instantly; a miss triggers a single-flight render that calls `publishPage`, caches the result, and returns it.
- Any `<instatic-hole>` tags hydrate via **Layer C**, fetching subtrees from the server as the user scrolls.

## Dynamic Node Detection

The `findDynamicNodeIds` function in [`src/core/publisher/dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts) drives Layers A and C by classifying nodes through four rules (plus a promotion rule):

1. **Explicit Dynamic Flag**: Module flagged `dynamic: true`.
2. **Dynamic Bindings**: Node has a `dynamicBindings` source reading request parameters.
3. **Request-Dependent Loops**: Loop source declared `requestDependent` or `perVisitor`.
4. **Nested Dynamic Components**: Visual-Component reference whose definition contains any dynamic node.

**Rule 3.5 (Promotion)**: If a static loop contains a dynamic child, the entire loop promotes to a single hole, preventing duplicate placeholders and maintaining cache efficiency.

## Code Examples

### Rendering a Page Programmatically (Layer A Entry Point)

Use `publishPage` to render a page tree to HTML:

```typescript
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);

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

```

### Triggering a Full Site Publish (Layer A + B)

Call the admin API to execute the complete pipeline:

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

```

The handler in [`server/publish/publishSite.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/publishSite.ts) writes the snapshot, bakes every page, swaps the active slot, and calls `bumpPublishVersion()` to clear Layer B.

### Accessing the Dynamic Page Cache (Layer B)

Retrieve or render dynamic pages through the LRU cache:

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

// First request – cache miss, renders and stores
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 };
  },
);

// Subsequent requests return instantly from memory
console.log(cached.body);

```

### Hydrating Dynamic Islands on the Client (Layer C)

The runtime automatically injects this IntersectionObserver logic on pages containing holes:

```javascript
// ~1KB IIFE shipped to browser from holeRuntime.ts
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'));

```

## Key Implementation Files

| File | Responsibility |
|------|----------------|
| [`src/core/publisher/render.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/render.ts) | Top-level `publishPage` orchestration (Layer A) |
| [`src/core/publisher/renderNode.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/renderNode.ts) | Recursive walker; inserts `<instatic-hole>` (Layer C) |
| [`src/core/publisher/dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts) | Single-pass dynamic-node classifier |
| [`src/core/publisher/cssCollector.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/cssCollector.ts) | Dedupes and sanitizes per-module CSS |
| [`server/publish/staticArtefact.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/staticArtefact.ts) | Atomic two-slot symlink swap; disk writes (Layer A) |
| [`server/publish/renderCache.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/renderCache.ts) | Versioned in-memory LRU (Layer B) |
| [`server/publish/publishState.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/publishState.ts) | Global `publishVersion` handling |
| [`server/publish/holeRuntime.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/holeRuntime.ts) | Client-side hydration script (Layer C) |
| [`server/publish/publicRouter.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/publicRouter.ts) | Request routing: Layer A → Layer B → live render |
| [`server/publish/publishedHtmlPipeline.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/publishedHtmlPipeline.ts) | Post-processing: DOMPurify, CSP injection |

## Summary

- **Layer A** generates static HTML files atomically via symlink swap, ensuring zero-downtime deployments.
- **Layer B** caches dynamic pages in a versioned LRU keyed by canonical query strings, automatically expiring when `publishVersion` increments.
- **Layer C** renders request-dependent content client-side through `<instatic-hole>` elements fetched on intersection.
- **Dynamic detection** in [`dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/dynamicDetection.ts) determines which nodes become holes, promoting entire loops when necessary to maintain performance.
- All assets receive versioned hashes with `Cache-Control: immutable` headers for permanent browser caching.

## Frequently Asked Questions

### How does Instatic prevent visitors from seeing half-written files during publishing?

Instatic uses an **atomic two-slot symlink swap** implemented in [`server/publish/staticArtefact.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/staticArtefact.ts). The system writes new files to a staging directory, then swaps a symlink pointer to the new location instantaneously. Readers always access complete files through the symlink, never encountering partial writes.

### What causes the Layer B cache to invalidate?

The **global `publishVersion`** counter triggers Layer B invalidation. When `bumpPublishVersion()` increments this value (called after successful disk writes), cache entries in [`renderCache.ts`](https://github.com/CoreBunch/Instatic/blob/main/renderCache.ts) expire lazily. The version check occurs on each lookup, ensuring stale content never serves without requiring explicit cache clearing.

### Can dynamic islands bypass the Layer B cache entirely?

Yes. When the Layer C runtime fetches fragments from `/_instatic/hole/<nodeId>`, the server can optionally bypass the Layer B LRU for **per-visitor fragments**. This allows personalized content to render fresh for each user while still leveraging the static shell from Layer A for the surrounding page structure.

### How does the pipeline handle CSS and JavaScript assets?

During Layer A baking, [`siteCssBundle.ts`](https://github.com/CoreBunch/Instatic/blob/main/siteCssBundle.ts) assembles four hashed CSS bundles (`reset`, `framework`, `style`, `userStyles`), deduping module CSS via `CssCollector`. JavaScript accumulates in `renderAccumulators.jsMap` during [`render.ts`](https://github.com/CoreBunch/Instatic/blob/main/render.ts) execution. All assets receive immutable cache headers, allowing browsers to cache them indefinitely across publishes.