Dashboard cards
Plugins can contribute self-contained cards to the admin dashboard. The four built-in stat cards (Posts / Pages / Categories / Tags) live above; plugin-contributed cards appear in a 1 / 2 / 3-column responsive grid below.
The flexweg-metrics MU plugin uses this to surface Flexweg storage usage and Firestore document counts. Custom plugins can add their own — backup status, sync indicators, latest log lines, anything that fits a card.
Registering a card
import { MyCard } from "./MyCard";
export const manifest: PluginManifest = {
// …
register(api) {
api.registerDashboardCard({
id: "my-plugin/my-card",
priority: 50,
component: MyCard,
});
},
};
That's the entire registration surface.
Fields
id— namespaced like<plugin-id>/<card-name>. Must be unique across all registered cards.priority— lower runs first; default 100. Use to position your card relative to others.component— React component that takes no props. Self-contained.
The card component
Cards are pure React components. They fetch their own data and manage their own loading / error / empty states.
import { useEffect, useState } from "react";
import { getStorageLimits } from "@flexweg/cms-runtime";
export function StorageCard() {
const [data, setData] = useState<{ used: number; total: number } | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const result = await getStorageLimits();
if (!cancelled) setData({ used: result.used, total: result.limit });
} catch (err) {
if (!cancelled) setError(String(err));
}
})();
return () => { cancelled = true; };
}, []);
if (error) return <Card><div className="text-red-500">Error: {error}</div></Card>;
if (!data) return <Card><div>Loading…</div></Card>;
const pct = (data.used / data.total) * 100;
return (
<Card>
<h3>Flexweg storage</h3>
<div>{(data.used / 1e6).toFixed(0)} MB / {(data.total / 1e6).toFixed(0)} MB</div>
<progress value={pct} max="100" />
</Card>
);
}
Use whatever UI primitives fit your design. The dashboard layout reserves a card-sized slot but doesn't constrain the content — you can render arbitrary HTML.
Refresh model
Cards do not poll. The dashboard re-mounts on navigation away/back, which is the expected refresh trigger. If you want a manual refresh button on your card, add one — but don't auto-refresh on a timer (would burn API calls and Firestore reads).
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
// re-runs whenever refreshKey changes
}, [refreshKey]);
return (
<Card>
{/* content */}
<button onClick={() => setRefreshKey((k) => k + 1)}>Refresh</button>
</Card>
);
Reading admin context
Cards can use admin hooks — they run in React, in the admin, on the dashboard:
import { useCmsData, useAuth } from "@flexweg/cms-runtime";
export function MyCard() {
const { posts, terms } = useCmsData();
const { user } = useAuth();
// …
}
useCmsData() gives you the live posts/pages/terms/media. useAuth() gives you the current user (admin or editor).
Don't fetch the same data the dashboard already has — read through hooks.
Reading plugin config
If your card displays plugin-config-driven content, read from settings via useCmsData:
import { useCmsData } from "@flexweg/cms-runtime";
export function MyCard() {
const { settings } = useCmsData();
const config = settings?.pluginConfigs?.["my-plugin"] as MyConfig | undefined;
// …
}
Or via your plugin's typed helper if you have one.
Internationalisation
Cards live in your plugin's i18n namespace:
import { useTranslation } from "react-i18next";
export function MyCard() {
const { t } = useTranslation("my-plugin");
return (
<Card>
<h3>{t("dashboardCard.title")}</h3>
{/* … */}
</Card>
);
}
Sizing + responsive layout
The dashboard renders cards in a grid-cols-1 md:grid-cols-2 lg:grid-cols-3 grid. Each card occupies one cell at every breakpoint. Don't try to span multiple cells — the grid doesn't support it (intentionally — keeps the visual rhythm consistent).
If your card needs more space than fits comfortably, consider:
- Stripping content (link to a settings page for details)
- Splitting into two cards
- Or moving to its own route entirely (link to it from the dashboard if you want)
Examples in the repo
src/mu-plugins/flexweg-metrics/StorageCard.tsx— one-shot fetch + manual refresh buttonsrc/mu-plugins/flexweg-metrics/FirestoreCard.tsx— parallelgetCountFromServeraggregation queries
Both demonstrate good patterns: cancellable effects, error fallbacks, no polling.
Continue
- Plugin manifest
- Hooks
- Plugins must-use → flexweg-metrics — example use case