Adding a Backstage Backend Plugin with a REST Endpoint
Most non-trivial portal features need server-side logic: a route that talks to an internal API, aggregates data, or enforces a policy the frontend can’t be trusted with. This how-to builds a backend plugin on Backstage’s new backend system that exposes an Express REST endpoint, wires it into the backend, and registers it under /api/<pluginId>. It is the server-side companion to Building Custom Backstage Plugins.
Getting the backend right matters because this is where authorization, secret handling, and data integrity actually live — frontend guards are convenience, not security.
Prerequisites
- Node.js 20.x and Yarn 4.1+ in a Backstage monorepo created with
@backstage/create-app@latest. @backstage/backend-plugin-api1.0+ and a backend already migrated to the new backend system (packages/backend/src/index.tsusingcreateBackend()).@backstage/backend-defaults0.5+ providing the HTTP router service.express4.18+ and@backstage/errors1.2+ for typed error handling.- Backstage CLI 0.26+ for scaffolding (
yarn backstage-cli new --select backend-plugin).
Exact Configuration
1. Scaffold the backend plugin
# Requires @backstage/cli >= 0.26.0
yarn backstage-cli new --select backend-plugin
# Plugin ID: insights
# Creates plugins/insights-backend/
2. Build the Express router
The router is plain Express plus Backstage’s MiddlewareFactory for consistent error handling and request logging. Inject the logger via the service rather than reaching for a global.
// plugins/insights-backend/src/service/router.ts
// Requires @backstage/backend-defaults >= 0.5.0, express >= 4.18.0
import { HttpAuthService, LoggerService } from '@backstage/backend-plugin-api';
import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter';
import { InputError } from '@backstage/errors';
import express from 'express';
import Router from 'express-promise-router';
export interface RouterOptions {
logger: LoggerService;
httpAuth: HttpAuthService;
config: { externalApiUrl: string };
}
export async function createRouter(options: RouterOptions): Promise<express.Router> {
const { logger, httpAuth, config } = options;
const router = Router();
router.use(express.json());
router.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
router.get('/services/:id', async (req, res) => {
const credentials = await httpAuth.credentials(req, { allow: ['user'] });
const { id } = req.params;
if (!/^[a-z0-9-]+$/.test(id)) {
throw new InputError(`Invalid service id: ${id}`);
}
logger.info(`Fetching insights for ${id}`, {
actor: credentials.principal.userEntityRef,
});
const upstream = await fetch(`${config.externalApiUrl}/v1/services/${id}`);
res.status(upstream.status).json(await upstream.json());
});
const middleware = MiddlewareFactory.create({ logger, config: undefined as any });
router.use(middleware.error());
return router;
}
3. Define the plugin module
createBackendPlugin declares the plugin’s dependencies on core services and mounts the router on the HTTP router service. The HTTP router automatically namespaces routes under /api/insights.
// plugins/insights-backend/src/plugin.ts
// Requires @backstage/backend-plugin-api >= 1.0.0
import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './service/router';
export const insightsPlugin = createBackendPlugin({
pluginId: 'insights',
register(env) {
env.registerInit({
deps: {
logger: coreServices.logger,
httpAuth: coreServices.httpAuth,
httpRouter: coreServices.httpRouter,
config: coreServices.rootConfig,
},
async init({ logger, httpAuth, httpRouter, config }) {
httpRouter.use(
await createRouter({
logger,
httpAuth,
config: {
externalApiUrl: config.getString('insights.externalApiUrl'),
},
}),
);
// Allow unauthenticated access to the health probe only
httpRouter.addAuthPolicy({ path: '/health', allow: 'unauthenticated' });
},
});
},
});
4. Register the plugin in the backend
// packages/backend/src/index.ts
// Requires @backstage/backend-defaults >= 0.5.0
import { createBackend } from '@backstage/backend-defaults';
const backend = createBackend();
backend.add(import('@backstage/plugin-app-backend'));
backend.add(import('@backstage/plugin-catalog-backend'));
backend.add(import('@internal/backstage-plugin-insights-backend'));
backend.start();
5. Supply configuration via env
# app-config.yaml
# Compatible with Backstage 1.20+
insights:
externalApiUrl: ${INSIGHTS_API_URL}
The endpoint is now reachable at /api/insights/services/:id. If this backend feeds entity data, align its response shape with your Catalog Integration Patterns so the frontend consumes one consistent schema.
Validation
# Requires Node.js >= 20, Backstage backend running via `yarn start-backend`
# 1. Health endpoint (unauthenticated, per the auth policy)
curl -s http://localhost:7007/api/insights/health
# expected: {"status":"ok"}
# 2. Authenticated route without a token is rejected
curl -s -o /dev/null -w "%{http_code}" http://localhost:7007/api/insights/services/payments-api
# expected: 401
# 3. With a valid Backstage token
curl -s http://localhost:7007/api/insights/services/payments-api \
-H "Authorization: Bearer ${BACKSTAGE_TOKEN}"
# expected: JSON body from the upstream service
# 4. Input validation rejects malformed ids
curl -s -o /dev/null -w "%{http_code}" http://localhost:7007/api/insights/services/Bad_Id \
-H "Authorization: Bearer ${BACKSTAGE_TOKEN}"
# expected: 400
Edge Cases & Troubleshooting
| Symptom | Root Cause | Resolution |
|---|---|---|
| All routes return 401, including health | No auth policy added for /health |
Call httpRouter.addAuthPolicy({ path: '/health', allow: 'unauthenticated' }) |
Config key 'insights.externalApiUrl' is missing at boot |
Env var unset or config block absent | Set ${INSIGHTS_API_URL} and confirm the insights: block is in app-config.yaml |
| Errors return raw stack traces | middleware.error() not mounted last |
Mount the error middleware after all routes |
Route reachable at /insights not /api/insights |
Mounting on root router instead of httpRouter |
Use coreServices.httpRouter, which prefixes /api/<pluginId> |
credentials() throws on service-to-service calls |
Policy allows only user principals |
Add 'service' to the allow array when other plugins call this route |
Frequently Asked Questions
Do I still need the old createServiceBuilder backend pattern?
No. The new backend system (createBackend + createBackendPlugin) is the supported path as of Backstage 1.20+. The legacy createServiceBuilder approach is deprecated; new plugins should use coreServices.httpRouter as shown.
How do I enforce fine-grained permissions on these routes?
Inject coreServices.permissions, define a permission, and call permissions.authorize() inside the handler before returning data. This ties the endpoint into the same policy engine your portal uses elsewhere rather than ad-hoc role checks.