Skip to main content
🎀 Speaking at KubeCon EU 2026 Lessons Learned Orchestrating Multi-Tenant GPUs on OpenShift AI View Session
🎀 Speaking at Red Hat Summit 2026 GPUs take flight: Safety-first multi-tenant Platform Engineering with NVIDIA and OpenShift AI Learn More
Building Custom Backstage Plugins for Your IDP
Platform Engineering

Building Custom Backstage Plugins for Your IDP

A hands-on guide to building custom Backstage plugins that integrate your internal tools, APIs, and workflows into a unified Internal Developer Portal.

LB
Luca Berton
Β· 2 min read

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

Creating a Frontend Plugin

Scaffold the Plugin

# From your Backstage root directory
yarn new --select plugin

# Follow the prompts:
# Plugin ID: gpu-dashboard
# Owner: @platform-team

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

Define 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.yaml

Testing 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.yaml

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

Luca Berton Ansible Pilot Ansible by Example Open Empower K8s Recipes Terraform Pilot CopyPasteLearn ProteinLens TechMeOut