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.
Hook signature
Section titled “Hook signature”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— thePluginContextwith storage, KV, logging, and capability-gated APIs
Hook configuration
Section titled “Hook configuration”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"); },},hooks: { "content:afterSave": { priority: 100, timeout: 5000, dependencies: ["audit-log"], errorPolicy: "continue", handler: async (event, ctx) => { ctx.log.info("Content saved"); }, },},Configuration options
Section titled “Configuration options”| Option | Type | Default | Description |
|---|---|---|---|
priority | number | 100 | Execution order. Lower numbers run first. |
timeout | number | 5000 | Maximum execution time in milliseconds. |
dependencies | string[] | [] | Plugin ids that must run before this hook. |
errorPolicy | "abort" | "continue" | "abort" | Whether to stop the pipeline on error. |
exclusive | boolean | false | Only one plugin can be the active provider. Used for email:deliver and comment:moderate. |
handler | function | — | The hook handler function. Required. |
Lifecycle hooks
Section titled “Lifecycle hooks”Run during plugin installation, activation, deactivation, and removal.
plugin:install
Section titled “plugin:install”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>
plugin:activate
Section titled “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
Section titled “plugin:deactivate”Runs when the plugin is disabled (but not removed).
"plugin:deactivate": async (_event, ctx) => { ctx.log.info("Plugin deactivated");},Event: {} — Returns: Promise<void>
plugin:uninstall
Section titled “plugin:uninstall”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>
Content hooks
Section titled “Content hooks”Run during create, update, and delete operations on site content.
content:beforeSave
Section titled “content:beforeSave”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.
content:afterSave
Section titled “content:afterSave”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>
content:beforeDelete
Section titled “content:beforeDelete”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
content:afterDelete
Section titled “content:afterDelete”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>
content:afterPublish
Section titled “content:afterPublish”Runs after content is promoted from draft to live. Requires content:read capability.
Event: { content, collection } — Returns: Promise<void>
content:afterUnpublish
Section titled “content:afterUnpublish”Runs after content is reverted from live to draft. Requires content:read capability.
Event: { content, collection } — Returns: Promise<void>
Media hooks
Section titled “Media hooks”media:beforeUpload
Section titled “media:beforeUpload”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
media:afterUpload
Section titled “media:afterUpload”Runs after a file is successfully uploaded.
Event: { media: { id, filename, mimeType, size, url, createdAt } } — Returns: Promise<void>
Public-page hooks
Section titled “Public-page hooks”These let plugins contribute to rendered public pages. Templates opt in by including the <EmDashHead>, <EmDashBodyStart>, and <EmDashBodyEnd> components from emdash/ui.
page:metadata
Section titled “page:metadata”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:
| Kind | Renders | Dedupe 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.
page:fragments
Section titled “page:fragments”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.
Hook execution order
Section titled “Hook execution order”Hooks run in this order:
- Hooks with lower
priorityvalues run first. - For equal priorities, hooks run in plugin registration order.
- Hooks with
dependencieswait 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 () => {},}Error handling
Section titled “Error handling”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"); },},Timeouts
Section titled “Timeouts”Hooks default to 5000ms. Bump the timeout for slower work:
"content:afterSave": { timeout: 30000, handler: async (event, ctx) => { // Long-running operation },},Hook reference
Section titled “Hook reference”| Hook | Trigger | Return | Exclusive |
|---|---|---|---|
plugin:install | First plugin installation | void | No |
plugin:activate | Plugin enabled | void | No |
plugin:deactivate | Plugin disabled | void | No |
plugin:uninstall | Plugin removed | void | No |
content:beforeSave | Before content save | Modified content or void | No |
content:afterSave | After content save | void | No |
content:beforeDelete | Before content delete | false to cancel, else allow | No |
content:afterDelete | After content delete | void | No |
content:afterPublish | After content publish | void | No |
content:afterUnpublish | After content unpublish | void | No |
media:beforeUpload | Before file upload | Modified file info or void | No |
media:afterUpload | After file upload | void | No |
cron | Scheduled task fires | void | No |
email:beforeSend | Before email delivery | Modified message, false, or void | No |
email:deliver | Deliver email via transport | void | Yes |
email:afterSend | After email delivery | void | No |
comment:beforeCreate | Before comment stored | Modified event, false, or void | No |
comment:moderate | Decide comment status | { status, reason? } | Yes |
comment:afterCreate | After comment stored | void | No |
comment:afterModerate | Admin changes comment status | void | No |
page:metadata | Page render | Contributions or null | No |
page:fragments | Page render (native only) | Contributions or null | No |
See the Hook Reference for complete event types and handler signatures.