React admin extensions

On this page

Native plugins can load trusted React components into the EmDash admin. The host keeps control of navigation, page routing, dashboard cards, editor layout, content tables, authentication, and error boundaries. The plugin supplies the components and the metadata needed to place them.

If the plugin only needs a settings form, start with admin.settingsSchema. It uses the host’s form components and does not require a React entrypoint. Sandboxed plugins can also use this generated form; the custom React extensions on this page require a native plugin.

Generated settings form

Declare settingsSchema inside the runtime definition. The following schema produces a multiline text field, a select, a number input, a switch, and a write-only secret input:

return definePlugin({
	id: "plugin-activity",
	version: "0.1.0",
	admin: {
		settingsSchema: {
			projectName: {
				type: "string",
				label: "Project name",
				description: "Name shown in activity exports",
			},
			notes: {
				type: "string",
				label: "Internal notes",
				multiline: true,
			},
			mode: {
				type: "select",
				label: "Recording mode",
				options: [
					{ value: "creates", label: "New entries only" },
					{ value: "all", label: "New and updated entries" },
				],
				default: "all",
			},
			retentionDays: {
				type: "number",
				label: "Retention in days",
				min: 1,
				max: 365,
				default: 30,
			},
			enabled: {
				type: "boolean",
				label: "Record activity",
				default: true,
			},
			exportToken: {
				type: "secret",
				label: "Export token",
			},
		},
	},
});

The available fields have the following options. label is required and description is optional for every type.

typeValueAdditional fields
stringstringdefault, multiline
numbernumberdefault, min, max
booleanbooleandefault
selectstringrequired options: Array<{ value, label }> and optional default
secretstringno additional fields; the stored value is never returned to the browser
urlstringdefault, placeholder
emailstringdefault, placeholder

The form is available from the settings control on the plugin’s card in Plugins. Reading or changing it requires plugins:manage.

Settings use the plugin’s namespaced KV store. A field named retentionDays is available to the plugin as settings:retentionDays:

const retentionDays =
	(await ctx.kv.get<number>("settings:retentionDays")) ?? 30;

Schema defaults fill the generated form when no value is stored, but EmDash does not write those defaults to KV. Apply the same fallback when the runtime reads the setting. Clearing a non-secret field deletes its stored value and returns the form to its default. Secret values are never sent back to the browser; the form reports only whether a secret is set and lets an administrator replace or clear it.

Settings labels and descriptions render as declared. If those strings must change with the admin locale, build a custom React settings page instead.

React entrypoint

A trusted React extension has three connected declarations:

  1. The descriptor’s adminEntry tells Astro which module to bundle into the admin.
  2. The runtime’s admin.entry, admin.pages, and admin.widgets describe the visible admin surfaces.
  3. The admin module exports component maps whose keys match the declared page paths and widget IDs.

The descriptor only needs the module specifier. Page and widget metadata belongs in the runtime definition:

export function activityPlugin(): PluginDescriptor {
	return {
		id: "plugin-activity",
		version: "0.1.0",
		format: "native",
		entrypoint: "@example/plugin-activity",
		adminEntry: "@example/plugin-activity/admin",
	};
}

export function createPlugin() {
	return definePlugin({
		id: "plugin-activity",
		version: "0.1.0",
		storage: {
			events: { indexes: ["createdAt"] },
		},
		admin: {
			entry: "@example/plugin-activity/admin",
			pages: [{ path: "/activity", label: "Activity", icon: "clock" }],
			widgets: [{ id: "recent-activity", title: "Recent activity" }],
		},
	});
}

Keep adminEntry and admin.entry identical. The first is a build-time import; the second tells the runtime that the plugin uses trusted React admin components.

Admin pages

Each page declaration has the following fields:

FieldRequiredBehavior
pathYesMounts the page at /_emdash/admin/plugins/<plugin-id><path>. Use a leading slash.
labelYesSupplies the sidebar and command-palette label.
iconNoNames a Phosphor icon in kebab, snake, space-separated, or PascalCase form. Unknown names fall back to the plugin icon.

The admin module maps each declared path to a React component. A trailing slash is treated as equivalent, and the plugin root opens the first exported page when no / page exists.

The following page loads a private plugin route. Use Kumo for controls and apiFetch() for plugin API requests; apiFetch() adds the X-EmDash-Request: 1 header required by cookie-authenticated private routes.

import { Button, Loader } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
import * as React from "react";

interface ActivitySummary {
	count: number;
}

