Skip to content

React admin pages and widgets

Native plugins can extend the admin panel with custom React pages, dashboard widgets, field widgets, and content-list columns — 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",
},
],
}

Declare labels in English. The admin runs them through its shared Lingui instance before rendering the sidebar and command palette, so a plugin that loads its own message catalog — with the English label as the message id — gets localized navigation for free. Labels that match one of the admin’s own messages (Settings, Dashboard, …) pick up the admin’s translations even without a plugin catalog; labels with no catalog entry anywhere render as declared.

src/admin.tsx
import { i18n } from "@lingui/core";
// Merge the plugin's compiled catalog into the admin's i18n instance.
// "Reports" now renders as "Berichte" when the admin locale is German.
const catalogs: Record<string, Record<string, string>> = {
de: { Reports: "Berichte", Settings: "Einstellungen" },
};
function mergeCatalog() {
const messages = catalogs[i18n.locale];
if (messages && !("Reports" in i18n.messages)) i18n.load(i18n.locale, messages);
}
mergeCatalog();
// The admin's locale switcher *replaces* the catalog on change, so re-merge.
// The sentinel check above keeps this from recursing (load() fires "change").
i18n.on("change", mergeCatalog);

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.

A trusted React plugin can add host-framed sections to the settings sidebar for saved content entries. EmDash owns the section heading and placement, applies collection and role filters, and isolates rendering failures so one plugin panel cannot unmount the editor.

Export a contentEditorPanels array from the plugin admin entry:

src/admin.tsx
import type { ContentEditorPanelContext } from "@emdash-cms/admin";
function ContentInsights({ entry, locale }: ContentEditorPanelContext) {
return (
<p>
Analysis for {entry.slug} in {locale ?? "the default locale"}
</p>
);
}
export const contentEditorPanels = [
{
id: "content-insights",
title: "Content insights",
component: ContentInsights,
collections: ["posts", "pages"],
minRole: 40,
order: 10,
},
];

Each panel receives the saved entry, its collection, and resolved locale. Panels are not mounted for new, unsaved entries. collections may be an array or a predicate and can be omitted to support every collection. Lower order values render first among contributed panels; ties are resolved deterministically by plugin and panel ID.

Panel IDs must be unique within the plugin. Keep panel content responsive to the narrow settings sidebar and perform authorization in plugin API routes rather than relying on minRole, which only controls visibility.

Trusted React plugins can add read-only columns to active content collection lists. EmDash keeps ownership of the table, pagination, row actions, and loading and empty states; the plugin supplies only the header metadata and cell content.

src/admin.tsx
import type {
ContentListColumnCellContext,
ContentListColumnExtension,
} from "@emdash-cms/admin";
import { useQuery } from "@tanstack/react-query";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
async function fetchReviewStatuses(
collection: string,
locale: string | undefined,
ids: readonly string[],
) {
const response = await apiFetch(
"/_emdash/api/plugins/editorial-workflow/review-statuses/batch",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ collection, locale, ids }),
},
);
return parseApiResponse<Record<string, string>>(response, "Failed to load review statuses");
}
function ReviewStatusCell({
item,
visibleItems,
collection,
locale,
}: ContentListColumnCellContext) {
const itemIds = visibleItems.map((visibleItem) => visibleItem.id);
const { data: reviewStatuses } = useQuery({
queryKey: ["editorial-workflow", "review-statuses", collection, locale ?? null, itemIds],
queryFn: () => fetchReviewStatuses(collection, locale, itemIds),
});
const reviewStatus = reviewStatuses?.[item.id];
return reviewStatus ? <span>{reviewStatus}</span> : <span>-</span>;
}
export const contentListColumns = [
{
id: "review-status",
label: "Review status",
collections: ["posts", "pages"],
order: 10,
align: "end",
cell: ReviewStatusCell,
},
] satisfies readonly ContentListColumnExtension[];

Every cell component mounts once per visible row. When a column needs data that is not already on item, use visibleItems to request the whole page in one batch. All cells in the example share the same React Query key, so they reuse one request instead of issuing one request per item.

Column ids only need to be unique within the plugin. Contributions are ordered by order, then plugin id and column id. A plugin can also provide:

  • header: a custom header component; label remains the host-rendered fallback.
  • collections: an array or predicate restricting where the column appears.
  • minRole: a visibility filter for the admin UI. This is not authorization; plugin API routes must still enforce access.

Disabled or missing plugins are omitted. Invalid definitions, collection predicates that throw, and render failures are isolated so the host content list remains usable. Columns are not shown in Trash.

Content-list columns are display-only. Server-backed sorting and filtering require a separate host data-provider contract; a browser comparator would only sort the currently loaded cursor pages and is therefore not supported by this API.

The admin entry point exports each contribution directly:

src/admin.tsx
import { SettingsPage } from "./components/SettingsPage";
import { ReportsPage } from "./components/ReportsPage";
import { StatusWidget } from "./components/StatusWidget";
import { OverviewWidget } from "./components/OverviewWidget";
import { ReviewStatusCell } from "./components/ReviewStatusCell";
export const pages = {
"/settings": SettingsPage,
"/reports": ReportsPage,
};
export const widgets = {
status: StatusWidget,
overview: OverviewWidget,
};
export const contentListColumns = [
{
id: "review-status",
label: "Review status",
collections: ["posts"],
cell: ReviewStatusCell,
},
];

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.
  • Content-list columns 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,
};