background-shape
Writing Custom Backstage Plugins in TypeScript, A Hands On Tutorial
October 13, 2025 · 10 min read · by Muhammad Amal programming

TL;DR — Backstage 1.34 plugins have a clean structure now that the new backend system is the default. A real plugin is a frontend package, a backend package, a common package, and a node-library if you want extension points. Skip the legacy createRouter patterns and build against the new service interfaces from day one.

The first plugin most teams write is a thin frontend card that fetches data from an internal API and renders it on the entity page. That’s fine to learn the shape of things. Production plugins are different. They have a backend route, a permission integration, a config schema, an extension point so other plugins can extend them, and tests. This tutorial gets you to that production-shape plugin in one sitting.

What we’re building is a deployment-history plugin. It shows the last twenty deployments of a service, pulled from an internal API. It exposes a backend route, a frontend entity card, and an extension point that lets other plugins register custom deployment-source backends. The pattern generalizes to most plugin work you’ll do.

If you don’t have a Backstage portal yet, the previous post in this series covers the 1.34 setup. Here, the assumption is a working monorepo with the new backend system already wired.

1. Generating the Package Skeletons

Backstage’s CLI scaffolds plugin packages with the right structure. Run these from the repo root:

yarn new --select plugin --option id=deployments
yarn new --select backend-plugin --option id=deployments
yarn new --select plugin-common --option id=deployments
yarn new --select plugin-node --option id=deployments

After they run you’ll have four packages under plugins/:

plugins/
+- deployments/             # frontend (React)
+- deployments-backend/     # backend HTTP routes
+- deployments-common/      # shared types
+- deployments-node/        # backend extension points

The common package is where types shared between frontend and backend live. The node package is where extension point interfaces live, separate from the backend implementation so other backend plugins can depend on the interface without depending on the implementation.

This split feels heavy for a small plugin. It pays back the moment someone else wants to extend yours.

2. Defining the Common Types

Start with the shared types in plugins/deployments-common/src/types.ts. These are the contract between every package:

export interface Deployment {
  id: string;
  service: string;
  environment: 'dev' | 'staging' | 'prod';
  version: string;
  status: 'pending' | 'in_progress' | 'succeeded' | 'failed' | 'rolled_back';
  startedAt: string;
  finishedAt?: string;
  triggeredBy: string;
  commitSha: string;
  url?: string;
}

export interface DeploymentListResponse {
  items: Deployment[];
  totalCount: number;
}

export const deploymentsPermissions = {
  read: { name: 'deployments.read', attributes: { action: 'read' } },
} as const;

Export the permission definitions from common so the backend, frontend, and any custom permission policy can all import from the same place. This avoids the classic mistake of having a permission name typed differently in three files.

3. Designing the Extension Point

The node package is small but important. It defines an interface that downstream backend modules can implement to plug in new deployment sources (ArgoCD, GitHub Actions, GitLab, your in-house deployer):

// plugins/deployments-node/src/extensions.ts
import { createExtensionPoint } from '@backstage/backend-plugin-api';
import { Deployment } from '@internal/plugin-deployments-common';

export interface DeploymentSource {
  id: string;
  list(opts: {
    service: string;
    environment?: string;
    limit: number;
  }): Promise<Deployment[]>;
}

export interface DeploymentSourceExtensionPoint {
  addSource(source: DeploymentSource): void;
}

export const deploymentSourceExtensionPoint =
  createExtensionPoint<DeploymentSourceExtensionPoint>({
    id: 'deployments.source',
  });

That’s it. Other plugins can now register a new source against this extension point without touching the deployments plugin code.

4. Building the Backend

The backend is where the real work happens. Start with the plugin entry point:

// plugins/deployments-backend/src/plugin.ts
import { createBackendPlugin, coreServices } from '@backstage/backend-plugin-api';
import { createRouter } from './router';
import {
  DeploymentSource,
  deploymentSourceExtensionPoint,
} from '@internal/plugin-deployments-node';

export const deploymentsPlugin = createBackendPlugin({
  pluginId: 'deployments',
  register(env) {
    const sources: DeploymentSource[] = [];

    env.registerExtensionPoint(deploymentSourceExtensionPoint, {
      addSource(source) {
        sources.push(source);
      },
    });

    env.registerInit({
      deps: {
        logger: coreServices.logger,
        httpRouter: coreServices.httpRouter,
        config: coreServices.rootConfig,
        permissions: coreServices.permissions,
        httpAuth: coreServices.httpAuth,
      },
      async init({ logger, httpRouter, config, permissions, httpAuth }) {
        const router = await createRouter({
          logger,
          config,
          permissions,
          httpAuth,
          sources,
        });
        httpRouter.use(router);
        httpRouter.addAuthPolicy({ path: '/health', allow: 'unauthenticated' });
      },
    });
  },
});

The register callback runs once at startup. It declares which extension points the plugin offers (the deployment source registry) and which core services it consumes (logger, HTTP router, config, permissions, HTTP auth). The new backend system handles dependency wiring.

The router is a normal Express router, but it uses the new auth and permission APIs:

