Plugin Hooks

On this page

Hooks let plugins run code in response to events. All hooks receive an event object and the plugin context. Hooks are declared at plugin definition time, not registered dynamically at runtime.

Hook Signature

Every hook handler receives two arguments:

async (event: EventType, ctx: PluginContext) => ReturnType;
  • event — Data about the event (content being saved, media uploaded, etc.)
  • ctx — The plugin context with storage, KV, logging, and capability-gated APIs

Hook Configuration

Hooks can be declared as a simple handler or with full configuration:

Simple

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

Full Config

hooks: {
  "content:afterSave": {
    priority: 100,
    timeout: 5000,
    dependencies: ["audit-log"],
    errorPolicy: "continue",
    handler: async (event, ctx) => {
      ctx.log.info("Content saved");
    }
  }
}

Configuration Options

OptionTypeDefaultDescription
prioritynumber100Execution order. Lower numbers run first.
timeoutnumber5000Maximum execution time in milliseconds.
dependenciesstring[][]Plugin IDs that must run before this hook.
errorPolicy"abort" | "continue""abort"Whether to stop the pipeline on error.
exclusivebooleanfalseOnly one plugin can be the active provider. Used for email:deliver and comment:moderate.
handlerfunctionThe hook handler function. Required.

Lifecycle Hooks

Lifecycle hooks run during plugin installation, activation, and deactivation.

plugin:install

Runs once when the plugin is first added to a site.

"plugin:install": async (_event, ctx) => {
  ctx.log.info("Installing plugin...");

  // Seed default data
  await ctx.kv.set("settings:enabled", true);
  await ctx.storage.items!.put("default", { name: "Default Item" });
}

Event: {}
Returns: Promise<void>

plugin:activate

Runs when the plugin is enabled (after install or when re-enabled).

"plugin:activate": async (_event, ctx) => {
  ctx.log.info("Plugin activated");
}

Event: {}
Returns: Promise<void>

plugin:deactivate

Runs when the plugin is disabled (but not removed).

"plugin:deactivate": async (_event, ctx) => {
  ctx.log.info("Plugin deactivated");
  // Release resources, pause background work
}

Event: {}
Returns: Promise<void>

plugin:uninstall

Runs when the plugin is removed from a site.

"plugin:uninstall": async (event, ctx) => {
  ctx.log.info("Uninstalling plugin...");

  if (event.deleteData) {
    // User opted to delete plugin data
    const result = await ctx.storage.items!.query({ limit: 1000 });
    await ctx.storage.items!.deleteMany(result.items.map(i => i.id));
  }
}

Event: { deleteData: boolean }
Returns: Promise<void>

Content Hooks

Content hooks run during create, update, and delete operations.

content:beforeSave

Runs before content is saved. Return modified content or void to keep it unchanged. Throw to cancel the save.

"content:beforeSave": async (event, ctx) => {
  const { content, collection, isNew } = event;

  // Validate
  if (collection === "posts" && !content.title) {
    throw new Error("Posts require a title");
  }

  // Transform
  if (content.slug) {
    content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
  }

  return content;
}

Event:

{
	content: Record<string, unknown>; // Content data being saved
	collection: string; // Collection name
	isNew: boolean; // True if creating, false if updating
}

Returns: Promise<Record<string, unknown> | void>

content:afterSave

Runs after content is successfully saved. Use for side effects like notifications, logging, or syncing to external systems.

"content:afterSave": async (event, ctx) => {
  const { content, collection, isNew } = event;

  ctx.log.info(`${isNew ? "Created" : "Updated"} ${collection}/${content.id}`);

  // Trigger external sync
  if (ctx.http) {
    await ctx.http.fetch("https://api.example.com/webhook", {
      method: "POST",
      body: JSON.stringify({ event: "content:save", id: content.id })
    });
  }
}

Event:

{
	content: Record<string, unknown>; // Saved content (includes id, timestamps)
	collection: string;
	isNew: boolean;
}

Returns: Promise<void>

content:beforeDelete

