Skip to content
Back to blog Backstage Plugins: Building Custom Developer Portal Features

Backstage Plugins: Building Custom Developer Portal Features

Platform EngineeringDevOps

Backstage Plugins: Building Custom Developer Portal Features

Backstage is only as useful as the plugins you add. This guide covers building custom plugins - frontend components, backend APIs, and integrations with your existing infrastructure.

TL;DR

  • Plugins are modular React/Node packages
  • Frontend plugin = React components + routes
  • Backend plugin = Express routes + services
  • Full example: Service health dashboard plugin
  • Testing and deployment patterns included

Plugin Architecture

backstage/
├── packages/
│   ├── app/                    # Frontend app
│   └── backend/                # Backend app
└── plugins/
    └── my-plugin/
        ├── src/
        │   ├── components/     # React components
        │   ├── api/            # API client
        │   ├── routes.tsx      # Plugin routes
        │   └── plugin.ts       # Plugin definition
        └── package.json

Create a Plugin

# Create new plugin
cd backstage
yarn new --select plugin

# Follow prompts:
# ? Enter the ID of the plugin [required] service-health
# ? Enter the owner(s) of the plugin platform-team

Frontend Plugin

Plugin Definition

// plugins/service-health/src/plugin.ts
import {
  createPlugin,
  createRoutableExtension,
  createApiFactory,
} from '@backstage/core-plugin-api';
import { serviceHealthApiRef, ServiceHealthClient } from './api';
import { rootRouteRef } from './routes';

export const serviceHealthPlugin = createPlugin({
  id: 'service-health',
  routes: {
    root: rootRouteRef,
  },
  apis: [
    createApiFactory({
      api: serviceHealthApiRef,
      deps: {},
      factory: () => new ServiceHealthClient(),
    }),
  ],
});

export const ServiceHealthPage = serviceHealthPlugin.provide(
  createRoutableExtension({
    name: 'ServiceHealthPage',
    component: () =>
      import('./components/ServiceHealthPage').then(m => m.ServiceHealthPage),
    mountPoint: rootRouteRef,
  }),
);

API Client

// plugins/service-health/src/api/types.ts
import { createApiRef } from '@backstage/core-plugin-api';

export interface ServiceHealth {
  name: string;
  status: 'healthy' | 'degraded' | 'down';
  latency: number;
  uptime: number;
  lastChecked: string;
}

export interface ServiceHealthApi {
  getServices(): Promise<ServiceHealth[]>;
  getService(name: string): Promise<ServiceHealth>;
}

export const serviceHealthApiRef = createApiRef<ServiceHealthApi>({
  id: 'plugin.service-health',
});

// plugins/service-health/src/api/client.ts
import { ServiceHealthApi, ServiceHealth } from './types';

export class ServiceHealthClient implements ServiceHealthApi {
  private baseUrl = '/api/service-health';

  async getServices(): Promise<ServiceHealth[]> {
    const response = await fetch(this.baseUrl);
    if (!response.ok) {
      throw new Error(`Failed to fetch services: ${response.statusText}`);
    }
    return response.json();
  }

  async getService(name: string): Promise<ServiceHealth> {
    const response = await fetch(`${this.baseUrl}/${name}`);
    if (!response.ok) {
      throw new Error(`Failed to fetch service: ${response.statusText}`);
    }
    return response.json();
  }
}

React Components

// plugins/service-health/src/components/ServiceHealthPage.tsx
import React from 'react';
import { useAsync } from 'react-use';
import {
  Content,
  ContentHeader,
  Page,
  Progress,
  ResponseErrorPanel,
  Table,
  TableColumn,
} from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { serviceHealthApiRef, ServiceHealth } from '../api';

const columns: TableColumn<ServiceHealth>[] = [
  { title: 'Service', field: 'name' },
  {
    title: 'Status',
    field: 'status',
    render: row => (
      <StatusIndicator status={row.status} />
    ),
  },
  { title: 'Latency', field: 'latency', render: row => `${row.latency}ms` },
  { title: 'Uptime', field: 'uptime', render: row => `${row.uptime}%` },
  { title: 'Last Checked', field: 'lastChecked' },
];

export const ServiceHealthPage = () => {
  const api = useApi(serviceHealthApiRef);
  const { value, loading, error } = useAsync(() => api.getServices(), []);

  if (loading) return <Progress />;
  if (error) return <ResponseErrorPanel error={error} />;

  return (
    <Page themeId="tool">
      <Content>
        <ContentHeader title="Service Health Dashboard" />
        <Table
          title="Services"
          columns={columns}
          data={value || []}
          options={{ search: true, paging: true }}
        />
      </Content>
    </Page>
  );
};

const StatusIndicator = ({ status }: { status: string }) => {
  const colors = {
    healthy: '#4caf50',
    degraded: '#ff9800',
    down: '#f44336',
  };
  
  return (
    <span style={{ 
      color: colors[status as keyof typeof colors],
      fontWeight: 'bold' 
    }}>
      {status.toUpperCase()}
    </span>
  );
};

Entity Card Component

Add a card to the entity page:

// plugins/service-health/src/components/ServiceHealthCard.tsx
import React from 'react';
import { useAsync } from 'react-use';
import {
  InfoCard,
  Progress,
  ResponseErrorPanel,
} from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { serviceHealthApiRef } from '../api';

