# How to Create Custom Canvas Modules for the Instatic Visual Editor

> Learn to create custom canvas modules for Instatic's visual editor. Define modules using defineModule, export them, and register with plugin.json permissions to extend Instatic's capabilities.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: how-to-guide
- Published: 2026-07-30

---

**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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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.

```typescript
// 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`](https://github.com/CoreBunch/Instatic/blob/main/modules/index.ts) and is referenced in the plugin manifest.

```typescript
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/manifest.ts) validates these fields during installation.

```json
{
  "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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/plugin.json).

```bash
bun instatic-plugin build

```

This emits a ZIP archive containing [`modules/index.js`](https://github.com/CoreBunch/Instatic/blob/main/modules/index.js) and [`plugin.json`](https://github.com/CoreBunch/Instatic/blob/main/plugin.json). Install the plugin via the admin UI or API:

```bash
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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/modulePackLoader.ts)):

1. Verifies the `modules.register` permission in the manifest.
2. Loads the [`modules/index.js`](https://github.com/CoreBunch/Instatic/blob/main/modules/index.js) bundle into an isolated QuickJS-WASM sandbox.
3. Wraps each `PluginModuleDefinition` into a `ModuleDefinition` and 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 `defineModule`** from `@instatic/plugin-sdk` to type-check your module’s ID, schema, and render function.
- **Export an array** of module definitions from [`modules/index.ts`](https://github.com/CoreBunch/Instatic/blob/main/modules/index.ts) as your module pack entrypoint.
- **Declare `modules.register`** permission and the `modules` entrypoint in [`plugin.json`](https://github.com/CoreBunch/Instatic/blob/main/plugin.json) to authorize loading.
- **Build with `instatic-plugin`** CLI 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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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.