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 Docs: https://backstage.io/docs
- Plugin Development: https://backstage.io/docs/plugins
- Storybook: https://backstage.io/storybook