How to Create Custom Canvas Modules for the Instatic Visual Editor
To create custom canvas modules for Instatic, define a module using the defineModule builder, export it from a module pack entrypoint, and register it by declaring the modules.register permission in plugin.json.
Canvas modules are the reusable building blocks that appear in the Instatic visual editor’s module library and can be dragged onto any page. According to the CoreBunch/Instatic source code, these modules run inside a sandboxed QuickJS environment and are registered through a type-safe plugin SDK. This guide walks through the exact implementation pattern used to author, bundle, and install custom canvas modules.
Architecture of Instatic Canvas Modules
The Instatic canvas system separates module definitions from their runtime execution. A PluginModuleDefinition (defined in src/core/plugin-sdk/modules.ts) describes the module’s metadata, property controls, and render logic. When a plugin is activated, the host loads the module pack into a QuickJS-WASM sandbox (server/plugins/modulePackVm.ts) and wraps each definition into a full ModuleDefinition registered with the canvas module registry.
Because modules execute in a sandbox, the render function must be pure: it receives typed props and an array of child HTML strings, then returns clean HTML without accessing the DOM, fetch, or React. This security model guarantees that third-party code cannot access host globals while still allowing rich, reusable UI components.
Step-by-Step Implementation
Step 1: Define the Module Using defineModule
Create a module file that imports the defineModule builder from @instatic/plugin-sdk. This type-checked helper (implemented in src/core/plugin-sdk/builders/defineModule.ts) enforces the correct shape for your module’s ID, category, default props, and controls.
The module ID must follow the format <pluginId>.<name> and contain at least one dot. The schema object uses control.* helpers to define the property panel UI, while the render function returns HTML using the html tagged template literal.
// src/modules/my-callout.ts
import { defineModule, control, html } from '@instatic/plugin-sdk';
export default defineModule({
id: 'acme.ui-kit.callout',
name: 'Callout',
category: 'UI Kit',
defaults: {
title: 'Heads up',
body: '…',
tone: 'info' as const
},
schema: {
title: control.text('Title'),
body: control.textarea('Body', { rows: 4 }),
tone: control.select('Tone', [
{ label: 'Info', value: 'info' },
{ label: 'Warning', value: 'warning' },
]),
},
render: ({ props, children }) => html`
<aside class="callout callout--${props.tone}">
<strong>${props.title}</strong>
${props.body}
</aside>
`,
});
Step 2: Create the Module Pack Entrypoint
The plugin must expose its modules through a designated entrypoint file that default-exports an array of PluginModuleDefinition objects (or a function returning such an array). This entrypoint lives at modules/index.ts and is referenced in the plugin manifest.
// src/modules/index.ts
import callout from './my-callout';
export default [callout];
The shape of this export is validated against the interfaces in src/core/plugin-sdk/modules.ts, which defines the contract for property controls and module dependencies.
Step 3: Configure the Plugin Manifest and Permissions
Before the host will load your module pack, you must declare the modules.register permission and point the modules entrypoint to your compiled bundle. The manifest parser in src/core/plugins/manifest.ts validates these fields during installation.
{
"id": "acme.ui-kit",
"name": "UI Kit",
"version": "1.0.0",
"apiVersion": 1,
"permissions": [ "modules.register" ],
"entrypoints": {
"modules": "modules/index.js"
}
}
The modules.register permission is strictly enforced by src/core/plugins/modulePackLoader.ts; without it, the host will refuse to load the module pack into the sandbox.
Step 4: Build and Install the Plugin
Bundle your plugin using the instatic-plugin CLI (located in src/core/plugin-sdk/cli/). The build process compiles your TypeScript modules into the JavaScript bundle referenced in plugin.json.
bun instatic-plugin build
This emits a ZIP archive containing modules/index.js and plugin.json. Install the plugin via the admin UI or API:
curl -X POST -F zip=@acme-ui-kit.zip http://localhost:3000/admin/api/cms/plugins/install
Once activated, the visual editor (code in src/admin/pages/site/canvas/*) automatically populates the module library with your custom components under the specified category.
Runtime Registration Process
When the plugin activates, the host performs the following actions (see src/core/plugins/modulePackLoader.ts):
- Verifies the
modules.registerpermission in the manifest. - Loads the
modules/index.jsbundle into an isolated QuickJS-WASM sandbox. - Wraps each
PluginModuleDefinitioninto aModuleDefinitionand registers it with the canvas module registry.
This sandboxed approach ensures that custom canvas modules cannot interfere with the host application or access sensitive APIs, while the registry makes them available for drag-and-drop composition in the editor canvas.
Summary
- Use
defineModulefrom@instatic/plugin-sdkto type-check your module’s ID, schema, and render function. - Export an array of module definitions from
modules/index.tsas your module pack entrypoint. - Declare
modules.registerpermission and themodulesentrypoint inplugin.jsonto authorize loading. - Build with
instatic-pluginCLI and install the resulting ZIP to activate your modules in the visual editor. - Render functions must be pure HTML generators; they execute in a QuickJS sandbox without DOM or network access.
Frequently Asked Questions
What is the required format for a canvas module ID?
A canvas module ID must follow the pattern <pluginId>.<name> and contain at least one dot. For example, acme.ui-kit.callout is valid, but callout alone is not. This namespace prevents collisions between plugins and is validated by the defineModule builder in src/core/plugin-sdk/builders/defineModule.ts.
Can canvas modules access the DOM or make network requests?
No. Canvas modules run inside a QuickJS-WASM sandbox (server/plugins/modulePackVm.ts) that isolates them from host globals. The render function receives only props and children arrays, and must return pure HTML strings. No document, window, or fetch APIs are available inside the sandbox.
How do I add custom property controls to a canvas module?
Define your property controls in the schema object using helpers from @instatic/plugin-sdk. Available controls include control.text(), control.textarea(), control.select(), and others defined in src/core/plugin-sdk/modules.ts. These generate the corresponding UI in the editor’s property panel when the module is selected.
Where does the canvas module registry live in the Instatic codebase?
The runtime registration logic is implemented in src/core/plugins/modulePackLoader.ts, which loads module packs and validates permissions. The actual registry that stores active module definitions is consumed by the canvas UI components in src/admin/pages/site/canvas/*, where modules become available for drag-and-drop placement onto pages.
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 →