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

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:

  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

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.