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
The plugin only mounts correctly on a backend already migrated to the new backend system, so confirm four things first: the runtime, createBackend() in place, the HTTP router service available, and Express with typed errors installed.
- 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
Once mounted, a request travels a fixed path: the HTTP router namespaces it under /api/insights, the auth policy admits or rejects it, the handler validates input and calls the upstream API, and the error middleware normalizes any failure. The diagram traces that path.
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.
Validate all four behaviors the route promises: the health probe is public, an unauthenticated data request is rejected, a valid token returns data, and a malformed id is refused with a 400.
# 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
The failures here trace to three wiring mistakes: an auth policy that is too broad or too narrow, config that never loaded, and mounting on the wrong router. The diagram groups the symptoms so you check the right wire first.
| 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.