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.
Defining Routes
Section titled “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
Section titled “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
Section titled “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
Section titled “Return Values”Return any JSON-serializable value:
// Objectreturn { success: true, data: items };
// Arrayreturn items;
// Primitivereturn 42;Errors
Section titled “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
Section titled “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
Section titled “Input Sources”Input is parsed from:
- POST/PUT/PATCH — Request body (JSON)
- GET/DELETE — URL query parameters
// POST /plugins/forms/create// Body: { "title": "Hello", "email": "user@example.com" }
// GET /plugins/forms/list?limit=20&status=pendingHTTP Methods
Section titled “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
Section titled “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
Section titled “Common Patterns”Settings Routes
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “Calling Routes Externally”Routes are accessible at their full URL:
# GET requestcurl https://your-site.com/_emdash/api/plugins/forms/submissions?limit=10
# POST requestcurl -X POST https://your-site.com/_emdash/api/plugins/forms/create \ -H "Content-Type: application/json" \ -d '{"title": "Hello", "email": "user@example.com"}'Route Context Reference
Section titled “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;}