---
title: Plugin Hooks
description: Hook into content, media, and plugin lifecycle events.
---

import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";

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:

```typescript
async (event: EventType, ctx: PluginContext) => ReturnType;
```

- `event` — Data about the event (content being saved, media uploaded, etc.)
- `ctx` — The [plugin context](/plugins/overview/#plugin-context) with storage, KV, logging, and capability-gated APIs

## Hook Configuration

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

<Tabs>
  <TabItem label="Simple">
```typescript
hooks: {
  "content:afterSave": async (event, ctx) => {
    ctx.log.info("Content saved");
  }
}
```
  </TabItem>
  <TabItem label="Full Config">
```typescript
hooks: {
  "content:afterSave": {
    priority: 100,
    timeout: 5000,
    dependencies: ["audit-log"],
    errorPolicy: "continue",
    handler: async (event, ctx) => {
      ctx.log.info("Content saved");
    }
  }
}
```
  </TabItem>
</Tabs>

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

<Aside type="tip">
	Use `errorPolicy: "continue"` for non-critical hooks like notifications. Use `"abort"` (the
	default) when the hook's result is essential.
</Aside>

## Lifecycle Hooks

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

### `plugin:install`

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

```typescript
"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).

```typescript
"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).

```typescript
"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.

```typescript
"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>`

<Aside type="caution">
	Be conservative in `plugin:uninstall`. Default to preserving data—users may reinstall. Only delete
	when `event.deleteData` is `true`.
</Aside>

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

```typescript
"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:**

```typescript
{
	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.

```typescript
"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:**

```typescript
{
	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.

```typescript
"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:**

```typescript
{
	id: string; // Content ID being deleted
	collection: string;
}
```

**Returns:** `Promise<boolean | void>`

### `content:afterDelete`

Runs after content is successfully deleted.

```typescript
"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:**

```typescript
{
	id: string;
	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.

```typescript
"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:**

```typescript
{
	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.

```typescript
"media:afterUpload": async (event, ctx) => {
  const { media } = event;

  ctx.log.info(`Uploaded ${media.filename}`, {
    id: media.id,
    size: media.size,
    mimeType: media.mimeType
  });
}
```

**Event:**

```typescript
{
	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:

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

```typescript
// 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.

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

<Aside type="tip">
	Use `errorPolicy: "continue"` for non-critical operations like analytics, notifications, or
	external syncs. The content save succeeds even if the hook fails.
</Aside>

## Timeouts

Hooks have a default timeout of 5000ms (5 seconds). Increase it for operations that may take longer:

```typescript
"content:afterSave": {
  timeout: 30000,  // 30 seconds
  handler: async (event, ctx) => {
    // Long-running operation
  }
}
```

<Aside type="caution">
	In sandboxed mode on Cloudflare, resource limits are enforced at the isolate level. Long-running
	hooks may be terminated regardless of the configured timeout.
</Aside>

## 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 trusted and sandboxed modes.

Core validates, deduplicates, and renders the contributions. Plugins return structured data, never raw HTML.

```typescript
"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:**

```typescript
{
  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`

Contributes raw HTML, scripts, or markup to page insertion points. **Trusted plugins only** — sandboxed plugins cannot use this hook.

```typescript
"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.

<Aside type="caution">
	`page:fragments` is trusted-only because its output runs as first-party code in the browser.
	Worker Loader isolation does not extend to browser-executed scripts. Use `page:metadata` for
	sandbox-safe contributions.
</Aside>

## 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        |
| `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 (trusted)          | Contributions or `null`        | No        |

See the [Hook Reference](/reference/hooks/) for complete event types and handler signatures.
