Understanding the Instatic Page Tree Schema (NodeTree): Structure and Usage

Instatic models every hierarchical document—pages, visual components, and slot fills—as a single primitive called a NodeTree, defined in src/core/page-tree/treeSchema.ts as a flat map of nodes plus a root node ID, which powers validation, traversal, mutation, and persistence across the CMS.

The page tree schema—known internally as the NodeTree—is the foundational data structure in CoreBunch/Instatic that unifies how hierarchical content is stored and manipulated. Rather than maintaining separate shapes for pages, visual components, and slot fragments, the codebase relies on one canonical flat-map structure declared in src/core/page-tree/treeSchema.ts and consumed by validation, mutation, selector, and storage layers.

What Is the Instatic Page Tree Schema?

At its core, the page tree schema is an interface and runtime validator that represents any tree of nodes as two fields. In src/core/page-tree/treeSchema.ts, the runtime schema is exported as NodeTreeSchema:

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

A matching generic TypeScript interface provides compile-time type safety:

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

This generic accepts a node type—such as PageNode or VCNode—so that mutation utilities can preserve richer type information while the persisted payload is validated against BaseNodeSchema.

Key Design Principles

  • Unified primitive – As implemented in CoreBunch/Instatic, a Page is a NodeTree<PageNode> that adds metadata such as id, slug, and title according to src/core/page-tree/page.ts. A visual component exposes its structure as vc.tree, a NodeTree<VCNode>. Slot fills appear as children of a base.slot-instance node inside the consumer page’s tree.
  • Flat-map storage – Every node lives in a single Record<string, BaseNode> keyed by ID, giving O(1) node lookup regardless of tree depth.
  • Type-safe extensibility – New node sub-types extend BaseNode without requiring changes to core mutation logic because the APIs operate on the generic NodeTree<TNode> shape.

How the NodeTree Is Used Across the Codebase

Validation at the Persistence Boundary

Before a stored tree reaches the mutation API, it must pass through src/core/page-tree/operationSchema.ts. Functions such as assertValidNodeTree and parsePageNodeTree decode raw JSON against NodeTreeSchema and raise detailed validation errors when the shape is incorrect. This guards the boundary between storage or network payloads and in-memory operations.

Tree Mutations

All write operations in src/core/page-tree/mutations.ts accept a NodeTree<PageNode> draft and apply changes such as insertNode, renameNode, and moveNode. Because these utilities are implemented against the generic NodeTree<TNode> interface, the same logic is reused for visual component trees without code duplication.

Selectors and Read-Only Traversal

The src/core/page-tree/selectors.ts module exports helpers like getNode, getChildren, flattenSubtree, and isAncestor. These functions operate on any NodeTree<TNode>, providing a unified way to read hierarchical relationships without mutating state.

Persistence and Database Storage

As defined in src/core/data/schemas.ts, the pages column stores a JSON-encoded NodeTree<PageNode>. This flat-map shape is also used for HTML imports, plugin SDK builders, and the canvas renderer, ensuring that every layer of the CMS reads the same canonical structure.

Practical Examples

Creating a New Page Node Tree

import { NodeTree } from '@core/page-tree'
import { BaseNode } from '@core/page-tree/baseNode'

function createEmptyPageTree(): NodeTree<BaseNode> {
  const rootId = 'root'
  return {
    nodes: {
      [rootId]: { id: rootId, type: 'base.body', props: {}, children: [] },
    },
    rootNodeId: rootId,
  }
}

Adding a Node via the Mutation API

import { insertNode } from '@core/page-tree/mutations'
import type { NodeTree, PageNode } from '@core/page-tree'

function addParagraph(
  tree: NodeTree<PageNode>,
  parentId: string,
  paragraphId: string,
) {
  insertNode(tree, {
    id: paragraphId,
    type: 'base.paragraph',
    props: { text: 'Hello world' },
    children: [],
    parentId,
  })
}

Reading a Node and Its Children

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

const paragraph = getNode(tree, 'paragraph-1')
const childIds = getChildren(tree, paragraph?.id ?? '')

Validating a Stored Tree on Load

import { assertValidNodeTree } from '@core/page-tree'

function loadSiteTree(raw: unknown) {
  // Throws if the shape is incorrect
  assertValidNodeTree(raw as any, 'site.pages[0].tree')
  return raw as NodeTree<PageNode>
}

Summary

Frequently Asked Questions

What is the relationship between a Page and a NodeTree in Instatic?

According to src/core/page-tree/page.ts, a Page is implemented as a NodeTree<PageNode> that wraps the generic tree structure with page-level metadata such as id, slug, and title. This means every page stores its content hierarchy inside the same flat-map primitive defined by the page tree schema.

Why does Instatic use a flat map instead of a nested tree structure?

The flat Record<string, BaseNode> in the nodes field allows O(1) lookup by ID, which simplifies mutations and selector operations. Parent-child relationships are still expressed through each node’s children array, but the data itself is normalized for performance and easier patching.

How does Instatic validate an incoming NodeTree before processing it?

As implemented in CoreBunch/Instatic, functions like assertValidNodeTree and parsePageNodeTree in src/core/page-tree/operationSchema.ts decode raw JSON against NodeTreeSchema. If the payload violates the expected shape, the validator throws a detailed error before the tree reaches the mutation layer.

Can the same NodeTree logic be reused for visual components and slot fills?

Yes. Because the core utilities are generic over NodeTree<TNode>, visual components reuse the exact same mutation and selector logic via NodeTree<VCNode>. Slot fills are stored directly as children of a base.slot-instance node within a page’s tree, so they also conform to the same schema without requiring a separate data structure.

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 →