How to Define a New Module in the Instatic Module Engine

To define a new module in the Instatic module engine, create a folder under src/modules/<module-id>/, export a ModuleDefinition object conforming to the interface in src/core/plugin-sdk/modules.ts, and register it with the ModuleRegistry to make it available in the visual editor and static publisher.

In the CoreBunch/Instatic codebase, modules are reusable building blocks that users can drag onto pages in the visual editor. The module engine enforces type safety through TypeBox schemas and manages the full lifecycle from editor UI to runtime execution and static site generation.

Module Architecture and File Structure

Every module resides in its own folder under src/modules/. The folder name becomes the module’s identifier (e.g., base/text or custom/hero).

Inside this folder, you must create an index.ts (or module.ts) file that exports a module definition object. This object implements the ModuleDefinition type imported from src/core/plugin-sdk/modules.ts. The registry, publisher, and editor UI all reference this central type to ensure consistency across the stack.

The ModuleDefinition Interface

The ModuleDefinition interface in src/core/plugin-sdk/modules.ts requires specific properties that control how your module behaves in the editor and final output:

  • moduleId – A unique string identifier (e.g., 'base.helloWorld') used as the key in ModuleRegistry.
  • title – The human-readable name displayed in the module picker UI.
  • icon – A dynamic import pointing to a Pixel Art icon for the visual editor.
  • propsSchema – A TypeBox schema defining configurable properties.
  • defaultProps – Optional default values for the props defined in the schema.
  • render – A function receiving the node's props and returning HTML (or a React component for the editor preview).
  • css – Optional CSS string scoped to this module; deduplicated by moduleId during publishing.
  • js – Optional client-side JavaScript executed once per page when this module is present.

Props Schema and TypeBox Validation

Instatic uses TypeBox (@sinclair/typebox) to validate all untyped boundaries. Your propsSchema ensures that data persisted from the editor matches the expected types at runtime.

import { Type } from '@sinclair/typebox';

const PropsSchema = Type.Object({
  heading: Type.String({ default: 'Hello' }),
  level: Type.Union([Type.Literal(1), Type.Literal(2), Type.Literal(3)])
});

The publisher and sandbox validate node data against this schema before rendering, preventing runtime type errors in the generated static site.

Render Functions and Asset Injection

The render function receives the validated props and must return an HTML string:

render: ({ props }) => {
  return `<h${props.level}>${props.heading}</h${props.level}>`;
}

If you provide a css property, the publisher in src/core/publisher/renderConfig.ts collects these styles and deduplicates them by moduleId to avoid redundant CSS in the final output. Similarly, the js property defines client-side behavior that the sandbox VM in server/plugins/quickjs/bootstrap/src/modulePackRuntime.ts executes once per page when the module is present.

Registering Your Module with the Registry

After defining your module, you must register it with the central ModuleRegistry. In src/core/plugin-sdk/modules.ts, import your definition and call registry.register():

// src/core/plugin-sdk/modules.ts
import { helloWorldModule } from '../../modules/base/helloWorld';

export const registry = new ModuleRegistry();
registry.register(helloWorldModule);

The ModuleRegistry maintains a map of moduleId → definition and exposes get(moduleId) and getAll() methods. The admin UI in src/admin/pages/site/module-picker/moduleInserterModel.ts calls registry.getAll() to populate the module picker, while the publisher uses registry.get(node.moduleId) to retrieve rendering logic during static site generation.

Runtime Rendering and Deduplication

During the publishing phase, src/core/publisher/renderConfig.ts walks the page node tree. For each node, it retrieves the corresponding ModuleDefinition from the registry. If the definition provides a render function, the publisher executes it; otherwise, it falls back to a generic <div> wrapper.

CSS and JS deduplication happen automatically based on the moduleId. The publisher ensures that a module’s CSS is injected only once per page regardless of how many instances of that module appear, and the sandbox VM loads the module’s JavaScript exactly once per moduleId when hydrating the page.

Minimal HelloWorld Module Example

Here is a complete, runnable example of a minimal text module:

// src/modules/base/helloWorld/index.ts
import { ModuleDefinition } from '@core/plugin-sdk/modules';
import { Type } from '@sinclair/typebox';

const PropsSchema = Type.Object({});

export const helloWorldModule: ModuleDefinition = {
  moduleId: 'base.helloWorld',
  title: 'Hello World',
  icon: () => import('pixel-art-icons/icons/clipboard.svg'),
  propsSchema: PropsSchema,
  defaultProps: {},
  
  render: ({ props }) => {
    return `<p>Hello World – enjoy Instatic!</p>`;
  },
  
  css: `
    .hello-world { color: var(--editor-surface-2); }
  `,
  
  js: `
    console.log('Hello World module loaded');
  `
};
// src/core/plugin-sdk/modules.ts
import { helloWorldModule } from '../../modules/base/helloWorld';

export const registry = new ModuleRegistry();
registry.register(helloWorldModule);

Once registered, this module appears immediately in the visual editor’s module picker and renders correctly in both the sandbox preview and the final static HTML output.

Summary

  • File Location: Create a folder under src/modules/<module-id>/ with an index.ts exporting a ModuleDefinition.
  • Type Safety: Define propsSchema using TypeBox to validate editor input and prevent runtime errors.
  • Registration: Import your module into src/core/plugin-sdk/modules.ts and call registry.register() to activate it.
  • Rendering: Implement the render function to generate HTML; optionally provide css and js for styling and client-side behavior.
  • Deduplication: The publisher in src/core/publisher/renderConfig.ts automatically deduplicates CSS and JS per moduleId during static site generation.

Frequently Asked Questions

What file structure is required for a new Instatic module?

Create a folder under src/modules/ using your desired module identifier as the folder name. Inside, place an index.ts (or module.ts) that exports a ModuleDefinition object conforming to the interface in src/core/plugin-sdk/modules.ts. The folder name becomes the canonical path for imports and registration.

How does the Instatic module engine validate module props?

The engine uses TypeBox schemas defined in the propsSchema property of your ModuleDefinition. When the publisher in src/core/publisher/renderConfig.ts renders a page, it validates node data against this schema. This type-safe approach ensures that data persisted from the visual editor matches the expectations of your render function at build time.

Where is module CSS and JavaScript deduplicated during publishing?

Deduplication occurs in src/core/publisher/renderConfig.ts. The publisher tracks which moduleId values have already contributed CSS and JS to the current page. Even if a module appears multiple times on the same page, its styles and scripts are injected only once, ensuring optimal bundle sizes in the static output.

How does the visual editor discover available modules?

The editor UI queries the ModuleRegistry via registry.getAll() in src/admin/pages/site/module-picker/moduleInserterModel.ts. This model populates the module picker sidebar, using each definition’s title and icon properties to display available components. Registration in src/core/plugin-sdk/modules.ts is the only step required to make a module discoverable in the UI.

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 →