Skip to content

Hooks

Hooks let plugins run code in response to events. All hooks receive an event object and the plugin context, and they’re declared at plugin definition time — there’s no dynamic registration at runtime.

This page covers sandboxed (standard-format) plugins. Hooks work identically in native plugins; the only difference is that native plugins can also register page:fragments, which sandboxed plugins can’t.

Every hook handler takes two arguments:

async (event: EventType, ctx: PluginContext) => ReturnType;
  • event — data about what just happened (content being saved, media uploaded, lifecycle transition, etc.)
  • ctx — the PluginContext with storage, KV, logging, and capability-gated APIs

A hook can be declared as a bare handler or wrapped in a config object:

hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved");
},
},
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.

Run during plugin installation, activation, deactivation, and removal.

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

"plugin:install": async (_event, ctx) => {
ctx.log.info("Installing plugin...");
await ctx.kv.set("settings:enabled", true);
await ctx.storage.items.put("default", { name: "Default Item" });
},

Event: {}Returns: Promise<void>

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>

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

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

Event: {}Returns: Promise<void>

Runs when the plugin is removed from a site.

"plugin:uninstall": async (event, ctx) => {
ctx.log.info("Uninstalling plugin...");
if (event.deleteData) {
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>

Run during create, update, and delete operations on site content.

Runs before content is saved. Return modified content, or void to leave it unchanged. Throw to cancel.

"content:beforeSave": async (event, ctx) => {
const { content, collection } = event;
if (collection === "posts" && !content.title) {
throw new Error("Posts require a title");
}
if (typeof content.slug === "string") {
content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
}
return content;
},

Event: { content, collection, isNew }Returns: modified content or void.

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

"content:afterSave": async (event, ctx) => {
ctx.log.info(`${event.isNew ? "Created" : "Updated"} ${event.collection}/${event.content.id}`);
if (ctx.http) {
await ctx.http.fetch("https://api.example.com/webhook", {
method: "POST",
body: JSON.stringify({ event: "content:save", id: event.content.id }),
});
}
},

Event: { content, collection, isNew }Returns: Promise<void>

Runs before content is deleted. Return false to cancel; true or void allows it.

"content:beforeDelete": async (event, ctx) => {
if (event.collection === "pages" && event.id === "home") {
ctx.log.warn("Cannot delete home page");
return false;
}
return true;
},

Event: { id, collection }Returns: boolean | void

Runs after content is successfully deleted.

"content:afterDelete": async (event, ctx) => {
await ctx.storage.cache.delete(`${event.collection}:${event.id}`);
},

Event: { id, collection }Returns: Promise<void>

Runs after content is promoted from draft to live. Requires content:read capability.

Event: { content, collection }Returns: Promise<void>

Runs after content is reverted from live to draft. Requires content:read capability.

Event: { content, collection }Returns: Promise<void>

Runs before a file is uploaded. Return modified file metadata or throw to cancel.

"media:beforeUpload": async (event, ctx) => {
if (!event.file.type.startsWith("image/")) {
throw new Error("Only images are allowed");
}
if (event.file.size > 10 * 1024 * 1024) {
throw new Error("File too large");
}
return { ...event.file, name: `${Date.now()}-${event.file.name}` };
},

Event: { file: { name, type, size } }Returns: modified file or void

Runs after a file is successfully uploaded.

Event: { media: { id, filename, mimeType, size, url, createdAt } }Returns: Promise<void>

These let plugins contribute to rendered public pages. Templates opt in by including the <EmDashHead>, <EmDashBodyStart>, and <EmDashBodyEnd> components from emdash/ui.

Contributes typed metadata to <head> — meta tags, OpenGraph properties, allowlisted <link> rels, and JSON-LD. Available to both sandboxed and native plugins. 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 kinds:

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 rel is restricted to a security-locked allowlist (canonical, alternate, author, license, nlweb, site.standard.document); href must be HTTP or HTTPS.

Contributes raw HTML, scripts, or stylesheets to page insertion points. Native plugins only.

Sandboxed plugins can’t use this hook because its output runs as first-party code in the visitor’s browser, outside any sandbox boundary. For sandbox-safe page contributions, use page:metadata. See Native plugins: page fragments if you need this surface.

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, handler: async () => {} }
// Plugin B
"content:afterSave": { priority: 100, handler: async () => {} }
// Plugin C
"content:afterSave": {
priority: 200,
dependencies: ["plugin-a"], // waits for A even if its priority would normally be later
handler: async () => {},
}

When a hook throws or times out:

  • errorPolicy: "abort" — the entire pipeline stops and the originating operation may fail.
  • errorPolicy: "continue" — the error is logged and remaining hooks still run.
"content:afterSave": {
timeout: 5000,
errorPolicy: "continue",
handler: async (event, ctx) => {
await ctx.http!.fetch("https://unreliable-api.com/notify");
},
},

Hooks default to 5000ms. Bump the timeout for slower work:

"content:afterSave": {
timeout: 30000,
handler: async (event, ctx) => {
// Long-running operation
},
},
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.