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

> Discover Instatic's three layer publishing pipeline for static-first rendering. Achieve lightning fast performance with atomic disk baking, LRU caching, and dynamic islands for runtime flexibility.

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

---

**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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/render.ts) performs a full render:
   - Recursively walks the node tree via [`renderNode.ts`](https://github.com/CoreBunch/Instatic/blob/main/renderNode.ts)
   - Uses [`dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/dynamicDetection.ts) to identify which nodes become holes
   - Emits final HTML and asset references

3. **Post-processing**: The HTML passes through [`publishedHtmlPipeline.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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)

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

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

```

This entry point in [`src/core/publisher/render.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/render.ts) orchestrates the tree walk and asset collection.

### Triggering a Full Site Publish

```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 Cached Dynamic Pages (Layer B)

```typescript
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`](https://github.com/CoreBunch/Instatic/blob/main/renderCache.ts) implementation handles single-flight rendering and LRU eviction automatically.

### Hydrating Dynamic Islands (Layer C)

```javascript
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/staticArtefact.ts), ensuring zero-downtime updates
- **Layer B** maintains a versioned LRU cache in [`renderCache.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/holeRuntime.ts)
- **Dynamic detection** in [`dynamicDetection.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/siteCssBundle.ts) assembles four hashed CSS bundles (`reset`, `framework`, `style`, `userStyles`) and deduplicates module CSS via `CssCollector` in [`cssCollector.ts`](https://github.com/CoreBunch/Instatic/blob/main/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.