Backstage Plugins Cheat Sheet
Overview
Backstage is Spotify’s open-source platform for building developer portals, and its plugin system is the primary extension mechanism for adding custom functionality. Plugins can provide new UI pages, backend APIs, scaffolder actions, catalog processors, and integrations with external tools. The Backstage plugin architecture uses a composable model where frontend plugins are React components and backend plugins are Express-based services, all wired together through the Backstage app framework.
Plugin development follows a well-defined structure with dedicated packages for frontend, backend, and shared components. The new backend system (introduced in Backstage 1.x) uses a modular architecture with dependency injection, making plugins more portable and testable. The ecosystem includes hundreds of community plugins for CI/CD, monitoring, cloud providers, and security tools, and building custom plugins lets organizations integrate proprietary tools and workflows into their unified developer experience.
Installation
Prerequisites and Setup
# Create a new Backstage app (if starting fresh)
npx @backstage/create-app@latest
# Navigate to your Backstage app
cd my-backstage-app
# Install required dependencies
yarn install
# Start development server
yarn dev
Creating a New Plugin
# Create a frontend plugin
yarn new --select plugin
# Follow prompts: plugin ID, owner, etc.
# Create a backend plugin
yarn new --select backend-plugin
# Create a scaffolder module (custom actions)
yarn new --select scaffolder-module
# Plugin is created in plugins/<plugin-name>/
Core Commands — Frontend Plugin Development
Plugin Structure
# Frontend plugin directory structure
plugins/my-plugin/
├── src/
│ ├── index.ts # Public exports
│ ├── plugin.ts # Plugin definition
│ ├── routes.ts # Route references
│ ├── components/
│ │ ├── MyPluginPage/
│ │ │ ├── MyPluginPage.tsx
│ │ │ └── index.ts
│ │ └── EntityOverviewCard/
│ │ ├── EntityOverviewCard.tsx
│ │ └── index.ts
│ ├── api/
│ │ ├── MyPluginClient.ts
│ │ └── types.ts
│ └── hooks/
│ └── useMyPluginData.ts
├── package.json
├── dev/ # Standalone dev environment
│ └── index.tsx
└── README.md
Plugin Definition
// src/plugin.ts
import {
createPlugin,
createRoutableExtension,
createComponentExtension,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
export const myPlugin = createPlugin({
id: 'my-plugin',
routes: {
root: rootRouteRef,
},
});
// Full page component
export const MyPluginPage = myPlugin.provide(
createRoutableExtension({
name: 'MyPluginPage',
component: () =>
import('./components/MyPluginPage').then(m => m.MyPluginPage),
mountPoint: rootRouteRef,
}),
);
// Entity card component (for catalog entity pages)
export const EntityMyPluginCard = myPlugin.provide(
createComponentExtension({
name: 'EntityMyPluginCard',
component: {
lazy: () =>
import('./components/EntityOverviewCard').then(
m => m.EntityOverviewCard,
),
},
}),
);
Routes
// src/routes.ts
import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'my-plugin',
});
export const detailRouteRef = createSubRouteRef({
id: 'my-plugin-detail',
parent: rootRouteRef,
path: '/:id',
});
Page Component
// src/components/MyPluginPage/MyPluginPage.tsx
import React from 'react';
import { useApi } from '@backstage/core-plugin-api';
import {
Header,
Page,
Content,
ContentHeader,
SupportButton,
Table,
TableColumn,
} from '@backstage/core-components';
import { myPluginApiRef } from '../../api/types';
import useAsync from 'react-use/lib/useAsync';
export const MyPluginPage = () => {
const api = useApi(myPluginApiRef);
const { value: data, loading, error } = useAsync(async () => {
return await api.getItems();
}, []);
const columns: TableColumn[] = [
{ title: 'Name', field: 'name' },
{ title: 'Status', field: 'status' },
{ title: 'Updated', field: 'updatedAt' },
];
return (
<Page themeId="tool">
<Header title="My Plugin" subtitle="Platform tool integration" />
<Content>
<ContentHeader title="Overview">
<SupportButton>Plugin documentation and support.</SupportButton>
</ContentHeader>
<Table
title="Items"
columns={columns}
data={data || []}
isLoading={loading}
options={{ search: true, paging: true }}
/>
</Content>
</Page>
);
};
API Client
// src/api/types.ts
import { createApiRef } from '@backstage/core-plugin-api';
export interface MyPluginApi {
getItems(): Promise<Item[]>;
getItemById(id: string): Promise<Item>;
}
export interface Item {
id: string;
name: string;
status: string;
updatedAt: string;
}
export const myPluginApiRef = createApiRef<MyPluginApi>({
id: 'plugin.my-plugin.service',
});
// src/api/MyPluginClient.ts
import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
import { MyPluginApi, Item } from './types';
export class MyPluginClient implements MyPluginApi {
private readonly discoveryApi: DiscoveryApi;
private readonly fetchApi: FetchApi;
constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }) {
this.discoveryApi = options.discoveryApi;
this.fetchApi = options.fetchApi;
}
async getItems(): Promise<Item[]> {
const baseUrl = await this.discoveryApi.getBaseUrl('my-plugin');
const response = await this.fetchApi.fetch(`${baseUrl}/items`);
if (!response.ok) throw new Error(`Failed to fetch items: ${response.statusText}`);
return await response.json();
}
async getItemById(id: string): Promise<Item> {
const baseUrl = await this.discoveryApi.getBaseUrl('my-plugin');
const response = await this.fetchApi.fetch(`${baseUrl}/items/${id}`);
if (!response.ok) throw new Error(`Failed to fetch item: ${response.statusText}`);
return await response.json();
}
}
Core Commands — Backend Plugin Development
Backend Plugin (New System)
// plugins/my-plugin-backend/src/plugin.ts
import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './router';
export const myPluginPlugin = createBackendPlugin({
pluginId: 'my-plugin',
register(env) {
env.registerInit({
deps: {
logger: coreServices.logger,
httpRouter: coreServices.httpRouter,
config: coreServices.rootConfig,
database: coreServices.database,
auth: coreServices.auth,
httpAuth: coreServices.httpAuth,
},
async init({ logger, httpRouter, config, database, auth, httpAuth }) {
httpRouter.use(
await createRouter({ logger, config, database, auth, httpAuth }),
);
httpRouter.addAuthPolicy({
path: '/health',
allow: 'unauthenticated',
});
},
});
},
});
Router
// plugins/my-plugin-backend/src/router.ts
import { Router } from 'express';
import { LoggerService, DatabaseService } from '@backstage/backend-plugin-api';
export interface RouterOptions {
logger: LoggerService;
config: any;
database: DatabaseService;
auth: any;
httpAuth: any;
}
export async function createRouter(options: RouterOptions): Promise<Router> {
const { logger, database } = options;
const router = Router();
const dbClient = await database.getClient();
// Run migrations
await dbClient.migrate.latest({
directory: __dirname + '/migrations',
});
router.get('/health', (_, res) => {
res.json({ status: 'ok' });
});
router.get('/items', async (_, res) => {
const items = await dbClient('items').select('*');
res.json(items);
});
router.get('/items/:id', async (req, res) => {
const item = await dbClient('items').where('id', req.params.id).first();
if (!item) {
res.status(404).json({ error: 'Not found' });
return;
}
res.json(item);
});
router.post('/items', async (req, res) => {
const { name, status } = req.body;
const [id] = await dbClient('items').insert({ name, status }).returning('id');
logger.info(`Created item: ${id}`);
res.status(201).json({ id });
});
return router;
}
Configuration
Register Plugin in the App
// packages/app/src/App.tsx — register frontend plugin
import { MyPluginPage } from '@internal/plugin-my-plugin';
const routes = (
<FlatRoutes>
<Route path="/my-plugin" element={<MyPluginPage />} />
{/* other routes */}
</FlatRoutes>
);
// Register API factory
// packages/app/src/apis.ts
import { myPluginApiRef, MyPluginClient } from '@internal/plugin-my-plugin';
import {
createApiFactory,
discoveryApiRef,
fetchApiRef,
} from '@backstage/core-plugin-api';
export const apis = [
createApiFactory({
api: myPluginApiRef,
deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
factory: ({ discoveryApi, fetchApi }) =>
new MyPluginClient({ discoveryApi, fetchApi }),
}),
];
// packages/backend/src/index.ts — register backend plugin
import { myPluginPlugin } from '@internal/plugin-my-plugin-backend';
const backend = createBackend();
backend.add(myPluginPlugin);
// ...
backend.start();
Entity Page Integration
// packages/app/src/components/catalog/EntityPage.tsx
import { EntityMyPluginCard } from '@internal/plugin-my-plugin';
const overviewContent = (
<Grid container spacing={3}>
<Grid item md={6}>
<EntityMyPluginCard />
</Grid>
</Grid>
);
Advanced Usage
Custom Scaffolder Action
// plugins/scaffolder-backend-module-my-plugin/src/actions/createItem.ts
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
export function createMyItemAction() {
return createTemplateAction<{
name: string;
description: string;
}>({
id: 'my-plugin:create-item',
schema: {
input: {
type: 'object',
required: ['name'],
properties: {
name: { type: 'string', title: 'Item Name' },
description: { type: 'string', title: 'Description' },
},
},
output: {
type: 'object',
properties: {
itemId: { type: 'string', title: 'Created Item ID' },
},
},
},
async handler(ctx) {
ctx.logger.info(`Creating item: ${ctx.input.name}`);
// Your logic here
const itemId = 'generated-id';
ctx.output('itemId', itemId);
},
});
}
Catalog Processor
// Custom processor to add metadata from external source
import { CatalogProcessor, CatalogProcessorEmit } from '@backstage/plugin-catalog-node';
import { Entity } from '@backstage/catalog-model';
import { LocationSpec } from '@backstage/plugin-catalog-common';
export class MyPluginProcessor implements CatalogProcessor {
getProcessorName(): string {
return 'MyPluginProcessor';
}
async preProcessEntity(
entity: Entity,
_location: LocationSpec,
_emit: CatalogProcessorEmit,
): Promise<Entity> {
if (entity.kind === 'Component' && entity.metadata.annotations?.['my-plugin/project-id']) {
const projectId = entity.metadata.annotations['my-plugin/project-id'];
// Fetch data from external API and enrich entity
entity.metadata.annotations['my-plugin/status'] = 'enriched';
}
return entity;
}
}
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| Plugin not showing in sidebar | Route not registered in App.tsx | Add <Route> in FlatRoutes and sidebar <SidebarItem> |
| API client 404 | Backend plugin not registered | Add plugin to packages/backend/src/index.ts |
| TypeScript errors on import | Package not built | Run yarn tsc or yarn build in plugin directory |
| Hot reload not working | Dev server caching | Restart with yarn dev and clear browser cache |
| Database migrations failing | Knex version mismatch | Ensure migration files match knex version in plugin |
| Auth errors on API calls | Missing auth policy | Add httpRouter.addAuthPolicy for public endpoints |
| Entity card not rendering | Missing isMyPluginAvailable check | Add entity condition check before rendering |
| Plugin test failures | Missing test setup | Add @backstage/test-utils and proper mocks |
# Build and type-check all plugins
yarn tsc
yarn build:all
# Run plugin tests
yarn workspace @internal/plugin-my-plugin test
# Run backend plugin tests
yarn workspace @internal/plugin-my-plugin-backend test
# Lint plugins
yarn lint
# Start in development mode
yarn dev
# Check for dependency issues
yarn backstage-cli versions:check