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.

Backend baseline before adding a REST route The runtime, a new-backend-system backend, the HTTP router service, and Express with typed errors enable a REST plugin. Node 20 / Yarn 4 createBackend() httpRouter service express + errors REST plugin ready /api/insights
The legacy createServiceBuilder path is deprecated; this plugin assumes the new backend system throughout.
  • Node.js 20.x and Yarn 4.1+ in a Backstage monorepo created with @backstage/create-app@latest.
  • @backstage/backend-plugin-api 1.0+ and a backend already migrated to the new backend system (packages/backend/src/index.ts using createBackend()).
  • @backstage/backend-defaults 0.5+ providing the HTTP router service.
  • express 4.18+ and @backstage/errors 1.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.

Request path through the backend plugin A request is namespaced by the HTTP router, checked by the auth policy, handled with input validation, and calls the upstream API. httpRouter /api/insights Auth policy user / service Handler validate input Upstream API fetch + return
Only the health probe is exempted from the auth policy; every data route requires a valid principal.

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.

Four route behaviors to verify Public health returns 200, no token returns 401, a valid token returns data, and a malformed id returns 400. /health → 200 public no token → 401 rejected valid token → data 200 JSON bad id → 400 InputError
Testing the 401 and 400 paths matters as much as the happy path — they prove the route is not accidentally open or lax.
# 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.

Backend plugin failures by wiring mistake Auth-policy scope, missing config, and wrong router mount are the three wiring mistakes behind the common failures. health returns 401 config missing at boot route at /insights not /api addAuthPolicy for /health set env + config block mount on httpRouter
Mounting on coreServices.httpRouter is what gives you the /api/<pluginId> prefix for free.
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.