---
title: Plugin API Routes
description: Expose REST endpoints from your plugin for admin UI and external integrations.
---

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

Plugins can expose API routes for their admin UI components or external integrations. Routes receive the full plugin context and can access storage, KV, content, and media. Both native and sandboxed plugins support API routes.

<Aside type="note">
	Native and sandboxed plugins use slightly different handler signatures for routes. Native-format plugins receive a single `RouteContext` argument. Standard-format plugins (which can run in either mode) receive two arguments: `(routeCtx, pluginCtx)`. See [Native vs. Sandboxed Plugins](/plugins/sandbox/) for details on the two formats.
</Aside>

## Defining Routes

Define routes in the `routes` object:

```typescript
import { definePlugin } from "emdash";
import { z } from "astro/zod";

export default definePlugin({
	id: "forms",
	version: "1.0.0",

	storage: {
		submissions: {
			indexes: ["formId", "status", "createdAt"],
		},
	},

	routes: {
		// Simple route
		status: {
			handler: async (ctx) => {
				return { ok: true, plugin: ctx.plugin.id };
			},
		},

		// Route with input validation
		submissions: {
			input: z.object({
				formId: z.string().optional(),
				limit: z.number().default(50),
				cursor: z.string().optional(),
			}),
			handler: async (ctx) => {
				const { formId, limit, cursor } = ctx.input;

				const result = await ctx.storage.submissions!.query({
					where: formId ? { formId } : undefined,
					orderBy: { createdAt: "desc" },
					limit,
					cursor,
				});

				return {
					items: result.items,
					cursor: result.cursor,
					hasMore: result.hasMore,
				};
			},
		},
	},
});
```

## Route URLs

Routes mount at `/_emdash/api/plugins/<plugin-id>/<route-name>`:

| Plugin ID | Route Name      | URL                                        |
| --------- | --------------- | ------------------------------------------ |
| `forms`   | `status`        | `/_emdash/api/plugins/forms/status`      |
| `forms`   | `submissions`   | `/_emdash/api/plugins/forms/submissions` |
| `seo`     | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |

Route names can include slashes for nested paths.

## Route Handler

The handler receives a `RouteContext` with the plugin context plus request-specific data:

```typescript
interface RouteContext extends PluginContext {
	input: TInput; // Validated input (from body or query params)
	request: Request; // Original Request object
}
```

### Return Values

Return any JSON-serializable value:

```typescript
// Object
return { success: true, data: items };

// Array
return items;

// Primitive
return 42;
```

<Aside type="note">
	Routes always return JSON. The response has `Content-Type: application/json`.
</Aside>

### Errors

Throw to return an error response:

```typescript
handler: async (ctx) => {
	const item = await ctx.storage.items!.get(ctx.input.id);

	if (!item) {
		throw new Error("Item not found");
		// Returns: { "error": "Item not found" } with 500 status
	}

	return item;
};
```

For custom status codes, throw a `Response`:

```typescript
handler: async (ctx) => {
	const item = await ctx.storage.items!.get(ctx.input.id);

	if (!item) {
		throw new Response(JSON.stringify({ error: "Not found" }), {
			status: 404,
			headers: { "Content-Type": "application/json" },
		});
	}

	return item;
};
```

## Input Validation

Use Zod schemas to validate and parse input:

```typescript
import { z } from "astro/zod";

routes: {
  create: {
    input: z.object({
      title: z.string().min(1).max(200),
      email: z.string().email(),
      priority: z.enum(["low", "medium", "high"]).default("medium"),
      tags: z.array(z.string()).optional()
    }),
    handler: async (ctx) => {
      // ctx.input is typed and validated
      const { title, email, priority, tags } = ctx.input;

      await ctx.storage.items!.put(`item_${Date.now()}`, {
        title,
        email,
        priority,
        tags: tags ?? [],
        createdAt: new Date().toISOString()
      });

      return { success: true };
    }
  }
}
```

Invalid input returns a 400 error with validation details.

### Input Sources

Input is parsed from:

1. **POST/PUT/PATCH** — Request body (JSON)
2. **GET/DELETE** — URL query parameters

```typescript
// POST /plugins/forms/create
// Body: { "title": "Hello", "email": "user@example.com" }

// GET /plugins/forms/list?limit=20&status=pending
```

## HTTP Methods

Routes respond to all HTTP methods. Check `ctx.request.method` to handle them differently:

```typescript
routes: {
  item: {
    input: z.object({
      id: z.string()
    }),
    handler: async (ctx) => {
      const { id } = ctx.input;

      switch (ctx.request.method) {
        case "GET":
          return await ctx.storage.items!.get(id);

        case "DELETE":
          await ctx.storage.items!.delete(id);
          return { deleted: true };

        default:
          throw new Response("Method not allowed", { status: 405 });
      }
    }
  }
}
```

## Accessing the Request

