Skip to main content

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 button
  • src/mu-plugins/flexweg-metrics/FirestoreCard.tsx — parallel getCountFromServer aggregation queries

Both demonstrate good patterns: cancellable effects, error fallbacks, no polling.

Continue