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
Pageis aNodeTree<PageNode>that adds metadata such asid,slug, andtitleaccording tosrc/core/page-tree/page.ts. A visual component exposes its structure asvc.tree, aNodeTree<VCNode>. Slot fills appear as children of abase.slot-instancenode 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
BaseNodewithout requiring changes to core mutation logic because the APIs operate on the genericNodeTree<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
- The page tree schema (
NodeTree) in CoreBunch/Instatic is a flat-map structure composed ofnodesandrootNodeId, defined insrc/core/page-tree/treeSchema.ts. - A generic
NodeTree<TNode>interface preserves type safety for pages, visual components, and slot fills whileBaseNodeSchemavalidates runtime payloads. - Validation utilities in
src/core/page-tree/operationSchema.tsenforce structural correctness before mutations occur. - Mutation helpers in
src/core/page-tree/mutations.tsdraft changes across anyNodeTree<TNode>, and selector utilities insrc/core/page-tree/selectors.tsprovide read-only traversal. - The same JSON shape is persisted in the database via
src/core/data/schemas.ts, powering imports, SDK builders, and the rendering canvas.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →