Building a Custom Entity Page Card in Backstage
Entity pages are where engineers actually spend their time, so a well-placed card that surfaces the right data — deploy status, on-call, cost, SLOs — is one of the highest-leverage UI customizations you can ship. This how-to builds a custom InfoCard component, reads the current entity from context, and mounts it on the catalog entity page. It applies the Custom UI Components for Portals patterns to a concrete card.
Doing this well matters because the entity page is the portal’s most-visited surface: a card that loads slowly or renders inconsistently undermines the whole portal’s perceived quality.
Prerequisites
The card needs four building blocks: InfoCard and state components from core-components, useEntity from catalog-react, useApi from core-plugin-api, and edit access to the entity page layout where you mount it.
- Backstage 1.20+ with a frontend plugin scaffolded (see Building Custom Backstage Plugins).
@backstage/core-components0.14+ (providesInfoCard,Progress,ResponseErrorPanel).@backstage/plugin-catalog-react1.12+ (providesuseEntity).@backstage/core-plugin-api1.9+ for API access anduseApi.- React 18.2 and TypeScript 5.3+.
- Access to edit the app’s entity page layout (
packages/app/src/components/catalog/EntityPage.tsx).
Exact Configuration
The data flow is one line: useEntity names the current service, useApi resolves a typed client, the client fetches through the backend, and the card renders — with explicit loading and error branches so a failure never blanks the tab.
1. Build the card component
useEntity returns the entity whose page is currently rendered, so the card is automatically scoped to context. Handle loading and error states explicitly — a card that throws will blank out the whole page tab.
// plugins/insights/src/components/DeployStatusCard/DeployStatusCard.tsx
// Requires @backstage/core-components >= 0.14.0, @backstage/plugin-catalog-react >= 1.12.0
import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { InfoCard, Progress, ResponseErrorPanel } from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import { useApi } from '@backstage/core-plugin-api';
import { insightsApiRef } from '../../api';
import { Typography } from '@material-ui/core';
export const DeployStatusCard = () => {
const { entity } = useEntity();
const insightsApi = useApi(insightsApiRef);
const { value, loading, error } = useAsync(
() => insightsApi.getDeployStatus(entity.metadata.name),
[entity.metadata.name],
);
if (loading) return <Progress />;
if (error) return <ResponseErrorPanel error={error} />;
return (
<InfoCard title="Deploy Status">
<Typography variant="body1">
Environment: {value?.environment ?? 'unknown'}
</Typography>
<Typography variant="body1">
Last deploy: {value?.lastDeployedAt ?? 'never'}
</Typography>
<Typography variant="body1">Status: {value?.status ?? 'n/a'}</Typography>
</InfoCard>
);
};
2. Define the API ref the card consumes
Keep data access behind a typed API ref rather than calling fetch inline, so the card is testable and the endpoint is swappable. Back this with a backend plugin REST endpoint so the card never talks to upstreams directly.
// plugins/insights/src/api/index.ts
// Requires @backstage/core-plugin-api >= 1.9.0
import { createApiRef, DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
export interface DeployStatus {
environment: string;
lastDeployedAt: string;
status: string;
}
export const insightsApiRef = createApiRef<{
getDeployStatus(serviceName: string): Promise<DeployStatus>;
}>({ id: 'plugin.insights.service' });
export class InsightsClient {
constructor(
private readonly discovery: DiscoveryApi,
private readonly fetchApi: FetchApi,
) {}
async getDeployStatus(serviceName: string): Promise<DeployStatus> {
const base = await this.discovery.getBaseUrl('insights');
const res = await this.fetchApi.fetch(`${base}/services/${serviceName}`);
if (!res.ok) throw new Error(`Insights API ${res.status}`);
return res.json();
}
}
3. Register the API factory in the plugin
// plugins/insights/src/plugin.ts
// Requires @backstage/core-plugin-api >= 1.9.0
import {
createApiFactory,
createPlugin,
discoveryApiRef,
fetchApiRef,
} from '@backstage/core-plugin-api';
import { insightsApiRef, InsightsClient } from './api';
export const insightsPlugin = createPlugin({
id: 'insights',
apis: [
createApiFactory({
api: insightsApiRef,
deps: { discovery: discoveryApiRef, fetchApi: fetchApiRef },
factory: ({ discovery, fetchApi }) => new InsightsClient(discovery, fetchApi),
}),
],
});
4. Export the card and mount it on the entity page
// plugins/insights/src/index.ts
export { insightsPlugin } from './plugin';
export { DeployStatusCard } from './components/DeployStatusCard/DeployStatusCard';
// packages/app/src/components/catalog/EntityPage.tsx
// Requires @backstage/plugin-catalog >= 1.20.0
import { Grid } from '@material-ui/core';
import { DeployStatusCard } from '@internal/backstage-plugin-insights';
const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
{/* existing cards... */}
<Grid item md={6} xs={12}>
<DeployStatusCard />
</Grid>
</Grid>
);
Validation
# Requires Node.js >= 20 and the app running via `yarn start`
# 1. Type-check the plugin compiles
yarn workspace @internal/backstage-plugin-insights tsc --noEmit
# expected: no output (exit 0)
# 2. Lint the new component
yarn backstage-cli package lint --since origin/main
# expected: no lint errors
In the browser, confirm rendering and resilience — the card must appear on the entity page, show a spinner under a slow network, and render an inline error panel on a failure rather than crashing the tab.
- Open any
Componententity page; the “Deploy Status” card appears in the Overview tab. - Throttle the network in DevTools — the card shows the
<Progress>spinner, not a blank panel. - Point the API at a 500 — the card renders
ResponseErrorPanelinline instead of crashing the tab.
Edge Cases & Troubleshooting
The failures split into context mistakes (rendered outside the entity layout, stale deps) and wiring mistakes (unregistered API factory, unmounted backend). The diagram groups the symptoms so you check the mount point or the registration.
| Symptom | Root Cause | Resolution |
|---|---|---|
useEntity must be used within an EntityProvider |
Card rendered outside the entity layout | Mount only inside EntityPage/EntityLayout, not on standalone routes |
| Card blanks the whole tab on error | Error thrown instead of handled | Branch on the error from useAsync and render ResponseErrorPanel |
No API factory found for plugin.insights.service |
API factory not registered | Add the createApiFactory entry to the plugin’s apis array |
| Card shows stale data across entities | Missing dependency in useAsync deps |
Include entity.metadata.name in the deps array so it refetches per entity |
| 404 from the discovery base URL | Backend plugin not mounted | Confirm the insights backend plugin is registered and serving /api/insights |
Frequently Asked Questions
Should the card fetch data directly or go through an API ref?
Always go through a typed API ref. It decouples the component from transport details, lets you inject a mock in tests via TestApiProvider, and centralizes auth and base-URL resolution through the discovery API.
How do I show the card only for certain entity types?
Wrap it with EntitySwitch and an isKind/isComponentType filter in EntityPage.tsx, so the card renders only for, say, Component entities of type service, and falls back to nothing otherwise.