Plugin API Routes

On this page

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.

Defining Routes

Define routes in the routes object:

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 IDRoute NameURL
formsstatus/_emdash/api/plugins/forms/status
formssubmissions/_emdash/api/plugins/forms/submissions
seosettings/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:

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:

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

// Array
return items;

// Primitive
return 42;

Errors

Throw to return an error response:

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:

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:

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
// POST /plugins/forms/create
// Body: { "title": "Hello", "email": "[email protected]" }

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

HTTP Methods

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

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:

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:

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:

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:fetch capability):

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

	capabilities: ["network:fetch"],
	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:

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:

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:

# 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": "[email protected]"}'

Route Context Reference

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