API-Driven UI Configuration: Stop Hardcoding Your Dashboard
Every dashboard has summary cards at the top. “Total Users: 142”, “Active: 98”, “At Risk: 12”. Product loves them. Users click them constantly.
Then Product wants changes. “Add a ‘High Cost’ card.” That’s a frontend deploy. “Change the ‘At Risk’ threshold from 5 to 10.” Another deploy. “Enterprise clients want different cards.” Now you’re adding feature flags. “A/B test which cards drive engagement.” Good luck.
The pattern: UI configuration that changes frequently is hardcoded in the frontend. Every change requires a deploy. Every customer variation requires conditional logic.
The fix: treat UI configuration as data, not code. Fetch it from an API. Backend owns the config. Frontend renders whatever it gets.
+-----------------------------------------------------------+
| HARDCODED CONFIG |
| |
| const CARDS = [ |
| { label: 'Total', key: 'total' }, |
| { label: 'Active', key: 'active' }, |
| ]; |
| |
| Want to add a card? --> Deploy frontend |
| Want to change threshold? --> Deploy frontend |
| Enterprise wants different cards? --> Feature flags |
+-----------------------------------------------------------+
+-----------------------------------------------------------+
| API-DRIVEN CONFIG |
| |
| GET /api/ui-config |
| { |
| "summaryCards": [...], |
| "facets": [...], |
| "thresholds": {...} |
| } |
| |
| Backend owns it. Frontend renders it. |
+-----------------------------------------------------------+
Now adding a card is a config change. Different cards per customer is a database row. A/B testing is backend logic. Frontend deploys for UI changes drop to zero.
The Core Idea
// Don't hardcode
const CARDS = [{ label: "Total", icon: UsersIcon }];
// Fetch
const { config } = useDashboardConfig("users");
// config.summaryCards, config.facets, config.thresholds
Backend owns the config. Frontend renders whatever it gets.
The API Contract
interface UIConfig {
users: EntityUIConfig;
products: EntityUIConfig;
}
interface EntityUIConfig {
summaryCards: SummaryCardConfig[];
facets: FacetConfig[];
thresholds: Record<string, number>;
}
interface SummaryCardConfig {
key: string; // matches data key
label: string;
icon: string; // string key, not component
variant: "default" | "success" | "warning" | "error";
}
Example Response
{
"users": {
"summaryCards": [
{
"key": "total",
"label": "Total Users",
"icon": "users",
"variant": "default"
},
{
"key": "active",
"label": "Active",
"icon": "check-circle",
"variant": "success"
},
{
"key": "atRisk",
"label": "At Risk",
"icon": "alert-triangle",
"variant": "warning"
}
],
"facets": [
{ "key": "status", "label": "Status" },
{ "key": "role", "label": "Role" }
],
"thresholds": {
"atRisk": 5,
"highCost": 1000
}
}
}
The Hook
const FALLBACK: UIConfig = {
users: {
summaryCards: [
{ key: "total", label: "Total", icon: "users", variant: "default" },
],
facets: [],
thresholds: {},
},
};
function useDashboardConfig(tab: keyof UIConfig) {
const { data, isError } = useQuery({
queryKey: ["ui-config"],
queryFn: () => fetch("/api/ui-config").then(r => r.json()),
staleTime: 5 * 60 * 1000, // 5 min
});
const isFallback = isError || !data;
if (isFallback) {
console.warn(`Using fallback config for ${tab}`);
}
return {
config: data?.[tab] ?? FALLBACK[tab],
isFallback,
};
}
Key decisions:
staleTime: 5 min: Config rarely changes. Don’t refetch every mount.- Always return config: Never
undefined. Fallback is mandatory. - Log fallback: Know when API is failing.
Icons as String Keys
Icons can’t be serialized. Use a registry:
const ICONS = {
users: Users,
"check-circle": CheckCircle,
"alert-triangle": AlertTriangle,
};
function DynamicIcon({ name }: { name: string }) {
const Icon = ICONS[name] ?? Circle;
return <Icon className="w-4 h-4" />;
}
API returns "icon": "alert-triangle". Frontend maps it.
Rendering Cards
function SummaryCards({ config, data, onCardClick }) {
return (
<div className="grid grid-cols-4 gap-4">
{config.map(card => (
<button
key={card.key}
onClick={() => onCardClick(card.key)}
className={VARIANT_STYLES[card.variant]}
>
<DynamicIcon name={card.icon} />
<span>{card.label}</span>
<span>{data[card.key]?.toLocaleString() ?? "—"}</span>
</button>
))}
</div>
);
}
Component doesn’t know what cards exist. Renders whatever config says.
Fallback Strategy
The API will fail. Plan for it.
Layer 1: React Query retry (2 attempts)
Layer 2: Stale-while-revalidate (serve cached for 5 min)
Layer 3: Hardcoded fallback (always available)
Layer 4: UI indicator (show banner when using fallback)
Layer 5: Monitoring (track fallback usage)
{
isFallback && <Banner variant="warning">Using default configuration.</Banner>;
}
When to Skip This
- Config never changes: API overhead not worth it
- No backend team: Just deploy together
- Complex conditional logic: 50 business rules? Put them in code
Works when:
- Different customers need different configs
- Product iterates on dashboard without deploys
- A/B testing UI elements
Results
Three dashboards:
- “Add a card” went from 2-hour deploy to 5-minute config change
- Enterprise clients get their own config, no code branches
- “What config is this user seeing?” is one API call
- Config API had 2 outages. Users never noticed (fallbacks).
Dev Tools
if (typeof window !== "undefined") {
window.__uiConfig = data;
window.__clearUIConfigCache = () => {
queryClient.invalidateQueries(["ui-config"]);
};
}
In console:
__uiConfig; // See current config
__clearUIConfigCache(); // Force refetch
Your dashboard config is data. Stop deploying to change a label.