export const ServiceHealthCard = () => {
  const { entity } = useEntity();
  const api = useApi(serviceHealthApiRef);
  const serviceName = entity.metadata.name;
  
  const { value, loading, error } = useAsync(
    () => api.getService(serviceName),
    [serviceName]
  );

  if (loading) return <Progress />;
  if (error) return <ResponseErrorPanel error={error} />;

  return (
    <InfoCard title="Service Health">
      <dl>
        <dt>Status</dt>
        <dd>{value?.status}</dd>
        <dt>Latency</dt>
        <dd>{value?.latency}ms</dd>
        <dt>Uptime</dt>
        <dd>{value?.uptime}%</dd>
      </dl>
    </InfoCard>
  );
};

// Export for use in entity page
export const serviceHealthPlugin.provide(
  createComponentExtension({
    name: 'ServiceHealthCard',
    component: {
      lazy: () => import('./components/ServiceHealthCard').then(m => m.ServiceHealthCard),
    },
  }),
);

Backend Plugin

// plugins/service-health-backend/src/plugin.ts
import { createBackendPlugin } from '@backstage/backend-plugin-api';
import { createRouter } from './router';

export const serviceHealthPlugin = createBackendPlugin({
  pluginId: 'service-health',
  register(env) {
    env.registerInit({
      deps: {
        httpRouter: coreServices.httpRouter,
        logger: coreServices.logger,
        config: coreServices.rootConfig,
      },
      async init({ httpRouter, logger, config }) {
        httpRouter.use(
          await createRouter({ logger, config }),
        );
      },
    });
  },
});

// plugins/service-health-backend/src/router.ts
import { Router } from 'express';
import { Logger } from 'winston';
import { Config } from '@backstage/config';

interface ServiceHealth {
  name: string;
  status: 'healthy' | 'degraded' | 'down';
  latency: number;
  uptime: number;
  lastChecked: string;
}

export async function createRouter(options: {
  logger: Logger;
  config: Config;
}): Promise<Router> {
  const { logger, config } = options;
  const router = Router();

  // Health check endpoint for each service
  const services = config.getConfigArray('serviceHealth.services');
  
  router.get('/', async (req, res) => {
    const results: ServiceHealth[] = [];
    
    for (const service of services) {
      const name = service.getString('name');
      const url = service.getString('healthUrl');
      
      try {
        const start = Date.now();
        const response = await fetch(url);
        const latency = Date.now() - start;
        
        results.push({
          name,
          status: response.ok ? 'healthy' : 'degraded',
          latency,
          uptime: 99.9, // Would come from metrics store
          lastChecked: new Date().toISOString(),
        });
      } catch (error) {
        results.push({
          name,
          status: 'down',
          latency: 0,
          uptime: 0,
          lastChecked: new Date().toISOString(),
        });
      }
    }
    
    res.json(results);
  });

  router.get('/:name', async (req, res) => {
    const { name } = req.params;
    const service = services.find(s => s.getString('name') === name);
    
    if (!service) {
      res.status(404).json({ error: 'Service not found' });
      return;
    }
    
    // ... check specific service
  });

  return router;
}

Configuration

# app-config.yaml
serviceHealth:
  services:
    - name: api-gateway
      healthUrl: https://api.company.com/health
    - name: user-service
      healthUrl: https://users.company.com/health
    - name: payment-service
      healthUrl: https://payments.company.com/health

Register Plugins

Frontend:

// packages/app/src/App.tsx
import { serviceHealthPlugin, ServiceHealthPage } from '@internal/plugin-service-health';

const routes = (
  <FlatRoutes>
    <Route path="/service-health" element={<ServiceHealthPage />} />
  </FlatRoutes>
);

// Add to sidebar
// packages/app/src/components/Root/Root.tsx
<SidebarItem icon={HeartIcon} to="service-health" text="Service Health" />

Backend:

// packages/backend/src/index.ts
import { serviceHealthPlugin } from '@internal/plugin-service-health-backend';

const backend = createBackend();
backend.add(serviceHealthPlugin);

Testing

// plugins/service-health/src/components/ServiceHealthPage.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { TestApiProvider } from '@backstage/test-utils';
import { ServiceHealthPage } from './ServiceHealthPage';
import { serviceHealthApiRef } from '../api';

const mockApi = {
  getServices: jest.fn().mockResolvedValue([
    { name: 'api', status: 'healthy', latency: 50, uptime: 99.9 },
    { name: 'db', status: 'degraded', latency: 200, uptime: 95.0 },
  ]),
};

describe('ServiceHealthPage', () => {
  it('renders service list', async () => {
    render(
      <TestApiProvider apis={[[serviceHealthApiRef, mockApi]]}>
        <ServiceHealthPage />
      </TestApiProvider>
    );

    await waitFor(() => {
      expect(screen.getByText('api')).toBeInTheDocument();
      expect(screen.getByText('HEALTHY')).toBeInTheDocument();
    });
  });
});

Publishing

# Build plugin
cd plugins/service-health
yarn build

# Publish to private registry
yarn publish --registry https://npm.company.com

References

======================================== Backstage + Custom Plugins

Your portal. Your features. Your way.

Found this helpful?

Comments