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 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:
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:
- POST/PUT/PATCH — Request body (JSON)
- 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;
}