How Instatic's NodeTree<TNode Primitive Powers Pages, Components, and Slot Fills

Instatic's NodeTree<TNode> is a flat, ID-keyed tree structure that unifies pages, visual components, and slot fills under a single generic type, enabling type-safe mutations and O(1) node lookups through a shared nodes map and rootNodeId pattern.

The CoreBunch/Instatic repository implements every hierarchical document as a generic NodeTree<TNode>. This architectural primitive eliminates duplication between pages and visual components while maintaining strict TypeScript type safety through its parameterized node types.

The Flat Tree Architecture of Instatic NodeTree

At its core, NodeTree<TNode> abandons traditional nested JSON structures in favor of a flat map. The shape consists of two required fields: a nodes dictionary keyed by string IDs, and a single rootNodeId string that serves as the entry point for traversal.

In src/core/page-tree/treeSchema.ts, the schema defines this structure using TypeBox:

export const NodeTreeSchema = Type.Object({
  nodes: Type.Record(Type.String(), BaseNodeSchema),
  rootNodeId: Type.String(),
})

export interface NodeTree<TNode extends BaseNode = BaseNode> {
  nodes: Record<string, TNode>
  rootNodeId: string
}

This flat architecture delivers three critical advantages. O(1) node lookup eliminates recursive traversal when accessing specific nodes. Cyclic-safe walks become trivial because parent-child relationships are reconstructed via ID references rather than object nesting. Database serialization requires no custom transformers since the structure is already normalized for storage.

Generic Type Safety with NodeTree

The TNode generic parameter allows the same tree container to hold different node shapes while preserving compile-time type safety. The interface defaults to BaseNode but accepts specializations like PageNode or VCNode (Visual Component Node).

Runtime validation operates only on the shared BaseNode shape through NodeTreeSchema. The mutation layer trusts the TypeScript generic type for development ergonomics, while the persistence layer validates against the schema-boundary. This separation means NodeTree<PageNode> and NodeTree<VCNode> share identical runtime footprints but offer distinct autocomplete and type-checking during development.

Three Applications: Pages, Visual Components, and Slot Fills

Pages as NodeTree

In src/core/page-tree/page.ts, a Page structurally is a NodeTree<PageNode> extended with metadata fields. The schema inherits directly from NodeTreeSchema:

export const PageSchema = Type.Object({
  ...NodeTreeSchema.properties,
  nodes: Type.Record(Type.String(), PageNodeSchema),
  id: Type.String(),
  slug: Type.String(),
  title: Type.String(),
})

By spreading NodeTreeSchema.properties and overriding the nodes record with PageNodeSchema, Instatic guarantees that any Page instance can be treated as a generic NodeTree<BaseNode> while retaining page-specific node capabilities.

Visual Components and VCNode Trees

Visual components store their hierarchical definition in a NodeTree<VCNode> accessible via vc.tree. The builder logic in src/core/plugin-sdk/builders/tree.ts flattens nested component definitions into this standardized shape. The VCNode type—defined in src/core/visualComponents/schemas.ts—extends BaseNode with component-specific properties while conforming to the same flat-map structure.

Slot Fills and Subtree Injection

When a visual component is dropped into a page slot, Instatic creates a base.slot-instance node inside the consumer page's tree. The children of this node form a subtree that follows the identical NodeTree shape, allowing slot fills to leverage the same traversal and mutation logic as root-level documents.

Generic Traversal and Mutation APIs

Reading with Selectors

The src/core/page-tree/selectors.ts module exports generic functions like getNode and getChildren that operate on any NodeTree<TNode>. Because selectors interact only with the shared tree structure, they work identically across pages and components:

import { getNode, getChildren } from '@core/page-tree/selectors'

function listAllHeadings<T extends BaseNode>(tree: NodeTree<T>): string[] {
  const walk = (nodeId: string, out: string[] = []): string[] => {
    const node = getNode(tree, nodeId)
    if (!node) return out
    if (node.type === 'base.heading') out.push(node.props.text ?? '')
    for (const child of getChildren(tree, nodeId)) {
      walk(child.id, out)
    }
    return out
  }
  return walk(tree.rootNodeId)
}

Writing with Mutations

All tree mutations accept a generic NodeTree<TNode> draft wrapped by mutateActiveTree. The src/core/page-tree/mutations.ts file implements primitives like insertNode that remain agnostic to the concrete node type:

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

function addButtonToVC(vcTree: NodeTree<VCNode>, parentId: string) {
  mutateActiveTree(vcTree, draft => {
    insertNode(draft, {
      id: 'button-1',
      type: 'base.button',
      props: { label: 'Click me' },
      children: [],
    }, parentId)
  })
}

The same insertNode function works for pages when passed a NodeTree<PageNode> draft, with TypeScript inferring the appropriate generic constraint.

Validation and Persistence

Before storage, trees undergo validation against NodeTreeSchema using TypeBox helpers. This occurs at the persistence boundary, ensuring that serialized data conforms to the expected flat-map structure regardless of the specific TNode specialization used during runtime operations.

import { NodeTreeSchema } from '@core/page-tree/treeSchema'
import { compiledEncode } from '@core/utils/typeboxHelpers'

function persistTree<T extends BaseNode>(tree: NodeTree<T>) {
  return compiledEncode(NodeTreeSchema, tree)
}

Summary

  • Instatic NodeTree uses a flat Record<string, TNode> plus rootNodeId instead of nested objects, enabling O(1) lookups and safe serialization.
  • The generic TNode parameter provides type-specific operations for PageNode, VCNode, and BaseNode while sharing runtime validation logic.
  • Pages extend NodeTree directly in src/core/page-tree/page.ts, while visual components embed trees via vc.tree as defined in the plugin SDK.
  • Slot fills inject subtrees using the same NodeTree shape, maintaining consistency across document boundaries.
  • Generic selectors and mutations in src/core/page-tree/selectors.ts and mutations.ts operate on any tree specialization without type-specific branching.

Frequently Asked Questions

What is the advantage of a flat nodes map over a nested tree structure?

The flat nodes map provides O(1) node lookup by ID, eliminates the risk of circular references during serialization, and simplifies parent-index reconstruction. This design makes deep traversal operations like flattenSubtree safer and reduces database storage complexity since the structure maps directly to JSON columns without custom transformers.

How does Instatic ensure type safety when the same mutations work for pages and components?

Instatic leverages TypeScript generics to enforce type safety at compile time while using a shared runtime schema. The NodeTree<TNode> interface accepts type parameters like PageNode or VCNode, allowing insertNode and getChildren to return correctly typed objects. Runtime validation against NodeTreeSchema occurs only at persistence boundaries, ensuring data integrity without sacrificing development ergonomics.

Can NodeTree handle deeply nested component hierarchies?

Yes. The flat-map structure imposes no depth limits on logical nesting. Parent-child relationships are tracked via ID references in the children arrays of each node, not by object nesting. The src/core/plugin-sdk/builders/tree.ts builder flattens arbitrarily deep component hierarchies into the NodeTree<VCNode> format while preserving the original structural semantics.

Where is the NodeTree schema validated in the Instatic codebase?

Validation occurs at the persistence boundary using NodeTreeSchema defined in src/core/page-tree/treeSchema.ts. The compiledEncode helper from @core/utils/typeboxHelpers validates tree structures before database storage. The mutation layer operates on TypeScript interfaces and trusts the pre-validated data, creating a clear separation between compile-time type checking and runtime validation.

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 →