Runs before content is deleted. Return false to cancel the deletion, true or void to allow it.

"content:beforeDelete": async (event, ctx) => {
  const { id, collection } = event;

  // Prevent deletion of protected content
  if (collection === "pages" && id === "home") {
    ctx.log.warn("Cannot delete home page");
    return false;
  }

  return true;
}

Event:

{
	id: string; // Content ID being deleted
	collection: string;
}

Returns: Promise<boolean | void>

content:afterDelete

Runs after content is successfully deleted.

"content:afterDelete": async (event, ctx) => {
  const { id, collection } = event;

  ctx.log.info(`Deleted ${collection}/${id}`);

  // Clean up related plugin data
  await ctx.storage.cache!.delete(`${collection}:${id}`);
}

Event:

{
	id: string;
	collection: string;
}

Returns: Promise<void>

content:afterPublish

Runs after content is published (promoted from draft to live). Use for side effects like cache invalidation, notifications, or syncing to external systems. Requires read:content capability.

"content:afterPublish": async (event, ctx) => {
  const { content, collection } = event;

  ctx.log.info(`Published ${collection}/${content.id}`);

  // Notify external system
  if (ctx.http) {
    await ctx.http.fetch("https://api.example.com/webhook", {
      method: "POST",
      body: JSON.stringify({ event: "content:publish", id: content.id })
    });
  }
}

Event:

{
	content: Record<string, unknown>; // Published content (includes id, timestamps)
	collection: string;
}

Returns: Promise<void>

content:afterUnpublish

Runs after content is unpublished (reverted from live to draft). Use for side effects like cache invalidation or notifying external systems. Requires read:content capability.

"content:afterUnpublish": async (event, ctx) => {
  const { content, collection } = event;

  ctx.log.info(`Unpublished ${collection}/${content.id}`);
}

Event:

{
	content: Record<string, unknown>; // Unpublished content
	collection: string;
}

Returns: Promise<void>

Media Hooks

Media hooks run during file uploads.

media:beforeUpload

Runs before a file is uploaded. Return modified file info or void to keep it unchanged. Throw to cancel the upload.

"media:beforeUpload": async (event, ctx) => {
  const { file } = event;

  // Validate file type
  if (!file.type.startsWith("image/")) {
    throw new Error("Only images are allowed");
  }

  // Validate file size (10MB max)
  if (file.size > 10 * 1024 * 1024) {
    throw new Error("File too large");
  }

  // Rename file
  return {
    ...file,
    name: `${Date.now()}-${file.name}`
  };
}

Event:

{
	file: {
		name: string; // Original filename
		type: string; // MIME type
		size: number; // Size in bytes
	}
}

Returns: Promise<{ name: string; type: string; size: number } | void>

media:afterUpload

Runs after a file is successfully uploaded.

"media:afterUpload": async (event, ctx) => {
  const { media } = event;

  ctx.log.info(`Uploaded ${media.filename}`, {
    id: media.id,
    size: media.size,
    mimeType: media.mimeType
  });
}

Event:

{
	media: {
		id: string;
		filename: string;
		mimeType: string;
		size: number | null;
		url: string;
		createdAt: string;
	}
}

Returns: Promise<void>

Hook Execution Order

Hooks run in this order:

  1. Hooks with lower priority values run first
  2. For equal priorities, hooks run in plugin registration order
  3. Hooks with dependencies wait for those plugins to complete
// Plugin A
"content:afterSave": {
  priority: 50,  // Runs first
  handler: async () => {}
}

// Plugin B
"content:afterSave": {
  priority: 100,  // Runs second (default priority)
  handler: async () => {}
}

// Plugin C
"content:afterSave": {
  priority: 200,
  dependencies: ["plugin-a"],  // Runs after A, even if priority was lower
  handler: async () => {}
}

Error Handling

When a hook throws or times out:

  • errorPolicy: "abort" — The entire pipeline stops. The original operation may fail.
  • errorPolicy: "continue" — The error is logged, and remaining hooks still run.