The full `Request` object is available for advanced use cases:

```typescript
handler: async (ctx) => {
	const { request } = ctx;

	// Headers
	const auth = request.headers.get("Authorization");

	// URL parameters
	const url = new URL(request.url);
	const page = url.searchParams.get("page");

	// Method
	if (request.method !== "POST") {
		throw new Response("POST required", { status: 405 });
	}

	// Body (if not using input schema)
	const body = await request.json();
};
```

## Common Patterns

### Settings Routes

Expose and update plugin settings:

```typescript
routes: {
  settings: {
    handler: async (ctx) => {
      const settings = await ctx.kv.list("settings:");
      const result: Record<string, unknown> = {};

      for (const entry of settings) {
        result[entry.key.replace("settings:", "")] = entry.value;
      }

      return result;
    }
  },

  "settings/save": {
    input: z.object({
      enabled: z.boolean().optional(),
      apiKey: z.string().optional(),
      maxItems: z.number().optional()
    }),
    handler: async (ctx) => {
      const input = ctx.input;

      for (const [key, value] of Object.entries(input)) {
        if (value !== undefined) {
          await ctx.kv.set(`settings:${key}`, value);
        }
      }

      return { success: true };
    }
  }
}
```

### Paginated List

Return paginated results with cursor-based navigation:

```typescript
routes: {
  list: {
    input: z.object({
      limit: z.number().min(1).max(100).default(50),
      cursor: z.string().optional(),
      status: z.string().optional()
    }),
    handler: async (ctx) => {
      const { limit, cursor, status } = ctx.input;

      const result = await ctx.storage.items!.query({
        where: status ? { status } : undefined,
        orderBy: { createdAt: "desc" },
        limit,
        cursor
      });

      return {
        items: result.items.map(item => ({
          id: item.id,
          ...item.data
        })),
        cursor: result.cursor,
        hasMore: result.hasMore
      };
    }
  }
}
```

### External API Proxy

Proxy requests to external services (requires `network:request` capability):

```typescript
definePlugin({
	id: "weather",
	version: "1.0.0",

	capabilities: ["network:request"],
	allowedHosts: ["api.weather.example.com"],

	routes: {
		forecast: {
			input: z.object({
				city: z.string(),
			}),
			handler: async (ctx) => {
				const apiKey = await ctx.kv.get<string>("settings:apiKey");

				if (!apiKey) {
					throw new Error("API key not configured");
				}

				const response = await ctx.http!.fetch(
					`https://api.weather.example.com/forecast?city=${ctx.input.city}`,
					{
						headers: { "X-API-Key": apiKey },
					},
				);

				if (!response.ok) {
					throw new Error(`Weather API error: ${response.status}`);
				}

				return response.json();
			},
		},
	},
});
```

### Action Endpoint

Trigger a one-off action:

```typescript
routes: {
	sync: {
		handler: async (ctx) => {
			ctx.log.info("Starting sync...");

			const startTime = Date.now();
			let synced = 0;

			// Do work...
			const items = await fetchExternalItems(ctx);
			for (const item of items) {
				await ctx.storage.items!.put(item.id, item);
				synced++;
			}

			const duration = Date.now() - startTime;
			ctx.log.info("Sync complete", { synced, duration });

			return {
				success: true,
				synced,
				duration,
			};
		};
	}
}
```

## Calling Routes from Admin UI

Use the `usePluginAPI()` hook in admin components:

```typescript
import { usePluginAPI } from "@emdash-cms/admin";

function SettingsPage() {
	const api = usePluginAPI();

	const handleSave = async (settings) => {
		await api.post("settings/save", settings);
	};

	const loadSettings = async () => {
		return api.get("settings");
	};
}
```

The hook automatically prefixes the plugin ID to route URLs.

## Calling Routes Externally

Routes are accessible at their full URL:

```bash
# GET request
curl https://your-site.com/_emdash/api/plugins/forms/submissions?limit=10

# POST request
curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
  -H "Content-Type: application/json" \
  -d '{"title": "Hello", "email": "user@example.com"}'
```

<Aside type="caution">
	Plugin routes don't have built-in authentication. For public endpoints, implement your own auth
	checks. For admin-only routes, the admin session middleware provides protection.
</Aside>

## Route Context Reference

```typescript
interface RouteContext<TInput = unknown> extends PluginContext {
	/** Validated input from request body or query params */
	input: TInput;

	/** Original request object */
	request: Request;

	/** Plugin metadata */
	plugin: { id: string; version: string };

	/** Plugin storage collections */
	storage: Record<string, StorageCollection>;

	/** Key-value store */
	kv: KVAccess;

	/** Content access (if capability declared) */
	content?: ContentAccess;

	/** Media access (if capability declared) */
	media?: MediaAccess;

	/** HTTP client (if capability declared) */
	http?: HttpAccess;

	/** Structured logger */
	log: LogAccess;
}
```
