How Page Trees and Component Trees Are Managed in Instatic

Instatic unifies pages, visual components, and slot fills into a single homogeneous NodeTree structure that uses a flat map of nodes together with a rootNodeId, enabling consistent mutations across the entire document model.

Instatic handles complex document hierarchies through a unified tree architecture rather than maintaining separate data structures for different entity types. Both pages and visual components (VCs) are implemented as NodeTree instances in the CoreBunch/Instatic repository, with the only distinctions being module IDs and metadata fields. This design powers the visual editor, component system, and static publishing pipeline through a single mutation engine and persistence schema.

The Unified NodeTree Architecture

NodeTree as the Core Primitive

At the foundation of Instatic’s document model lies the NodeTree primitive defined in src/core/page-tree/treeSchema.ts. The tree is stored as a flat map of nodes (Record<id, BaseNode>) alongside a rootNodeId that anchors the hierarchy. This structure avoids deeply nested JSON and enables O(1) node lookups while maintaining parent-child relationships through explicit ID references.

Extending Base Nodes for Pages and Components

Every node in the system extends the base shape declared in src/core/page-tree/baseNode.ts, which includes common fields such as id, moduleId, children, and props. Specific node types add specialized metadata:

  • Pages are NodeTrees that add title, slug, and dynamicBindings fields
  • Visual components store their own NodeTree as vc.tree
  • Slot fills persist as children of locked base.slot-instance nodes living directly inside the consumer page’s tree

This unified shape means operations like duplication, movement, and deletion work identically whether you are editing a page root or a nested component instance.

Mutating Tree Structures

Node-Level Mutation Helpers

The engine provides 11 pure “node-level” mutation helpers in src/core/page-tree/mutations.ts that operate on a mutable draft supplied by Zustand’s Mutative middleware. These include:

  • insertNode – Validates the parent and appends the node, updating the parentId reference
  • deleteNode – Removes a node and its subtree
  • moveNode – Relocates a node within the hierarchy
  • duplicateNode – Clones a subtree using cloneNodeWithRemap from src/core/page-tree/cloneNode.ts
  • wrapNode – Creates a container around existing nodes (Figma-style grouping)

Page-Level Operations

Actions affecting the page roster reside in src/core/page-tree/pageMutations.ts. Functions like addPage, deletePage, and duplicatePage reuse the node-level helpers. For example, duplicatePage clones an entire page by calling cloneNodeWithRemap on the page’s root node, ensuring all internal IDs are remapped while preserving the tree structure.

The Operation Dispatcher

The editor and plugins communicate with the tree through a tagged-union TreeOperation dispatched to applyTreeOperation in src/core/page-tree/mutations.ts. This function branches to the appropriate helper and returns the mutated tree plus the affectedNodeIds that may need cache invalidation, providing a clean boundary between UI actions and state mutations.

Visual Components and Slot Management

Slot Instance Synchronization

When a visual component reference is dropped onto a page, syncSlotInstances in src/core/visualComponents/slotSync.ts automatically materializes one base.slot-instance child for each slot param defined by the VC. These slot-instance nodes are locked—meaning they cannot be deleted or moved directly—and carry the user-authored content as ordinary children. The function also deletes stray slot instances when the VC’s slot definitions change, keeping the tree in sync with the component schema.

Rendering Slot Fills

During publishing, the renderer walks the page tree, locates base.slot-instance children, and injects their grandchildren at the matching base.slot-outlet position. This logic lives in src/core/publisher/renderVisualComponentRef.ts. By keeping slot fills as part of the same tree rather than a separate data structure, Instatic ensures that tree-wide operations like duplication and serialization remain consistent across page boundaries.

Working with the Tree API

Creating a New Page

Pages are created through the page-level API, which initializes a base.body root node:

import { addPage } from '@core/page-tree/pageMutations'

const newPage = addPage(siteDocument, 'About us', 'about')

The addPage function creates a fresh base.body node via createNode and appends the page to site.pages.

Inserting Nodes

To insert a component into a page tree:

import { insertNode, createNode } from '@core/page-tree/mutations'

const buttonNode = createNode('base.button', { label: 'Click me' })
insertNode(tree, buttonNode, parentNodeId)

insertNode validates the parent and updates the parentId on the new node.

Duplicating Subtrees

The editor’s duplicate command uses the tree mutation helper:

import { duplicateNode } from '@core/page-tree/mutations'

const newRootId = duplicateNode(tree, originalNodeId)

This builds an ID map, clones every node with cloneNodeWithRemap, re-links parent IDs, and splices the clone into the parent’s children array immediately after the original.

Wrapping Nodes

Group selections into containers using:

import { wrapNodes } from '@core/page-tree/mutations'

const wrapperId = wrapNodes(tree, ['nodeA', 'nodeB'], 'base.container')

wrapNodes reduces the selection to top-level nodes, finds the closest common ancestor using helpers from src/core/page-tree/selectors.ts, creates a wrapper node, and moves the branches under it.

Synchronizing Slots

Keep slot instances aligned with VC definitions:

import { syncSlotInstances } from '@core/visualComponents/slotSync'

syncSlotInstances({
  vcNodeId: visualComponentRefId,
  vcTree: vcTree,
  targetTree: pageTree,
})

This ensures a base.slot-instance child exists for every slot param, removes extraneous children, and marks all slot-instance nodes as locked.

Dispatching Operations from Plugins

Plugins send structured operations through the central dispatcher:

import { applyTreeOperation } from '@core/page-tree/mutations'

const result = applyTreeOperation(pageTree, {
  kind: 'moveNode',
  nodeId: 'node123',
  parentId: 'parent456',
  index: 2,
})

The result contains affectedNodeIds indicating which nodes require cache invalidation.

Summary

Frequently Asked Questions

How does Instatic handle deeply nested component hierarchies?

Instatic treats nested components as ordinary node subtrees. When a visual component contains another VC reference, it stores its own NodeTree at vc.tree, and the parent page stores a base.visual-component-ref node. Slot fills are nested as children of base.slot-instance nodes. Because all operations use the same mutation engine in src/core/page-tree/mutations.ts, the depth of nesting does not require special handling—the tree utilities getParent, isAncestor, and findClosestCommonAncestor from src/core/page-tree/selectors.ts traverse the flat node map efficiently regardless of depth.

What prevents users from accidentally deleting slot containers?

Slot-instance nodes are marked as locked during synchronization by syncSlotInstances in src/core/visualComponents/slotSync.ts. The editor UI respects this lock state, preventing direct deletion or movement of these container nodes. Users can only edit the user-authored content stored as ordinary children of the slot instance, ensuring the structural integrity required by the visual component’s definition remains intact.

How are tree mutations optimized for performance in large documents?

Mutations operate on a mutable draft produced by Zustand’s Mutative middleware, allowing direct modifications without immutable copying overhead. The flat Record<id, BaseNode> structure means getParent and ancestry checks in src/core/page-tree/selectors.ts run in constant time by ID lookup rather than tree traversal. Additionally, applyTreeOperation returns only the affectedNodeIds, enabling precise cache invalidation rather than triggering re-renders of the entire document tree.

Can nodes be moved between different pages?

The current mutation API in src/core/page-tree/mutations.ts operates within a single NodeTree instance. Moving content between pages requires extracting the subtree via duplicateNode (which uses cloneNodeWithRemap to generate new IDs) and then inserting it into the target page’s tree, followed by deletion from the source. Cross-page operations are handled at the application layer in src/core/page-tree/pageMutations.ts to ensure ID remapping and reference consistency across the site document.

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 →