"content:afterSave": {
  timeout: 5000,
  errorPolicy: "continue",  // Don't fail the save if this hook fails
  handler: async (event, ctx) => {
    // External API call that might fail
    await ctx.http!.fetch("https://unreliable-api.com/notify");
  }
}

Timeouts

Hooks have a default timeout of 5000ms (5 seconds). Increase it for operations that may take longer:

"content:afterSave": {
  timeout: 30000,  // 30 seconds
  handler: async (event, ctx) => {
    // Long-running operation
  }
}

Public Page Hooks

Public page hooks let plugins contribute to the <head> and <body> of rendered pages. Templates opt in using the <EmDashHead>, <EmDashBodyStart>, and <EmDashBodyEnd> components from emdash/ui.

page:metadata

Contributes typed metadata to <head> — meta tags, OpenGraph properties, canonical/alternate links, and JSON-LD structured data. Works in both native and sandboxed modes.

Core validates, deduplicates, and renders the contributions. Plugins return structured data, never raw HTML.

"page:metadata": async (event, ctx) => {
  if (event.page.kind !== "content") return null;

  return {
    kind: "jsonld",
    id: `schema:${event.page.content?.collection}:${event.page.content?.id}`,
    graph: {
      "@context": "https://schema.org",
      "@type": "BlogPosting",
      headline: event.page.pageTitle ?? event.page.title,
      description: event.page.description,
    },
  };
}

Event:

{
  page: {
    url: string;
    path: string;
    locale: string | null;
    kind: "content" | "custom";
    pageType: string;
    title: string | null;
    pageTitle?: string | null;
    description: string | null;
    canonical: string | null;
    image: string | null;
    content?: { collection: string; id: string; slug: string | null };
  }
}

Returns: PageMetadataContribution | PageMetadataContribution[] | null

Contribution types:

KindRendersDedupe key
meta<meta name="..." content="...">key or name
property<meta property="..." content="...">key or property
link<link rel="canonical|alternate" href="...">canonical: singleton; alternate: key or hreflang
jsonld<script type="application/ld+json">id (if present)

First contribution wins for any dedupe key. Link hrefs must be HTTP or HTTPS.

page:fragments

Contributes raw HTML, scripts, or markup to page insertion points. Native plugins only — sandboxed plugins cannot use this hook.

"page:fragments": async (event, ctx) => {
  return {
    kind: "external-script",
    placement: "head",
    src: "https://www.googletagmanager.com/gtm.js?id=GTM-XXXXX",
    async: true,
  };
}

Returns: PageFragmentContribution | PageFragmentContribution[] | null

Placements: "head", "body:start", "body:end". Templates that omit a component for a placement silently ignore contributions targeting it.

Hooks Reference

HookTriggerReturnExclusive
plugin:installFirst plugin installationvoidNo
plugin:activatePlugin enabledvoidNo
plugin:deactivatePlugin disabledvoidNo
plugin:uninstallPlugin removedvoidNo
content:beforeSaveBefore content saveModified content or voidNo
content:afterSaveAfter content savevoidNo
content:beforeDeleteBefore content deletefalse to cancel, else allowNo
content:afterDeleteAfter content deletevoidNo
content:afterPublishAfter content publishvoidNo
content:afterUnpublishAfter content unpublishvoidNo
media:beforeUploadBefore file uploadModified file info or voidNo
media:afterUploadAfter file uploadvoidNo
cronScheduled task firesvoidNo
email:beforeSendBefore email deliveryModified message, false, or voidNo
email:deliverDeliver email via transportvoidYes
email:afterSendAfter email deliveryvoidNo
comment:beforeCreateBefore comment storedModified event, false, or voidNo
comment:moderateDecide comment status{ status, reason? }Yes
comment:afterCreateAfter comment storedvoidNo
comment:afterModerateAdmin changes comment statusvoidNo
page:metadataPage renderContributions or nullNo
page:fragmentsPage render (native only)Contributions or nullNo

See the Hook Reference for complete event types and handler signatures.