export function ActivityPage() {
	const { i18n } = useLingui();
	const [summary, setSummary] = React.useState<ActivitySummary>();
	const [error, setError] = React.useState<string>();

	const load = React.useCallback(async () => {
		setError(undefined);
		try {
			const response = await apiFetch(
				"/_emdash/api/plugins/plugin-activity/summary",
			);
			setSummary(
				await parseApiResponse<ActivitySummary>(
					response,
					i18n._({ id: "activity.load-error", message: "Could not load activity" }),
				),
			);
		} catch (cause) {
			setError(cause instanceof Error ? cause.message : String(cause));
		}
	}, [i18n]);

	React.useEffect(() => {
		void load();
	}, [load]);

	return (
		<section className="space-y-4">
			<h1 className="text-2xl font-semibold">
				{i18n._({ id: "activity.title", message: "Activity" })}
			</h1>
			{summary ? (
				<p>
					{i18n._({ id: "activity.count", message: "Event count" })}: {summary.count}
				</p>
			) : error ? (
				<p role="alert" className="text-kumo-danger">{error}</p>
			) : (
				<Loader />
			)}
			<Button type="button" onClick={() => void load()}>
				{i18n._({ id: "activity.refresh", message: "Refresh" })}
			</Button>
		</section>
	);
}

Define the corresponding route in the native runtime. Native handlers receive one context argument:

routes: {
	summary: {
		permission: "plugins:read",
		handler: async (ctx) => ({
			count: await ctx.storage.events.count(),
		}),
	},
},

Private routes default to the administrator-only plugins:manage permission. Declare the narrowest existing permission that matches the operation. Use public: true only for an endpoint intended for unauthenticated internet traffic.

Export the page from the admin entrypoint:

import type { PluginAdminExports } from "emdash";

import { ActivityPage } from "./ActivityPage.js";

export const pages: PluginAdminExports["pages"] = {
	"/activity": ActivityPage,
};

Page labels are passed through the admin’s shared Lingui instance. A label such as Settings uses the admin’s translation when one exists. A plugin can load its own message catalog into the shared instance for plugin-specific labels and component messages; otherwise the declared English message is the fallback.

Load the plugin catalog when the admin entrypoint is imported, then load it again after the administrator changes locale. The following small German catalog uses the same IDs as the page and widget examples:

import { i18n } from "@lingui/core";

const catalogs: Record<string, Record<string, string>> = {
	de: {
		Activity: "Aktivität",
		"activity.title": "Aktivität",
		"activity.count": "Ereignisanzahl",
		"activity.refresh": "Aktualisieren",
		"activity.load-error": "Aktivität konnte nicht geladen werden",
		"activity.unavailable": "Nicht verfügbar",
		"activity.default-locale": "Standardsprache",
	},
};

function loadPluginCatalog() {
	const messages = catalogs[i18n.locale];
	if (!messages || "activity.title" in i18n.messages) return;
	i18n.load(i18n.locale, messages);
}

loadPluginCatalog();
i18n.on("change", loadPluginCatalog);

Import the loader for its registration side effect before exporting components:

import "./i18n.js";

// Page, widget, panel, and column exports follow.

The admin replaces its active catalog when the locale changes. The change listener restores the plugin messages, and the message-ID check prevents i18n.load() from triggering a loop. For more locales, generate the message objects with the plugin’s Lingui build instead of maintaining them by hand. Keep @lingui/core and @lingui/react as peer dependencies so the plugin uses the host’s shared instance.

Dashboard widgets

A widget declaration has a required id and optional title and size:

admin: {
	entry: "@example/plugin-activity/admin",
	widgets: [
		{ id: "recent-activity", title: "Recent activity", size: "half" },
	],
},

Export a component under the same ID. This widget reads the same summary route as the page and supplies only the card content; EmDash supplies the surrounding dashboard card and heading.

import { useLingui } from "@lingui/react";
import { useQuery } from "@tanstack/react-query";
import type { PluginAdminExports } from "emdash";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";

interface ActivitySummary {
	count: number;
}

async function loadSummary(fallbackMessage: string) {
	const response = await apiFetch(
		"/_emdash/api/plugins/plugin-activity/summary",
	);
	return parseApiResponse<ActivitySummary>(
		response,
		fallbackMessage,
	);
}

function RecentActivityWidget() {
	const { i18n } = useLingui();
	const { data, isLoading, isError } = useQuery({
		queryKey: ["plugin-activity", "summary"],
		queryFn: () =>
			loadSummary(
				i18n._({ id: "activity.load-error", message: "Could not load activity" }),
			),
	});

	return (
		<p>
			{i18n._({ id: "activity.count", message: "Event count" })}:{" "}
			{isLoading
				? "…"
				: isError
					? i18n._({ id: "activity.unavailable", message: "Unavailable" })
					: (data?.count ?? 0)}
		</p>
	);
}

