Plugins can extend the admin panel with custom pages and dashboard widgets. These are React components that render alongside core admin functionality.
Admin Entry Point
Plugins with admin UI export components from an admin entry point:
import { SEOSettingsPage } from "./components/SEOSettingsPage";
import { SEODashboardWidget } from "./components/SEODashboardWidget";
// Dashboard widgets
export const widgets = {
"seo-overview": SEODashboardWidget,
};
// Admin pages
export const pages = {
"/settings": SEOSettingsPage,
};
Configure the entry point in package.json:
{
"exports": {
".": "./dist/index.js",
"./admin": "./dist/admin.js"
}
}
Reference it in your plugin definition:
definePlugin({
id: "seo",
version: "1.0.0",
admin: {
entry: "@my-org/plugin-seo/admin",
pages: [{ path: "/settings", label: "SEO Settings", icon: "settings" }],
widgets: [{ id: "seo-overview", title: "SEO Overview", size: "half" }],
},
});
Admin Pages
Admin pages are React components that receive the plugin context via hooks.
Page Definition
Define pages in admin.pages:
admin: {
pages: [
{
path: "/settings", // URL path (relative to plugin base)
label: "Settings", // Sidebar label
icon: "settings", // Icon name (optional)
},
{
path: "/reports",
label: "Reports",
icon: "chart",
},
];
}
Pages mount at /_emdash/admin/plugins/<plugin-id>/<path>.
Page Component
import { useState, useEffect } from "react";
import { usePluginAPI } from "@emdash-cms/admin";
export function SettingsPage() {
const api = usePluginAPI();
const [settings, setSettings] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
api.get("settings").then(setSettings);
}, []);
const handleSave = async () => {
setSaving(true);
await api.post("settings/save", settings);
setSaving(false);
};
return (
<div>
<h1>Plugin Settings</h1>
<label>
Site Title
<input
type="text"
value={settings.siteTitle || ""}
onChange={(e) => setSettings({ ...settings, siteTitle: e.target.value })}
/>
</label>
<label>
<input
type="checkbox"
checked={settings.enabled ?? true}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
Enabled
</label>
<button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
);
}
Plugin API Hook
Use usePluginAPI() to call your plugin’s routes:
import { usePluginAPI } from "@emdash-cms/admin";
function MyComponent() {
const api = usePluginAPI();
// GET request to plugin route
const data = await api.get("status");
// POST request with body
await api.post("settings/save", { enabled: true });
// With URL parameters
const result = await api.get("history?limit=50");
}
The hook automatically adds the plugin ID prefix to route URLs.
Dashboard Widgets
Widgets appear on the admin dashboard and provide at-a-glance information.
Widget Definition
Define widgets in admin.widgets:
admin: {
widgets: [
{
id: "seo-overview", // Unique widget ID
title: "SEO Overview", // Widget title (optional)
size: "half", // "full" | "half" | "third"
},
];
}
Widget Component
import { useState, useEffect } from "react";
import { usePluginAPI } from "@emdash-cms/admin";
export function SEOWidget() {
const api = usePluginAPI();
const [data, setData] = useState({ score: 0, issues: [] });
useEffect(() => {
api.get("analyze").then(setData);
}, []);
return (
<div className="widget-content">
<div className="score">{data.score}%</div>
<ul>
{data.issues.map((issue, i) => (
<li key={i}>{issue.message}</li>
))}
</ul>
</div>
);
}
Widget Sizes
| Size | Description |
|---|---|
full | Full dashboard width |
half | Half dashboard width |
third | One-third dashboard width |
Widgets wrap automatically based on screen width.
Export Structure
The admin entry point exports two objects:
import { SettingsPage } from "./components/SettingsPage";
import { ReportsPage } from "./components/ReportsPage";
import { StatusWidget } from "./components/StatusWidget";
import { OverviewWidget } from "./components/OverviewWidget";
// Pages keyed by path
export const pages = {
"/settings": SettingsPage,
"/reports": ReportsPage,
};
// Widgets keyed by ID
export const widgets = {
status: StatusWidget,
overview: OverviewWidget,
};
Using Admin Components
EmDash provides pre-built components for common patterns:
import {
Card,
Button,
Input,
Select,
Toggle,
Table,
Pagination,
Alert,
Loading
} from "@emdash-cms/admin";
function SettingsPage() {
return (
<Card title="Settings">
<Input label="API Key" type="password" />
<Toggle label="Enabled" defaultChecked />
<Button variant="primary">Save</Button>
</Card>
);
}
Auto-Generated Settings UI
If your plugin only needs a settings form, use admin.settingsSchema without custom components:
admin: {
settingsSchema: {
apiKey: { type: "secret", label: "API Key" },
enabled: { type: "boolean", label: "Enabled", default: true }
}
}
EmDash generates a settings page automatically. Add custom pages only for functionality beyond basic settings.
Navigation
Plugin pages appear in the admin sidebar under the plugin name. The order matches the admin.pages array.
admin: {
pages: [
{ path: "/settings", label: "Settings", icon: "settings" }, // First
{ path: "/history", label: "History", icon: "history" }, // Second
{ path: "/reports", label: "Reports", icon: "chart" }, // Third
];
}
Build Configuration
Admin components need a separate build entry point. Configure your bundler:
tsdown
export default {
entry: {
index: "src/index.ts",
admin: "src/admin.tsx"
},
format: "esm",
dts: true,
external: ["react", "react-dom", "emdash", "@emdash-cms/admin"]
}; tsup
export default {
entry: ["src/index.ts", "src/admin.tsx"],
format: "esm",
dts: true,
external: ["react", "react-dom", "emdash", "@emdash-cms/admin"]
}; Keep React and EmDash admin as external dependencies to avoid bundling duplicates.
Plugin Enable/Disable
When a plugin is disabled in the admin:
- Sidebar links are hidden
- Dashboard widgets are not rendered
- Admin pages return 404
- Backend hooks still execute (for data safety)
Plugins can check their enabled state:
const enabled = await ctx.kv.get<boolean>("_emdash:enabled");
Example: Complete Admin UI
import { definePlugin } from "emdash";
export default definePlugin({
id: "analytics",
version: "1.0.0",
capabilities: ["network:fetch"],
allowedHosts: ["api.analytics.example.com"],
storage: {
events: { indexes: ["type", "createdAt"] },
},
admin: {
entry: "@my-org/plugin-analytics/admin",
settingsSchema: {
trackingId: { type: "string", label: "Tracking ID" },
enabled: { type: "boolean", label: "Enabled", default: true },
},
pages: [
{ path: "/dashboard", label: "Dashboard", icon: "chart" },
{ path: "/settings", label: "Settings", icon: "settings" },
],
widgets: [{ id: "events-today", title: "Events Today", size: "third" }],
},
routes: {
stats: {
handler: async (ctx) => {
const today = new Date().toISOString().split("T")[0];
const count = await ctx.storage.events!.count({
createdAt: { gte: today },
});
return { today: count };
},
},
},
});
import { EventsWidget } from "./components/EventsWidget";
import { DashboardPage } from "./components/DashboardPage";
import { SettingsPage } from "./components/SettingsPage";
export const widgets = {
"events-today": EventsWidget,
};
export const pages = {
"/dashboard": DashboardPage,
"/settings": SettingsPage,
};