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.
Admin entry point
Section titled “Admin entry point”Plugins with admin UI export pages and widgets objects from an admin entrypoint:
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:
{ "exports": { ".": "./dist/index.js", "./admin": "./dist/admin.js" }}Reference it from definePlugin():
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
Section titled “Admin pages”Admin pages are React components that mount under /_emdash/admin/plugins/<plugin-id>/<path>.
Page definition
Section titled “Page definition”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.
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);Page component
Section titled “Page component”The following component reads and saves settings through the plugin API hook:
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> );}Plugin API hook
Section titled “Plugin API hook”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}Dashboard widgets
Section titled “Dashboard widgets”Widgets appear on the admin dashboard and provide at-a-glance information.
Widget definition
Section titled “Widget definition”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" }, ],}Widget component
Section titled “Widget component”The following component fetches its data on mount and renders a compact summary:
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> );}Widget sizes
Section titled “Widget sizes”| Size | Description |
|---|---|
full | Full dashboard width |
half | Half dashboard width |
third | One-third dashboard width |
Widgets wrap automatically based on screen width.
Content editor panels
Section titled “Content editor panels”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:
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.
Content-list columns
Section titled “Content-list columns”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.
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;labelremains 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.
Export structure
Section titled “Export structure”The admin entry point exports each contribution directly:
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, },];Using admin components
Section titled “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
Section titled “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. Reach for custom React pages only when you need behaviour beyond a basic form.
Navigation
Section titled “Navigation”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 ],}Build configuration
Section titled “Build configuration”Admin components need a separate build entry point. The following bundler configuration builds both the server and admin entrypoints:
export default { entry: { index: "src/index.ts", admin: "src/admin.tsx", }, format: "esm", dts: true, external: ["react", "react-dom", "emdash", "@emdash-cms/admin"],};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
Section titled “Plugin enable/disable”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");Complete example
Section titled “Complete example”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:
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:
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,};