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.

Building blocks for an entity card core-components, useEntity, useApi, and entity-page edit access make a custom card possible. InfoCard + states useEntity useApi EntityPage access Card mountable context-scoped
useEntity is what scopes the card to whichever entity page is rendering it — no prop-drilling required.
  • Backstage 1.20+ with a frontend plugin scaffolded (see Building Custom Backstage Plugins).
  • @backstage/core-components 0.14+ (provides InfoCard, Progress, ResponseErrorPanel).
  • @backstage/plugin-catalog-react 1.12+ (provides useEntity).
  • @backstage/core-plugin-api 1.9+ for API access and useApi.
  • 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.

Entity card data flow useEntity names the service, useApi resolves a client that fetches through the backend, and the card renders with loading and error states. useEntity metadata.name useApi(ref) typed client fetch backend discovery base InfoCard render loading / error / data
Going through a typed API ref, not an inline fetch, is what makes the card unit-testable with a mock.

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.

Card rendering and resilience checks The card renders on the page, shows a spinner under throttling, and shows an error panel on a 500. Renders on page Overview tab Slow → spinner Progress, not blank 500 → error panel tab survives
The error-panel check is the important one — a thrown error blanks the entire entity tab, not just the card.
  1. Open any Component entity page; the “Deploy Status” card appears in the Overview tab.
  2. Throttle the network in DevTools — the card shows the <Progress> spinner, not a blank panel.
  3. Point the API at a 500 — the card renders ResponseErrorPanel inline 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.

Entity card failures by cause Rendered-outside-context and stale data are context mistakes; missing API factory and unmounted backend are wiring mistakes. Context mistakes outside EntityPage stale across entities → mount + deps Wiring mistakes no API factory backend unmounted → register + serve
Including entity.metadata.name in the useAsync deps is what makes the card refetch as you navigate between entities.
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.