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-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

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.