Admin UI

On this page

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

Admin Entry Point

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

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:

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

Reference it in your plugin definition:

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

Admin pages are React components that receive the plugin context via hooks.

Page Definition

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

Page Component

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

Plugin API Hook

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.

Dashboard Widgets

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

Widget Definition

Define widgets in admin.widgets:

admin: {
	widgets: [
		{
			id: "seo-overview", // Unique widget ID
			title: "SEO Overview", // Widget title (optional)
			size: "half", // "full" | "half" | "third"
		},
	];
}

Widget Component

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

Widget Sizes

SizeDescription
fullFull dashboard width
halfHalf dashboard width
thirdOne-third dashboard width

Widgets wrap automatically based on screen width.

Export Structure

The admin entry point exports two objects:

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

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

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

Build Configuration

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

tsdown

export default {
  entry: {
    index: "src/index.ts",
    admin: "src/admin.tsx"
  },
  format: "esm",
  dts: true,
  external: ["react", "react-dom", "emdash", "@emdash-cms/admin"]
};

tsup

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

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

Example: Complete Admin UI

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