Skip to content

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.

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

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

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.
handlerfunctionThe hook handler function. Required.

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

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>

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");
// Release resources, pause background work
}

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) {
// 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 run during create, update, and delete operations.

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>

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>

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>

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 run during file uploads.

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>

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>

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 () => {}
}

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");
}
}

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 let plugins contribute to the <head> and <body> of rendered pages. Templates opt in using the <EmDashHead>, <EmDashBodyStart>, and <EmDashBodyEnd> components from emdash/ui.

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:

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.

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.

HookTriggerReturn
plugin:installFirst plugin installationvoid
plugin:activatePlugin enabledvoid
plugin:deactivatePlugin disabledvoid
plugin:uninstallPlugin removedvoid
content:beforeSaveBefore content saveModified content or void
content:afterSaveAfter content savevoid
content:beforeDeleteBefore content deletefalse to cancel, else allow
content:afterDeleteAfter content deletevoid
media:beforeUploadBefore file uploadModified file info or void
media:afterUploadAfter file uploadvoid
page:metadataPage renderContributions or null
page:fragmentsPage render (trusted)Contributions or null