Skip to content

Plugin API Routes

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.

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

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.

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 any JSON-serializable value:

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

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

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 is parsed from:

  1. POST/PUT/PATCH — Request body (JSON)
  2. GET/DELETE — URL query parameters
// POST /plugins/forms/create
// Body: { "title": "Hello", "email": "user@example.com" }
// GET /plugins/forms/list?limit=20&status=pending

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

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

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

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

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();
},
},
},
});

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

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.

Routes are accessible at their full URL:

Terminal window
# 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"}'
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;
}