Skip to content

Creating Plugins

This guide walks through building a complete EmDash plugin. You’ll learn how to structure the code, define hooks and storage, and export admin UI components.

Every plugin has two parts that run in different contexts:

  1. Plugin descriptor (PluginDescriptor) — returned by the factory function, tells EmDash how to load the plugin. Runs at build time in Vite (imported in astro.config.mjs). Must be side-effect-free and cannot use runtime APIs.
  2. Plugin definition (definePlugin()) — contains the runtime logic (hooks, routes, storage). Runs at request time on the deployed server. Has access to the full plugin context (ctx).

These must be in separate entrypoints because they execute in completely different environments:

my-plugin/
├── src/
│ ├── descriptor.ts # Plugin descriptor (runs in Vite at build time)
│ ├── index.ts # Plugin definition with definePlugin() (runs at deploy time)
│ ├── admin.tsx # Admin UI exports (React components) — optional
│ └── astro/ # Optional: Astro components for site-side rendering
│ └── index.ts # Must export `blockComponents`
├── package.json
└── tsconfig.json

The descriptor tells EmDash where to find the plugin and what admin UI it provides. This file is imported in astro.config.mjs and runs in Vite.

src/descriptor.ts
import type { PluginDescriptor } from "emdash";
// Options your plugin accepts at registration time
export interface MyPluginOptions {
enabled?: boolean;
maxItems?: number;
}
export function myPlugin(options: MyPluginOptions = {}): PluginDescriptor {
return {
id: "my-plugin",
version: "1.0.0",
entrypoint: "@my-org/plugin-example",
options,
adminEntry: "@my-org/plugin-example/admin",
componentsEntry: "@my-org/plugin-example/astro",
adminPages: [{ path: "/settings", label: "Settings", icon: "settings" }],
adminWidgets: [{ id: "status", title: "Status", size: "half" }],
};
}

The definition contains the runtime logic — hooks, routes, storage, and admin configuration. This file is loaded at request time on the deployed server.

src/index.ts
import { definePlugin } from "emdash";
import type { MyPluginOptions } from "./descriptor.js";
export function createPlugin(options: MyPluginOptions = {}) {
const maxItems = options.maxItems ?? 100;
return definePlugin({
id: "my-plugin",
version: "1.0.0",
// Declare required capabilities
capabilities: ["read:content"],
// Plugin storage (document collections)
storage: {
items: {
indexes: ["status", "createdAt", ["status", "createdAt"]],
},
},
// Admin UI configuration
admin: {
entry: "@my-org/plugin-example/admin",
settingsSchema: {
maxItems: {
type: "number",
label: "Maximum Items",
description: "Limit stored items",
default: maxItems,
min: 1,
max: 1000,
},
enabled: {
type: "boolean",
label: "Enabled",
default: options.enabled ?? true,
},
},
pages: [{ path: "/settings", label: "Settings", icon: "settings" }],
widgets: [{ id: "status", title: "Status", size: "half" }],
},
// Hook handlers
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Plugin installed");
},
"content:afterSave": async (event, ctx) => {
const enabled = await ctx.kv.get<boolean>("settings:enabled");
if (enabled === false) return;
ctx.log.info("Content saved", {
collection: event.collection,
id: event.content.id,
});
},
},
// API routes (trusted only — not available in sandboxed plugins)
routes: {
status: {
handler: async (ctx) => {
const count = await ctx.storage.items!.count();
return { count, maxItems };
},
},
},
});
}
export default createPlugin;

The id field must follow these rules:

  • Lowercase alphanumeric characters and hyphens only
  • Either simple (my-plugin) or scoped (@my-org/my-plugin)
  • Unique across all installed plugins
// Valid IDs
"seo";
"audit-log";
"@emdash-cms/plugin-forms";
// Invalid IDs
"MyPlugin"; // No uppercase
"my_plugin"; // No underscores
"my.plugin"; // No dots

Use semantic versioning:

version: "1.0.0"; // Valid
version: "1.2.3-beta"; // Valid (prerelease)
version: "1.0"; // Invalid (missing patch)

Configure package.json exports so EmDash can load each entrypoint. The descriptor and definition are separate exports because they run in different environments:

package.json
{
"name": "@my-org/plugin-example",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./descriptor": {
"types": "./dist/descriptor.d.ts",
"import": "./dist/descriptor.js"
},
"./admin": {
"types": "./dist/admin.d.ts",
"import": "./dist/admin.js"
},
"./astro": {
"types": "./dist/astro/index.d.ts",
"import": "./dist/astro/index.js"
}
},
"files": ["dist"],
"peerDependencies": {
"emdash": "^0.1.0",
"react": "^18.0.0"
}
}
ExportContextPurpose
"."Server (runtime)createPlugin() / definePlugin() — loaded by entrypoint at request time
"./descriptor"Vite (build time)PluginDescriptor factory — imported in astro.config.mjs
"./admin"BrowserReact components for admin pages/widgets
"./astro"Server (SSR)Astro components for site-side block rendering

