Skip to content

Admin UI

Plugins can extend the admin panel with custom pages and dashboard widgets. These are React components that render alongside core admin functionality.

Plugins with admin UI export components from an admin entry point:

src/admin.tsx
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:

package.json
{
"exports": {
".": "./dist/index.js",
"./admin": "./dist/admin.js"
}
}

Reference it in your plugin definition:

src/index.ts
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 are React components that receive the plugin context via hooks.

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>.

src/components/SettingsPage.tsx
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>
);
}

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.

Widgets appear on the admin dashboard and provide at-a-glance information.

Define widgets in admin.widgets:

admin: {
widgets: [
{
id: "seo-overview", // Unique widget ID
title: "SEO Overview", // Widget title (optional)
size: "half", // "full" | "half" | "third"
},
];
}
src/components/SEOWidget.tsx
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>
);
}
SizeDescription
fullFull dashboard width
halfHalf dashboard width
thirdOne-third dashboard width

Widgets wrap automatically based on screen width.

The admin entry point exports two objects:

src/admin.tsx
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,
};

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

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.

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

Admin components need a separate build entry point. Configure your bundler:

tsdown.config.ts
export default {
entry: {
index: "src/index.ts",
admin: "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.

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");
src/index.ts
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 };
},
},
},
});
src/admin.tsx
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,
};