Instatic Modules vs Visual Components: Architecture and Usage Differences
Instatic modules are atomic leaf nodes rendered by the module-engine as static HTML, while Visual Components are complete reusable sub-trees with named slots stored in the components system table.
In the CoreBunch/Instatic codebase, the editor and publisher distinguish between two types of reusable building blocks that serve different architectural purposes. While both participate in the same page-tree infrastructure, modules function as low-level rendering primitives, whereas Visual Components operate as higher-level composite patterns with structured parameter binding.
Conceptual Overview
Understanding the fundamental abstraction layers helps clarify when to implement each construct.
What Are Instatic Modules?
A module is an atomic leaf node identified by a moduleId that produces static HTML and CSS. Defined in src/modules/*, each module exports a ModuleDefinition<TProps> containing a render() function that returns HTML strings and optionally a React component for editor-time interaction.
Modules are the primary primitive for the module-engine and represent simple UI elements like buttons, headings, or images. They declare parameters as simple scalar values (strings, numbers, booleans) through a schema field, but they do not support child nodes or slots—they are strictly leaf nodes in the tree.
What Are Visual Components?
A Visual Component is a complete sub-tree stored as a row in the components system table. Defined by a VisualComponentSchema in src/core/visualComponents/schemas.ts, a VC contains a full NodeTree<VCNode> that can nest multiple BaseNode entries (such as base.container or base.heading) and includes a params array supporting complex types like slot, richText, and image.
Visual Components expose named slots by inserting base.slot-outlet nodes within their tree structure. When used on a page, they are referenced via base.visual-component-ref nodes that automatically generate base.slot-instance children to hold consumer content.
Architectural Differences
The distinction between these building blocks becomes clear when examining their file locations, data structures, and parameter handling.
File Locations and Registration
Modules reside in src/modules/* with one folder per first-party module. Registration occurs through the singleton registry in src/core/module-engine/registry.ts:
// src/modules/base/heading/index.ts
import type { ModuleDefinition } from '@core/module-engine';
import { registry } from '@core/module-engine';
export const HeadingModule: ModuleDefinition<{ text: string; level: number }> = {
moduleId: 'base.heading',
name: 'Heading',
schema: {
text: { type: 'string', defaultValue: '' },
level: { type: 'number', defaultValue: 2 },
},
render: ({ text, level }) => `<h${level}>${text}</h${level}>`,
};
registry.registerOrReplace(HeadingModule);
Visual Components live under src/core/visualComponents/*, with schemas defined in src/core/visualComponents/schemas.ts and slot synchronization logic in src/core/visualComponents/slotSync.ts.
Data Structures and Schemas
Modules export a flat definition with a render function and simple props schema. In contrast, Visual Components store entire node trees:
// src/core/visualComponents/schemas.ts (structure)
export const VisualComponentSchema = Type.Object({
id: Type.String(),
name: Type.String(),
tree: NodeTreeSchema, // the VC's node tree
params: Type.Array(VCParamSchema),
});
A Visual Component row in the components table contains a complete tree definition:
{
"id": "vc-card",
"name": "Card",
"tree": {
"nodes": {
"root": { "moduleId": "base.body", "children": ["c1"] },
"c1": { "moduleId": "base.container", "children": ["h", "s"] },
"h": { "moduleId": "base.heading", "props": { "text": "Card Title" } },
"s": { "moduleId": "base.slot-outlet", "props": { "slotName": "content" } }
},
"rootNodeId": "root"
},
"params": [
{ "id": "p1", "name": "title", "type": "string", "defaultValue": "Card Title" },
{ "id": "p2", "name": "content", "type": "slot" }
]
}
Parameters and Slots
Module parameters are scalar values defined in the ModuleDefinition schema. Modules cannot contain child nodes or slots—they are atomic by design.
Visual Component parameters use the VCParam type, supporting string, number, boolean, url, enum, color, image, richText, and slot types. Slots are declared not as parameters, but by inserting base.slot-outlet nodes in the VC tree. When a VC reference appears on a page, syncSlotInstances automatically generates corresponding base.slot-instance nodes as children of the reference node:
// In a page's node map – a visual-component-ref node
{
"id": "ref1",
"moduleId": "base.visual-component-ref",
"props": { "componentId": "vc-card", "instanceProps": { "title": "My Card" } },
"children": ["slot1"]
}
After synchronization, the children become:
[
{
"id": "slot1",
"moduleId": "base.slot-instance",
"props": { "slotName": "content", "locked": true },
"children": ["p1"]
}
]
Runtime Behavior
The rendering pipelines differ significantly between these two systems.
Module Rendering Pipeline
Modules are rendered by the module-engine through renderStandardNode. The module's publishBehavior can be standard, special, or transparent. The render function executes during both editor and publisher phases, outputting static HTML directly.
Visual Component Rendering Pipeline
Visual Components are inlined by the publisher using src/core/publisher/renderVisualComponentRef.ts. The renderVisualComponentRef function instantiates the VC tree at the reference point, substitutes propBindings with instanceProps, and walks the resulting tree to emit static HTML:
// src/core/publisher/renderVisualComponentRef.ts (excerpt)
export const renderVisualComponentRef = (refNode, vc, publishContext) => {
const instantiatedTree = instantiateVCAtRef(refNode, vc);
// Walk the instantiated tree, substitute propBindings → instanceProps
// … emit static HTML for each node …
};
Unlike modules, VCs are not rendered as single nodes but expanded in-place to their full tree structure during the publishing phase.
Mutations and State Management
Both modules and Visual Components mutate through the generic tree-mutation API (insertNode, updateNodeProps, etc.). However, Visual Components require additional synchronization logic.
When a VC definition changes, the system generates specific operations (InsertSlot, RenameSlot, DeleteSlot) to maintain consistency. The syncSlotInstances function in src/core/visualComponents/slotSync.ts ensures that base.visual-component-ref nodes maintain the correct base.slot-instance children matching the VC's current slot definitions, preserving ordering and content integrity.
Summary
- Modules are atomic leaf nodes in
src/modules/*that export aModuleDefinitionwith scalar parameters and arender()function—ideal for simple UI primitives like headings and buttons. - Visual Components are reusable sub-trees stored in the
componentstable with fullNodeTree<VCNode>structures, supporting named slots viabase.slot-outletand complex parameter binding. - Modules are rendered directly by the module-engine as static HTML, while Visual Components are inlined by the publisher through
renderVisualComponentRef.tsduring the build process. - File locations: Modules register in
src/core/module-engine/registry.ts; Visual Components define schemas insrc/core/visualComponents/schemas.tsand synchronize slots viasrc/core/visualComponents/slotSync.ts. - Slot handling is exclusive to Visual Components, automatically managed through
base.slot-instancenodes that mirror the VC'sbase.slot-outletdefinitions.
Frequently Asked Questions
When should I use a module versus a Visual Component?
Use a module when building simple, atomic UI elements like buttons, headings, or images that require only scalar parameters and render as single HTML elements. Use a Visual Component when creating composite, reusable patterns like cards or dialogs that need multiple nested nodes, named slots for content injection, and complex parameter binding across the entire sub-tree.
How do Visual Component slots differ from module children?
Modules cannot have children in the page tree—they are strictly leaf nodes. Visual Components expose named slots by placing base.slot-outlet nodes in their tree definition. When a VC is referenced on a page via base.visual-component-ref, the system automatically generates base.slot-instance nodes as children of that reference, creating the insertion points where page editors can add content. This contrasts with modules, which must handle any internal structure within their own render() logic.
Can Visual Components be nested inside other Visual Components?
While Visual Components contain standard BaseNode entries (such as base.container or base.heading) in their NodeTree, the architecture stores VCs as complete sub-tree definitions in the components table. Whether a VC can reference another VC depends on the publisher's renderVisualComponentRef implementation, which handles the inlining process. The slot synchronization logic in syncSlotInstances manages the tree structure but does not inherently prevent nesting if the VC definition includes another base.visual-component-ref node.
Where are Visual Component definitions stored compared to modules?
Module definitions live as TypeScript files in src/modules/* and register at runtime through src/core/module-engine/registry.ts. Visual Component definitions persist as JSON rows in the components system table, with their schemas validated by VisualComponentSchema in src/core/visualComponents/schemas.ts. This distinction means modules are typically code-first constructs deployed with the application, while Visual Components can be stored and retrieved dynamically from the database.
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 →