How the Instatic Module Engine Defines and Renders Block Modules
The Instatic module engine treats every reusable UI block as a first-class component defined by a ModuleDefinition<TProps> type that specifies a React canvas component, a pure render function for static HTML generation, and a property schema for the editor UI.
The Instatic module engine is the architectural backbone of the open-source CoreBunch/Instatic repository, providing a type-safe system for declaring, registering, and rendering block modules. This engine separates the interactive editing experience from the static publishing pipeline, ensuring that custom blocks behave consistently in both contexts. Understanding how the engine defines and renders block modules enables developers to extend the platform with reusable components that integrate seamlessly into the visual editor and generate optimized static output.
Module Definition Architecture
Every block module begins with a type declaration in src/core/module-engine/types.ts. The engine consumes a ModuleDefinition<TProps> interface that strictly types the module's props and declares its runtime behavior.
Core Definition Fields
A complete module definition requires these fields:
id: A unique string identifier used for registration lookups and persistence.name: The human-readable label displayed in the editor palette.component: The React component rendered in the interactive canvas.render: A pure function that returns static markup for the publishing pipeline.schema: A property schema (PropertyControlunion) that drives the editor’s property pane.
Optional fields include description for help text, icon (loaded from pixel-art-icons), and dependencies for declaring other required modules.
Type-Safe Registration
The definition lives in the module’s index file and imports the registry singleton from src/core/module-engine/registry.ts. The registry maintains a map of moduleId → ModuleDefinition and serves as the single source of truth for both the editor and server-side publisher.
// src/modules/example/index.ts
import type { ModuleDefinition } from '@core/module-engine';
import { registry } from '@core/module-engine';
import ExampleEditor from './ExampleEditor';
import { renderExample } from './renderExample';
import { exampleSchema } from './schema';
const definition: ModuleDefinition<{ title: string; color: string }> = {
id: 'example',
name: 'Example Block',
description: 'A simple illustrative block.',
icon: () => import('pixel-art-icons/icons/star-solid.tsx'),
component: ExampleEditor,
render: renderExample,
schema: exampleSchema,
};
registry.registerOrReplace(definition);
Registering Modules with the Registry
During application bootstrap, each module’s index file executes registry.registerOrReplace(definition). The registry implementation in src/core/module-engine/registry.ts validates the definition and stores it in an internal map. This registration pattern ensures that modules are discoverable by the editor for canvas rendering and by the publisher for static site generation.
The registry’s singleton pattern guarantees that module definitions are immutable after registration, preventing runtime collisions and ensuring consistent behavior across the application lifecycle.
Rendering Pipeline and Static Generation
When a page is published, the publisher (located in src/core/publisher/publishPage.ts) traverses the page tree. For every node whose type is a module, it invokes the module’s render function with the node’s current props.
Render Output Structure
The render function returns a RenderOutput object defined in src/core/module-engine/types.ts:
html: The static markup string inserted into the page.css: Optional styles emitted into the site-wide stylesheet.js: Optional JavaScript emitted as a separate file (never inlined).
// src/modules/cta/renderCTA.ts
import type { RenderOutput } from '@core/module-engine/types';
export function renderCTA(props: {
label: string;
url: string;
variant: 'primary' | 'secondary';
}): RenderOutput {
const className = props.variant === 'primary' ? 'btn-primary' : 'btn-secondary';
return {
html: `<a href="${props.url}" class="${className}">${props.label}</a>`,
css: `.btn-primary{background:#0d6efd;color:#fff}.btn-secondary{background:#6c757d;color:#fff}`
};
}
Publisher Integration
The publisher aggregates all JavaScript modules into a site-wide module-JS map (buildPublishedSiteModuleJsMap) and injects one <script defer> tag per module at the end of the HTML document. This architecture guarantees that the runtime component (component) and the static render output remain synchronized, while keeping the initial HTML payload free of inline scripts.
Property Controls and Editor Integration
The property schema defined in src/core/module-engine/propertySchema.ts declares a discriminated union of PropertyControl objects (text inputs, selects, color pickers, etc.). The editor consumes this schema to auto-generate the property pane for any selected module instance.
When a content editor adjusts a value in the property pane, the engine updates the node’s props and triggers a re-render of the React component in the canvas. The same props are later passed to the render function during publishing, ensuring visual parity between the editing environment and the final static output.
HTML Tag Badge Resolution
Modules can expose semantic metadata through the HTML tag badge system. The helper function resolveHtmlTagBadge in src/core/module-engine/htmlTagBadge.ts centralizes the dispatch logic for badges (handling omitted values, static strings, or dynamic functions). This ensures the DOM/Layers panel displays consistent contextual information—such as labeling a module as an <h1> or <section>—without duplicating logic across the codebase.
Practical Example: Building a Call-to-Action Module
Here is a complete implementation pattern for a "Call-to-Action" block that demonstrates the full lifecycle from definition to rendering:
// src/modules/cta/index.ts
import type { ModuleDefinition } from '@core/module-engine';
import { registry } from '@core/module-engine';
import CTAEditor from './CTAEditor';
import { renderCTA } from './renderCTA';
import { ctaSchema } from './schema';
const ctaDefinition: ModuleDefinition<{
label: string;
url: string;
variant: 'primary' | 'secondary';
}> = {
id: 'cta',
name: 'Call-to-Action',
icon: () => import('pixel-art-icons/icons/target-solid.tsx'),
component: CTAEditor,
render: renderCTA,
schema: ctaSchema,
};
registry.registerOrReplace(ctaDefinition);
When instantiated in a page, the module is represented as:
{
"type": "module",
"moduleId": "cta",
"props": {
"label": "Join Now",
"url": "/signup",
"variant": "primary"
}
}
The editor renders CTAEditor with live props controls, while the publisher calls renderCTA to generate the final static markup and styles.
Summary
- Module definitions use the
ModuleDefinition<TProps>interface insrc/core/module-engine/types.tsto declareid,name,component,render, andschema. - The registry singleton in
src/core/module-engine/registry.tsstores all modules viaregisterOrReplace, providing a centralized lookup for the editor and publisher. - The render function returns a
RenderOutputobject containinghtml, optionalcss, and optionaljsfor static site generation. - Property schemas in
src/core/module-engine/propertySchema.tsdrive the editor UI, ensuring type-safe prop editing that mirrors the TypeScript interfaces. - The publisher in
src/core/publisher/publishPage.tswalks the page tree and aggregates module JavaScript into deferred script tags for optimal performance.
Frequently Asked Questions
What is the difference between the component and render properties in a module definition?
The component property is a React component that provides the interactive editing experience in the visual canvas, supporting user interactions and real-time previews. The render property is a pure function that returns static HTML, CSS, and JavaScript for the publishing pipeline, ensuring the final site output is optimized and server-renderable without React runtime overhead.
How does the Instatic module engine handle JavaScript output during publishing?
When the publisher processes a module containing JavaScript, it extracts the js property from the RenderOutput object and aggregates all module scripts into a site-wide map. Instead of inlining scripts, the engine generates deferred <script> tags at the end of the HTML document, improving page load performance while maintaining modular separation of concerns.
Where are module definitions stored at runtime?
Module definitions are stored in the registry singleton located in src/core/module-engine/registry.ts. This registry maintains a mapping of module IDs to their ModuleDefinition objects and acts as the single source of truth for both the client-side editor and the server-side publishing process.
How do property controls connect to a module's TypeScript props?
The schema field in the module definition accepts a PropertyControl union type defined in src/core/module-engine/propertySchema.ts. This schema describes the shape of editable properties (text inputs, selects, color pickers), and the editor uses this metadata to generate a property pane that enforces type safety, ensuring that values passed to the module's component and render functions always match the declared TProps generic.
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 →