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
| 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
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:
- 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
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:
| 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
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
| 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.