Plugin Hooks
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
Section titled “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
Section titled “Hook Configuration”Hooks can be declared as a simple handler or with full configuration:
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. |
handler | function | — | The hook handler function. Required. |
Lifecycle Hooks
Section titled “Lifecycle Hooks”Lifecycle hooks run during plugin installation, activation, and deactivation.
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...");
// 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
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"); // Release resources, pause background work}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) { // 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
Section titled “Content Hooks”Content hooks run during create, update, and delete operations.
content:beforeSave
Section titled “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
Section titled “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
Section titled “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
Section titled “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>
Media Hooks
Section titled “Media Hooks”Media hooks run during file uploads.
media:beforeUpload
Section titled “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
Section titled “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
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, // 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
Section titled “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
Section titled “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
Section titled “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
Section titled “page:metadata”Contributes typed metadata to <head> — meta tags, OpenGraph properties, canonical/alternate links, and JSON-LD structured data. Works in both trusted 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.title, description: event.page.description, }, };}Event:
{ page: { url: string; path: string; locale: string | null; kind: "content" | "custom"; pageType: string; title: 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:
| 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 hrefs must be HTTP or HTTPS.
page:fragments
Section titled “page:fragments”Contributes raw HTML, scripts, or markup to page insertion points. Trusted 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
Section titled “Hooks Reference”| Hook | Trigger | Return |
|---|---|---|
plugin:install | First plugin installation | void |
plugin:activate | Plugin enabled | void |
plugin:deactivate | Plugin disabled | void |
plugin:uninstall | Plugin removed | void |
content:beforeSave | Before content save | Modified content or void |
content:afterSave | After content save | void |
content:beforeDelete | Before content delete | false to cancel, else allow |
content:afterDelete | After content delete | void |
media:beforeUpload | Before file upload | Modified file info or void |
media:afterUpload | After file upload | void |
page:metadata | Page render | Contributions or null |
page:fragments | Page render (trusted) | Contributions or null |