export const widgets: PluginAdminExports["widgets"] = {
	"recent-activity": RecentActivityWidget,
};

The host places the component inside a dashboard card and renders title as its heading. Keep the component compact and do not add a second card shell. size accepts full, half, or third; it is stored as a layout hint, but the current dashboard renders plugin widgets in its responsive two-column grid without applying that hint.

Content editor panels

An editor panel adds a host-framed section to the settings sidebar of a saved entry. It does not mount for a new entry because no saved entry exists yet.

Panels and content-list columns are discovered directly from the trusted admin module. They need adminEntry and admin.entry so the module loads, but they do not need entries in admin.pages or admin.widgets.

import type {
	ContentEditorPanelContext,
	ContentEditorPanelExtension,
} from "@emdash-cms/admin";
import { useLingui } from "@lingui/react";

function ActivityPanel({ entry, collection, locale }: ContentEditorPanelContext) {
	const { i18n } = useLingui();
	const displayLocale =
		locale ??
		i18n._({ id: "activity.default-locale", message: "Default locale" });

	return (
		<p className="text-sm text-kumo-subtle">
			{collection}/{entry.slug} ({displayLocale})
		</p>
	);
}

export const contentEditorPanels = [
	{
		id: "activity-summary",
		title: "Activity summary",
		component: ActivityPanel,
		collections: ["posts", "pages"],
		order: 10,
	},
] satisfies readonly ContentEditorPanelExtension[];

Panel fields have the following behavior:

  • id, title, and component are required. The ID must be unique among this plugin’s panels.
  • collections is an array of collection names or a predicate. Omit it to show the panel for every collection.
  • minRole is a numeric visibility threshold. It does not authorize API calls.
  • order sorts lower values first. Ties use plugin ID and panel ID.

The component receives the saved entry, its collection, and the resolved locale. Keep its layout responsive to the narrow sidebar. EmDash isolates component and collection-predicate failures so one panel cannot unmount the editor.

Content-list columns

A content-list column adds read-only cells to active collection lists. The host still owns pagination, row actions, loading and empty states, and the table itself. Columns are not shown in Trash.

The following column uses visibleItems to fetch one page of statuses. Every cell uses the same React Query key, so the requests share one result instead of issuing one request per row.

import { useQuery } from "@tanstack/react-query";
import type {
	ContentListColumnCellContext,
	ContentListColumnExtension,
} from "@emdash-cms/admin";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";

async function loadStatuses(
	collection: string,
	locale: string | undefined,
	ids: readonly string[],
) {
	const response = await apiFetch(
		"/_emdash/api/plugins/plugin-activity/statuses",
		{
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ collection, locale, ids }),
		},
	);
	return parseApiResponse<Record<string, string>>(
		response,
		"Could not load activity statuses",
	);
}

function ActivityCell({
	item,
	visibleItems,
	collection,
	locale,
}: ContentListColumnCellContext) {
	const ids = visibleItems.map((visibleItem) => visibleItem.id);
	const { data } = useQuery({
		queryKey: ["plugin-activity", "statuses", collection, locale ?? null, ids],
		queryFn: () => loadStatuses(collection, locale, ids),
	});

	return <span>{data?.[item.id] ?? "-"}</span>;
}

export const contentListColumns = [
	{
		id: "activity",
		label: "Activity",
		cell: ActivityCell,
		collections: ["posts", "pages"],
		align: "end",
		order: 10,
	},
] satisfies readonly ContentListColumnExtension[];

Column fields have the following behavior:

  • id, label, and cell are required. The ID must be unique among this plugin’s columns.
  • header replaces the header content with a component; label remains the host fallback.
  • collections, minRole, and order behave like their panel equivalents.
  • align accepts start or end and uses logical alignment for left-to-right and right-to-left locales.

Columns cannot add browser-only sorting or filtering. Those controls would affect only the loaded cursor page, not the full server-backed collection.

Disabled plugins

When an administrator disables the plugin, EmDash removes its pages, widgets, panels, and columns from the admin. Its private routes return not found, and its hooks stop running. Re-enabling the plugin rebuilds the hook pipeline and makes its trusted admin exports available again.

Package the entrypoint

Export the admin module separately from the server runtime so Astro can bundle it for the browser with the host’s React, Kumo, and Lingui instances. Distributing native plugins provides the complete package layout, exports, peer dependencies, and build commands.