// plugins/deployments-backend/src/router.ts
import express from 'express';
import Router from 'express-promise-router';
import { LoggerService, HttpAuthService, PermissionsService, RootConfigService } from '@backstage/backend-plugin-api';
import { NotAllowedError, InputError } from '@backstage/errors';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { deploymentsPermissions, DeploymentListResponse } from '@internal/plugin-deployments-common';
import { DeploymentSource } from '@internal/plugin-deployments-node';

interface RouterOptions {
  logger: LoggerService;
  config: RootConfigService;
  permissions: PermissionsService;
  httpAuth: HttpAuthService;
  sources: DeploymentSource[];
}

export async function createRouter(opts: RouterOptions): Promise<express.Router> {
  const { logger, permissions, httpAuth, sources } = opts;
  const router = Router();
  router.use(express.json());

  router.get('/health', (_req, res) => res.json({ status: 'ok' }));

  router.get('/deployments', async (req, res) => {
    const credentials = await httpAuth.credentials(req, { allow: ['user'] });

    const decision = (
      await permissions.authorize([{ permission: deploymentsPermissions.read }], { credentials })
    )[0];
    if (decision.result !== AuthorizeResult.ALLOW) {
      throw new NotAllowedError('Unauthorized to read deployments');
    }

    const { service, environment, limit } = req.query;
    if (typeof service !== 'string') throw new InputError('service query param required');

    const all = await Promise.all(
      sources.map(s =>
        s.list({
          service,
          environment: typeof environment === 'string' ? environment : undefined,
          limit: Math.min(Number(limit) || 20, 100),
        }),
      ),
    );

    const merged = all.flat().sort(
      (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
    );

    const response: DeploymentListResponse = {
      items: merged.slice(0, 20),
      totalCount: merged.length,
    };
    res.json(response);
  });

  return router;
}

The router fans out to every registered source, merges results, sorts by start time, and returns the top twenty. Permission checks happen up front. If the user can’t read deployments, the route 403s before any source is queried.

5. Writing a Backend Module That Extends Your Plugin

Now demonstrate the extension point by writing an ArgoCD-backed source as a separate backend module:

// plugins/deployments-source-argocd/src/module.ts
import { createBackendModule, coreServices } from '@backstage/backend-plugin-api';
import { deploymentSourceExtensionPoint } from '@internal/plugin-deployments-node';
import { ArgocdDeploymentSource } from './source';

export const deploymentsSourceArgocdModule = createBackendModule({
  pluginId: 'deployments',
  moduleId: 'source-argocd',
  register(reg) {
    reg.registerInit({
      deps: {
        source: deploymentSourceExtensionPoint,
        config: coreServices.rootConfig,
        logger: coreServices.logger,
      },
      async init({ source, config, logger }) {
        source.addSource(new ArgocdDeploymentSource({
          baseUrl: config.getString('argocd.baseUrl'),
          token: config.getString('argocd.token'),
          logger,
        }));
      },
    });
  },
});

The implementation calls ArgoCD’s /api/v1/applications endpoint, maps the response to the shared Deployment type, and returns it. The deployments plugin doesn’t know ArgoCD exists. Any team can ship their own source module without modifying upstream.

6. The Frontend Card

The frontend lives in plugins/deployments/src. Three files do the work: the API client, the React component, and the plugin registration.

// plugins/deployments/src/api.ts
import { createApiRef, FetchApi, DiscoveryApi } from '@backstage/core-plugin-api';
import { DeploymentListResponse } from '@internal/plugin-deployments-common';

export const deploymentsApiRef = createApiRef<DeploymentsApi>({
  id: 'plugin.deployments.service',
});

export interface DeploymentsApi {
  list(service: string): Promise<DeploymentListResponse>;
}

export class DeploymentsClient implements DeploymentsApi {
  constructor(private readonly opts: { discovery: DiscoveryApi; fetch: FetchApi }) {}

  async list(service: string): Promise<DeploymentListResponse> {
    const base = await this.opts.discovery.getBaseUrl('deployments');
    const res = await this.opts.fetch.fetch(
      `${base}/deployments?service=${encodeURIComponent(service)}`,
    );
    if (!res.ok) throw new Error(`Failed to fetch deployments: ${res.status}`);
    return res.json();
  }
}

The discoveryApi resolves deployments to the backend URL at runtime. The fetchApi automatically attaches the Backstage auth token. Don’t use raw fetch.

The component reads the entity from context and queries the API:

// plugins/deployments/src/components/DeploymentsCard.tsx
import React from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
import { useApi } from '@backstage/core-plugin-api';
import { InfoCard, Progress, ResponseErrorPanel } from '@backstage/core-components';
import useAsync from 'react-use/lib/useAsync';
import { deploymentsApiRef } from '../api';

export const DeploymentsCard = () => {
  const { entity } = useEntity();
  const api = useApi(deploymentsApiRef);

  const { value, loading, error } = useAsync(
    () => api.list(entity.metadata.name),
    [entity.metadata.name],
  );

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

  return (
    <InfoCard title="Recent deployments">
      <ul>
        {value?.items.map(d => (
          <li key={d.id}>
            <strong>{d.version}</strong> to <em>{d.environment}</em> by {d.triggeredBy}
            {' — '}{d.status}
          </li>
        ))}
      </ul>
    </InfoCard>
  );
};

Plugin registration in plugins/deployments/src/plugin.ts ties the API to the React tree:

import { createPlugin, createApiFactory, discoveryApiRef, fetchApiRef, createComponentExtension } from '@backstage/core-plugin-api';
import { deploymentsApiRef, DeploymentsClient } from './api';

export const deploymentsPlugin = createPlugin({
  id: 'deployments',
  apis: [
    createApiFactory({
      api: deploymentsApiRef,
      deps: { discovery: discoveryApiRef, fetch: fetchApiRef },
      factory: ({ discovery, fetch }) => new DeploymentsClient({ discovery, fetch }),
    }),
  ],
});

export const EntityDeploymentsCard = deploymentsPlugin.provide(
  createComponentExtension({
    name: 'EntityDeploymentsCard',
    component: { lazy: () => import('./components/DeploymentsCard').then(m => m.DeploymentsCard) },
  }),
);

Mount it in packages/app/src/components/catalog/EntityPage.tsx inside the service entity layout:

const serviceEntityPage = (
  <EntityLayout>
    <EntityLayout.Route path="/" title="Overview">
      <Grid container>
        <Grid item md={6}><EntityAboutCard /></Grid>
        <Grid item md={6}><EntityDeploymentsCard /></Grid>
      </Grid>
    </EntityLayout.Route>
  </EntityLayout>
);

7. Testing the Whole Stack

The backend gets unit tests with @backstage/backend-test-utils:

// plugins/deployments-backend/src/router.test.ts
import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import { deploymentsPlugin } from './plugin';
import request from 'supertest';

describe('deployments router', () => {
  it('rejects unauthenticated requests', async () => {
    const { server } = await startTestBackend({
      features: [
        deploymentsPlugin,
        mockServices.httpAuth.factory({ defaultCredentials: undefined }),
      ],
    });
    const res = await request(server).get('/api/deployments/deployments?service=foo');
    expect(res.status).toBe(401);
  });
});

The frontend gets RTL tests using the Backstage test helpers, which mock the API ref:

// plugins/deployments/src/components/DeploymentsCard.test.tsx
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { deploymentsApiRef } from '../api';
import { DeploymentsCard } from './DeploymentsCard';

const mockApi = { list: jest.fn().mockResolvedValue({ items: [], totalCount: 0 }) };

it('renders empty state', async () => {
  const { findByText } = await renderInTestApp(
    <TestApiProvider apis={[[deploymentsApiRef, mockApi]]}>
      <EntityProvider entity={{ metadata: { name: 'svc' } } as any}>
        <DeploymentsCard />
      </EntityProvider>
    </TestApiProvider>,
  );
  expect(await findByText('Recent deployments')).toBeInTheDocument();
});
+----------------+      +-------------------+      +-----------+
| Frontend card  +----->+ Backend router    +----->+ Source 1  |
| (React)        |      | (Express, perms)  |  +-->+ Source 2  |
+----------------+      +---------+---------+  |   +-----------+
                                  |            |
                                  +------------+
                                       merge & sort

Common Pitfalls

  • Putting types in the backend package. The first time you want to share a type with the frontend, you’ll end up with a circular dependency. Put every cross-package type in *-common from the start, even if it feels like overkill.
  • Skipping the extension point. Tempting to inline the ArgoCD source directly into the deployments backend. Three months later you need a GitHub Actions source and you’re refactoring everything to add a registry. Pay the upfront cost.
  • Using fetch directly in the frontend. The fetchApi handles token attachment, CSRF, and the development proxy. Bypassing it means tests pass and production breaks in interesting ways the first time you turn on auth.
  • No config schema. Add config.d.ts to every backend plugin with a JSON schema for its config keys. Without it, typos in app-config.yaml silently default to undefined and you debug for an hour wondering why the API isn’t being called.

Troubleshooting

  • Error: Unknown plugin: 'deployments' in the frontend. The discoveryApi couldn’t resolve the plugin ID. Either the backend plugin wasn’t added to backend.add(import(...)), or the pluginId in the backend’s createBackendPlugin doesn’t match the string passed to discovery.getBaseUrl.
  • 403 from the backend, but the user is signed in. The permission framework is denying. Check the policy logs for the exact permission name. The most common cause is the frontend hitting the route without the Authorization header because someone called fetch directly.
  • The card renders but data is always empty. The service query parameter doesn’t match what the source expects. Backstage entity names are usually lowercased; ArgoCD app names sometimes aren’t. Add a debug log in the source’s list method and you’ll see the mismatch in two seconds.

Wrapping Up

A plugin with this structure is something other engineers can extend, you can test, and you can ship. The upfront cost of four packages instead of one is real but it’s the cost of building something durable. The next time someone asks for a new source, your answer is “write a module”, not “let me refactor”.

The plugin development docs cover the deeper API surface, especially the auth and permission service interfaces. The next post in this series gets into TechDocs production setup.