---
title: Creating Plugins
description: Build an EmDash plugin with hooks, storage, settings, and admin UI.
---

import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";

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.

## Plugin Structure

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
```

## Creating the Plugin

### Descriptor (build time)

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.

```typescript title="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" }],
	};
}
```

### Definition (runtime)

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

```typescript title="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;
```

## Plugin ID Rules

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

```typescript
// Valid IDs
"seo";
"audit-log";
"@emdash-cms/plugin-forms";

// Invalid IDs
"MyPlugin"; // No uppercase
"my_plugin"; // No underscores
"my.plugin"; // No dots
```

## Version Format

Use semantic versioning:

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

## Package Exports

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

```json title="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"
	}
}
```

| Export | Context | Purpose |
|--------|---------|---------|
| `"."` | Server (runtime) | `createPlugin()` / `definePlugin()` — loaded by `entrypoint` at request time |
| `"./descriptor"` | Vite (build time) | `PluginDescriptor` factory — imported in `astro.config.mjs` |
| `"./admin"` | Browser | React 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.

<Aside type="tip">
	Keep `emdash` and `react` as peer dependencies. This prevents version conflicts when the plugin
	is installed in a site.
</Aside>

## Complete Example: Audit Log Plugin

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

```typescript title="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;
```

## Testing Plugins

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`:

   ```typescript
   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.

## Portable Text Block Types

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.

### Declaring block types

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

```typescript title="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.

### Site-side rendering

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

```typescript title="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:

```typescript
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.

<Aside type="tip">
	The embeds plugin (`@emdash-cms/plugin-embeds`) is a complete example of this pattern. It provides
	YouTube, Vimeo, Tweet, Bluesky, Mastodon, Gist, and Link Preview block types with both admin
	editing fields and site-side Astro rendering components.
</Aside>

### Package exports

Add the `./astro` export to `package.json`:

```json title="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" }
	}
}
```

## Next Steps

- [Hooks Reference](/plugins/hooks/) — All available hooks with signatures
- [Storage API](/plugins/storage/) — Document collections and queries
- [Settings](/plugins/settings/) — Settings schema and KV store
- [Admin UI](/plugins/admin-ui/) — Pages and widgets
- [API Routes](/plugins/api-routes/) — REST endpoints
