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