Backstage gives you an Internal Developer Portal out of the box. But the real value comes when you build custom plugins that integrate your specific tools, APIs, and workflows. Here is how to build plugins that your developers will actually use.
Why Custom Plugins
The Backstage plugin marketplace has hundreds of community plugins for common tools like Kubernetes, ArgoCD, PagerDuty, and GitHub. But every organization has internal tools that no marketplace plugin covers:
- Internal deployment pipelines
- Custom cost dashboards
- Team health metrics
- Compliance and audit tooling
- GPU cluster status (for AI workloads)
- Custom onboarding workflows
Custom plugins turn Backstage from a catalog viewer into the single pane of glass your platform engineering team has been promising.
Plugin Architecture
Backstage plugins come in three types:
- Frontend plugins: React components that render in the Backstage UI
- Backend plugins: Node.js services that handle API calls, database access, and business logic
- Common plugins: Shared types and utilities used by both frontend and backend
A typical custom plugin has both frontend and backend components:
plugins/
my-plugin/ # Frontend
src/
components/
api/
plugin.ts
routes.ts
my-plugin-backend/ # Backend
src/
service/
router.ts
plugin.ts
my-plugin-common/ # Shared types
src/
types.tsCreating a Frontend Plugin
Scaffold the Plugin
# From your Backstage root directory
yarn new --select plugin
# Follow the prompts:
# Plugin ID: gpu-dashboard
# Owner: @platform-teamThis generates the boilerplate in plugins/gpu-dashboard/.
Define the Plugin
// plugins/gpu-dashboard/src/plugin.ts
import {
createPlugin,
createRoutableExtension,
} from '@backstage/core-plugin-api';
export const gpuDashboardPlugin = createPlugin({
id: 'gpu-dashboard',
routes: {
root: rootRouteRef,
},
});
export const GpuDashboardPage = gpuDashboardPlugin.provide(
createRoutableExtension({
name: 'GpuDashboardPage',
component: () =>
import('./components/DashboardPage').then(m => m.DashboardPage),
mountPoint: rootRouteRef,
}),
);Build the Component
// plugins/gpu-dashboard/src/components/DashboardPage.tsx
import React from 'react';
import {
Header,
Page,
Content,
InfoCard,
Progress,
} from '@backstage/core-components';
import { useGpuClusters } from '../hooks/useGpuClusters';
export const DashboardPage = () => {
const { clusters, loading, error } = useGpuClusters();
if (loading) return <Progress />;
if (error) return <div>Error: {error.message}</div>;
return (
<Page themeId="tool">
<Header title="GPU Cluster Dashboard" />
<Content>
{clusters.map(cluster => (
<InfoCard key={cluster.name} title={cluster.name}>
<p>GPUs Available: {cluster.available}/{cluster.total}</p>
<p>Utilization: {cluster.utilization}%</p>
<p>Running Jobs: {cluster.activeJobs}</p>
</InfoCard>
))}
</Content>
</Page>
);
};Create an API Client
// plugins/gpu-dashboard/src/api/GpuApiClient.ts
import { createApiRef } from '@backstage/core-plugin-api';
export interface GpuCluster {
name: string;
total: number;
available: number;
utilization: number;
activeJobs: number;
}
export const gpuApiRef = createApiRef<GpuApi>({
id: 'plugin.gpu-dashboard.api',
});
export interface GpuApi {
getClusters(): Promise<GpuCluster[]>;
}
export class GpuApiClient implements GpuApi {
private readonly baseUrl: string;
constructor(options: { baseUrl: string }) {
this.baseUrl = options.baseUrl;
}
async getClusters(): Promise<GpuCluster[]> {
const response = await fetch(
`${this.baseUrl}/api/gpu-dashboard/clusters`
);
return response.json();
}
}Creating a Backend Plugin
Scaffold the Backend
yarn new --select backend-plugin
# Plugin ID: gpu-dashboardDefine the Router
// plugins/gpu-dashboard-backend/src/router.ts
import { Router } from 'express';
import { Logger } from 'winston';
export interface RouterOptions {
logger: Logger;
}
export async function createRouter(
options: RouterOptions
): Promise<Router> {
const { logger } = options;
const router = Router();
router.get('/clusters', async (req, res) => {
try {
// Fetch from your internal GPU management API
const clusters = await fetchGpuClusters();
res.json(clusters);
} catch (error) {
logger.error('Failed to fetch GPU clusters', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/clusters/:name/jobs', async (req, res) => {
const { name } = req.params;
const jobs = await fetchClusterJobs(name);
res.json(jobs);
});
return router;
}
async function fetchGpuClusters() {
// Integrate with your actual GPU management system
// NVIDIA DCGM, Kubernetes GPU metrics, or custom API
const response = await fetch(
'https://gpu-manager.internal/api/v1/clusters'
);
return response.json();
}Register the Backend Plugin
// packages/backend/src/index.ts
import { gpuDashboardPlugin } from '@internal/plugin-gpu-dashboard-backend';
const backend = createBackend();
backend.add(gpuDashboardPlugin());
backend.start();Entity Cards
The most useful plugins are not standalone pages but cards that appear on existing entity pages. Add GPU information to service catalog entries:
// plugins/gpu-dashboard/src/components/GpuEntityCard.tsx
import React from 'react';
import { InfoCard } from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
export const GpuEntityCard = () => {
const { entity } = useEntity();
const gpuRequirement = entity.metadata.annotations?.[
'gpu-dashboard/gpu-type'
];
if (!gpuRequirement) return null;
return (
<InfoCard title="GPU Requirements">
<p>GPU Type: {gpuRequirement}</p>
<p>Requested: {entity.metadata.annotations?.['gpu-dashboard/count']}</p>
</InfoCard>
);
};Register it as an entity card:
export const EntityGpuCard = gpuDashboardPlugin.provide(
createComponentExtension({
name: 'EntityGpuCard',
component: {
lazy: () =>
import('./components/GpuEntityCard').then(m => m.GpuEntityCard),
},
}),
);Software Templates
Create templates that developers use to provision new services with GPU support:
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: gpu-ml-service
title: ML Service with GPU Support
description: Create a new ML service with GPU provisioning on OpenShift AI
spec:
owner: platform-team
type: service
parameters:
- title: Service Details
properties:
name:
title: Service Name
type: string
gpuType:
title: GPU Type
type: string
enum: [a100, h100, l40s]
gpuCount:
title: Number of GPUs
type: integer
default: 1
model:
title: Model to Serve
type: string
steps:
- id: fetch
name: Fetch Template
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
gpuType: ${{ parameters.gpuType }}
gpuCount: ${{ parameters.gpuCount }}
- id: publish
name: Publish to GitLab
action: publish:gitlab
input:
repoUrl: gitlab.com?owner=myorg&repo=${{ parameters.name }}
- id: register
name: Register in Catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yamlTesting Plugins
Unit Tests
import { renderInTestApp } from '@backstage/test-utils';
import { GpuDashboardPage } from './DashboardPage';
describe('GpuDashboardPage', () => {
it('renders cluster information', async () => {
const { getByText } = await renderInTestApp(
<GpuDashboardPage />
);
expect(getByText('GPU Cluster Dashboard')).toBeInTheDocument();
});
});Integration Tests
import { startTestBackend } from '@backstage/backend-test-utils';
import { gpuDashboardPlugin } from './plugin';
describe('GPU Dashboard Backend', () => {
it('returns cluster data', async () => {
const { server } = await startTestBackend({
features: [gpuDashboardPlugin()],
});
const response = await server.inject({
method: 'GET',
url: '/api/gpu-dashboard/clusters',
});
expect(response.statusCode).toBe(200);
});
});Deployment with Ansible
Automate Backstage deployment including your custom plugins using Ansible:
---
- name: Deploy Backstage with custom plugins
hosts: k8s_cluster
tasks:
- name: Build Backstage image with plugins
community.docker.docker_image:
name: "registry.internal/backstage:{{ backstage_version }}"
source: build
build:
path: "{{ backstage_repo }}"
dockerfile: packages/backend/Dockerfile
- name: Deploy to Kubernetes
kubernetes.core.k8s:
state: present
src: manifests/backstage-deployment.yamlFinal Thoughts
The Internal Developer Portal is only as useful as the tools it integrates. Community plugins cover the common cases, but custom plugins are where your IDP becomes indispensable. Start with one high-value plugin β the tool your developers check most frequently β and build from there.
The investment pays off quickly. Every tool you bring into Backstage is one fewer context switch for your developers and one more reason they will actually use the portal instead of bookmarking twenty different dashboards.
