Skip to content

React admin pages and widgets

Native plugins can extend the admin panel with custom React pages and dashboard widgets — sandboxed plugins describe their UI as Block Kit instead, because shipping plugin JavaScript into the admin would break sandbox isolation.

If your plugin only needs a settings form, the auto-generated admin.settingsSchema form (see Your first native plugin) covers most cases without writing any React. Reach for custom components when you need richer UI than settingsSchema provides.

Plugins with admin UI export pages and widgets objects from an admin entrypoint:

src/admin.tsx
import { SEOSettingsPage } from "./components/SEOSettingsPage";
import { SEODashboardWidget } from "./components/SEODashboardWidget";
export const widgets = {
"seo-overview": SEODashboardWidget,
};
export const pages = {
"/settings": SEOSettingsPage,
};

Configure the entry point in package.json:

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

Reference it from definePlugin():

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

The descriptor needs a matching adminEntry so EmDash knows where to find the components at build time:

adminEntry: "@my-org/plugin-seo/admin",

Admin pages are React components that mount under /_emdash/admin/plugins/<plugin-id>/<path>.

Declare each page under admin.pages with a path, label, and icon:

admin: {
pages: [
{
path: "/settings",
label: "Settings",
icon: "settings",
},
{
path: "/reports",
label: "Reports",
icon: "chart",
},
],
}

The following component reads and saves settings through the plugin API hook:

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 as string) || ""}
onChange={(e) => setSettings({ ...settings, siteTitle: e.target.value })}
/>
</label>
<button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
);
}

usePluginAPI() calls your plugin’s routes with the plugin id prefix and the X-EmDash-Request: 1 CSRF header added automatically:

import { usePluginAPI } from "@emdash-cms/admin";
function MyComponent() {
const api = usePluginAPI();
const data = await api.get("status"); // GET /_emdash/api/plugins/<id>/status
await api.post("settings/save", { enabled: true }); // POST with JSON body
const result = await api.get("history?limit=50"); // query params supported
}

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

Declare each widget under admin.widgets with an id, title, and size:

admin: {
widgets: [
{
id: "seo-overview",
title: "SEO Overview",
size: "half", // "full" | "half" | "third"
},
],
}

The following component fetches its data on mount and renders a compact summary:

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 as { message: string }).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";
export const pages = {
"/settings": SettingsPage,
"/reports": ReportsPage,
};
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. Reach for custom React pages only when you need behaviour beyond a basic form.

Plugin pages appear in the admin sidebar under the plugin’s name. The order matches the admin.pages array, as shown below:

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. The following bundler configuration builds both the server and admin entrypoints:

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

The following plugin defines a dashboard page, a settings page, and a widget, with the runtime and admin entrypoints in separate files. The src/index.ts file holds the descriptor and runtime:

src/index.ts
import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";
export function analyticsPlugin(): PluginDescriptor {
return {
id: "analytics",
version: "1.0.0",
format: "native",
entrypoint: "@my-org/plugin-analytics",
adminEntry: "@my-org/plugin-analytics/admin",
adminPages: [
{ path: "/dashboard", label: "Dashboard", icon: "chart" },
{ path: "/settings", label: "Settings", icon: "settings" },
],
adminWidgets: [{ id: "events-today", title: "Events Today", size: "third" }],
};
}
export function createPlugin() {
return definePlugin({
id: "analytics",
version: "1.0.0",
capabilities: ["network:request"],
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 };
},
},
},
});
}
export default createPlugin;

The src/admin.tsx file maps page paths and widget ids to their React components:

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