Plugin System Overview

On this page

EmDash’s plugin system lets you extend the CMS without modifying core code. Plugins can hook into content lifecycle events, store their own data, expose settings to administrators, and add custom UI to the admin panel.

Design Philosophy

EmDash plugins come in two flavors: sandboxed and native. Sandboxed plugins run in isolated V8 workers and can be installed from the marketplace with one click. Native plugins run in-process and are configured in code.

Prefer sandboxed plugins. They can be installed, updated, and removed from the admin UI without touching code or redeploying. Only use native plugins when you need features that require build-time integration (React admin pages, Portable Text rendering components, or page fragment injection).

Key principles:

  • Sandbox-first — Design for the sandbox; use native mode only when you need to
  • Declarative — Hooks, storage, and routes are declared at definition time, not registered dynamically
  • Type-safe — Full TypeScript support with typed context objects
  • Capability-based — Plugins declare what they need; the sandbox enforces it

What Plugins Can Do

Hook into events

Run code before or after content saves, media uploads, and plugin lifecycle events.

Store data

Persist plugin-specific data in indexed collections without writing database migrations.

Expose settings

Declare a settings schema and get an auto-generated admin UI for configuration.

Add admin pages

Create custom admin pages and dashboard widgets with React components.

Create API routes

Expose endpoints for your plugin’s admin UI or external integrations.

Make HTTP requests

Call external APIs with declared host restrictions for security.

Plugin Architecture

Every plugin is created with definePlugin():

import { definePlugin } from "emdash";

export default definePlugin({
	id: "my-plugin",
	version: "1.0.0",

	// What APIs the plugin needs access to
	capabilities: ["read:content", "network:fetch"],

	// Hosts the plugin can make HTTP requests to
	allowedHosts: ["api.example.com"],

	// Persistent storage collections
	storage: {
		entries: {
			indexes: ["userId", "createdAt"],
		},
	},

	// Event handlers
	hooks: {
		"content:afterSave": async (event, ctx) => {
			ctx.log.info("Content saved", { id: event.content.id });
		},
	},

	// REST API endpoints
	routes: {
		status: {
			handler: async (ctx) => ({ ok: true }),
		},
	},

	// Admin UI configuration
	admin: {
		settingsSchema: {
			apiKey: { type: "secret", label: "API Key" },
		},
		pages: [{ path: "/dashboard", label: "Dashboard" }],
		widgets: [{ id: "status", size: "half" }],
	},
});

Plugin Context

Every hook and route handler receives a PluginContext object with access to:

PropertyDescriptionAvailability
ctx.storagePlugin’s document collectionsAlways (if declared)
ctx.kvKey-value store for settings and stateAlways
ctx.contentRead/write site contentWith read:content or write:content
ctx.mediaRead/write media filesWith read:media or write:media
ctx.httpHTTP client for external requestsWith network:fetch
ctx.logStructured logger (debug, info, warn, error)Always
ctx.pluginPlugin metadata (id, version)Always
ctx.siteSite info: name, url, localeAlways
ctx.url()Generate absolute URLs from pathsAlways
ctx.usersRead user info: get(), getByEmail(), list()With read:users
ctx.cronSchedule tasks: schedule(), cancel(), list()Always
ctx.emailSend email: send()With email:send + provider configured

The context shape is identical across all hooks and routes. Capability-gated properties are only present when the plugin declares the required capability.

Capabilities

Capabilities determine what APIs are available in the plugin context:

CapabilityGrants Access To
read:contentctx.content.get(), ctx.content.list()
write:contentctx.content.create(), ctx.content.update(), ctx.content.delete()
read:mediactx.media.get(), ctx.media.list()
write:mediactx.media.getUploadUrl(), ctx.media.upload(), ctx.media.delete()
network:fetchctx.http.fetch() (restricted to allowedHosts)
network:fetch:anyctx.http.fetch() (unrestricted — for user-configured URLs)
read:usersctx.users.get(), ctx.users.getByEmail(), ctx.users.list()
email:sendctx.email.send() (requires a provider plugin)
email:provideRegister email:deliver exclusive hook (transport provider)
email:interceptRegister email:beforeSend / email:afterSend hooks
page:injectRegister page:metadata / page:fragments hooks

Registration

Register plugins in your Astro configuration:

import { defineConfig } from "astro/config";
import { emdash } from "emdash/astro";
import seoPlugin from "@emdash-cms/plugin-seo";
import auditLogPlugin from "@emdash-cms/plugin-audit-log";

export default defineConfig({
	integrations: [
		emdash({
			plugins: [seoPlugin({ generateSitemap: true }), auditLogPlugin({ retentionDays: 90 })],
		}),
	],
});

Plugins are resolved at build time. Order matters for hooks with the same priority—earlier plugins in the array run first.

Execution Modes

EmDash supports two plugin execution modes:

ModeDescriptionPlatform
SandboxedIsolated V8 workers with enforced limitsCloudflare only
NativeIn-process with full accessAny

In sandboxed mode, capabilities are enforced at the runtime level — plugins can only access what they declare. In native mode, capabilities are advisory and plugins have full process access.

Next Steps