Only include ./admin and ./astro exports if the plugin uses them.

This example demonstrates storage, lifecycle hooks, content hooks, and API routes:

src/index.ts
import { definePlugin } from "emdash";
interface AuditEntry {
timestamp: string;
action: "create" | "update" | "delete";
collection: string;
resourceId: string;
userId?: string;
}
export function createPlugin() {
return definePlugin({
id: "audit-log",
version: "0.1.0",
storage: {
entries: {
indexes: [
"timestamp",
"action",
"collection",
["collection", "timestamp"],
["action", "timestamp"],
],
},
},
admin: {
settingsSchema: {
retentionDays: {
type: "number",
label: "Retention (days)",
description: "Days to keep entries. 0 = forever.",
default: 90,
min: 0,
max: 365,
},
},
pages: [{ path: "/history", label: "Audit History", icon: "history" }],
widgets: [{ id: "recent-activity", title: "Recent Activity", size: "half" }],
},
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Audit log plugin installed");
},
"content:afterSave": {
priority: 200, // Run after other plugins
timeout: 2000,
handler: async (event, ctx) => {
const { content, collection, isNew } = event;
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: isNew ? "create" : "update",
collection,
resourceId: content.id as string,
};
const entryId = `${Date.now()}-${content.id}`;
await ctx.storage.entries!.put(entryId, entry);
ctx.log.info(`Logged ${entry.action} on ${collection}/${content.id}`);
},
},
"content:afterDelete": {
priority: 200,
timeout: 1000,
handler: async (event, ctx) => {
const { id, collection } = event;
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: "delete",
collection,
resourceId: id,
};
const entryId = `${Date.now()}-${id}`;
await ctx.storage.entries!.put(entryId, entry);
ctx.log.info(`Logged delete on ${collection}/${id}`);
},
},
},
routes: {
recent: {
handler: async (ctx) => {
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit: 10,
});
return {
entries: result.items.map((item) => ({
id: item.id,
...(item.data as AuditEntry),
})),
};
},
},
history: {
handler: async (ctx) => {
const url = new URL(ctx.request.url);
const limit = parseInt(url.searchParams.get("limit") || "50", 10);
const cursor = url.searchParams.get("cursor") || undefined;
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit,
cursor,
});
return {
entries: result.items.map((item) => ({
id: item.id,
...(item.data as AuditEntry),
})),
cursor: result.cursor,
hasMore: result.hasMore,
};
},
},
},
});
}
export default createPlugin;

Test plugins by creating a minimal Astro site with the plugin registered:

  1. Create a test site with EmDash installed.

  2. Register your plugin in astro.config.mjs:

    import myPlugin from "../path/to/my-plugin/src";
    export default defineConfig({
    integrations: [
    emdash({
    plugins: [myPlugin()],
    }),
    ],
    });
  3. Run the dev server and trigger hooks by creating/updating content.

  4. Check the console for ctx.log output and verify storage via API routes.

For unit tests, mock the PluginContext interface and call hook handlers directly.

Plugins can add custom block types to the Portable Text editor. These appear in the editor’s slash command menu and can be inserted into any portableText field.

In createPlugin(), declare blocks under admin.portableTextBlocks:

src/index.ts
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video", // Named icon: video, code, link, link-external
placeholder: "Paste YouTube URL...",
fields: [ // Block Kit fields for the editing UI
{ type: "text_input", action_id: "id", label: "YouTube URL" },
{ type: "text_input", action_id: "title", label: "Title" },
{ type: "text_input", action_id: "poster", label: "Poster Image URL" },
],
},
],
}

Each block type defines:

  • type — Block type name (used in Portable Text _type)
  • label — Display name in the slash command menu
  • icon — Icon key (video, code, link, link-external). Falls back to a generic cube.
  • placeholder — Input placeholder text
  • fields — Block Kit form fields for editing. If omitted, a simple URL input is shown.

To render your block types on the site, export Astro components from a componentsEntry:

src/astro/index.ts
import YouTube from "./YouTube.astro";
import CodePen from "./CodePen.astro";
// This export name is required — the virtual module imports it
export const blockComponents = {
youtube: YouTube,
codepen: CodePen,
};

Set componentsEntry in your plugin descriptor:

export function myPlugin(options = {}): PluginDescriptor {
return {
id: "my-plugin",
entrypoint: "@my-org/my-plugin",
componentsEntry: "@my-org/my-plugin/astro",
// ...
};
}

Plugin block components are automatically merged into <PortableText> — site authors don’t need to import anything. User-provided components take precedence over plugin defaults.

Add the ./astro export to package.json:

package.json
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" },
"./astro": { "types": "./dist/astro/index.d.ts", "import": "./dist/astro/index.js" }
}
}