Build Backstage backend plugins with createBackendPlugin and core services: DI, httpRouter, secure-by-default auth, Knex DB, routes, testing. Use for APIs and background jobs.
This skill provides specialized knowledge and workflows for building Backstage backend plugins and modules using the New Backend System. It guides the development of server-side functionality including REST/HTTP APIs, background jobs, data processing, and integrations, plus the module/extension-point pattern used to extend other plugins.
Use this skill when creating server-side functionality for Backstage: REST/HTTP APIs, background jobs, data processing, integrations, or modules that extend an existing plugin's extension points.
Before building a backend plugin, clearly understand:
Load reference files as needed based on the plugin requirements:
For Core Services:
For Testing:
Follow the Golden Path workflow below for implementation, referring to reference files as needed.
Important Decisions:
After implementing the plugin:
startTestBackendsupertest against the test serverTestDatabasesmockCredentials and mockServices.httpAuth.mockyarn backstage-cli package test --coverage --watchAll=false
Before publishing:
cache or database services)yarn new → select backend-plugin; the package lives in plugins/<id>-backend/.createBackendPlugin, declare dependencies via deps, and initialize in register(env).registerInit.coreServices.httpRouter. Backstage prefixes plugin routers with /api/<pluginId>.httpRouter.addAuthPolicy({ path, allow }) to allow unauthenticated endpoints.allow on addAuthPolicy only accepts 'unauthenticated' or 'user-cookie'.logger, database, httpRouter, httpAuth, auth, userInfo, urlReader, scheduler, cache, permissions, etc.) are available via coreServices.zod and throw InputError from @backstage/errors. Let the root HTTP router handle error responses — you usually don't need per-router error middleware.createBackendModule) extends another plugin via its extension points. A plugin exposes extension points with env.registerExtensionPoint(...).Validate inputs at the edge using zod. Throw InputError (from @backstage/errors) on failure so the root error handler can serialize it as a 400 response — no need to write the response yourself:
import { InputError } from '@backstage/errors';
import { z } from 'zod/v3';
const querySchema = z.object({ q: z.string().min(1) });
router.get('/search', async (req, res) => {
const parsed = querySchema.safeParse(req.query);
if (!parsed.success) {
throw new InputError(parsed.error.toString());
}
const results = await search(parsed.data.q);
res.json({ items: results });
});
Use zod/v3 (or zod/v4 — both are available in the repo today) to pin behaviour across the codebase. Recent scaffolded plugins use zod/v3.
The root HTTP router installs a centralized error middleware, so individual plugin routers don't need to add their own. If you need to customize error formatting at the root level, use MiddlewareFactory from @backstage/backend-defaults/rootHttpRouter:
import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter';
// Typically only set up once in tests or at the root.
const middleware = MiddlewareFactory.create({ config, logger });
router.use(middleware.error({ logAllErrors: false }));
The legacy helper
errorHandlerfrom@backstage/backend-commonhas been removed. UseMiddlewareFactory.create(...).error(), ormockErrorHandlerfrom@backstage/backend-test-utilsin tests.
Wrap async handlers with express-promise-router so thrown errors reach the error middleware automatically — that's what the scaffolded template does.
httpRouter.addAuthPolicy({ path, allow: 'unauthenticated' })httpAuthuserInfo when requiredauth to issue on-behalf-of tokens for calls to other pluginsrouter.get('/me', async (req, res) => {
const credentials = await httpAuth.credentials(req, { allow: ['user'] });
const { userEntityRef, ownershipEntityRefs } = await userInfo.getUserInfo(credentials);
res.json({ userEntityRef, ownershipEntityRefs });
});
const knex = await database.getClient(); gets a per-plugin Knex client (separate schema per plugin).js) and include them in your package's files listknex.migrate.latest({ directory, migrationSource }) during initcache or database services so multiple replicas share statelogger.child({ plugin: 'example' }) for traceabilitycoreServices.auditor to emit structured audit events# From the repository root (interactive)
yarn new
# Select: backend-plugin
# Plugin id (without -backend suffix), e.g. example
# Non-interactive (for AI agents/automation)
yarn new --select backend-plugin --option pluginId=example --option owner=""
This creates plugins/example-backend/ using the New Backend System. The scaffolded structure is:
src/plugin.ts — createBackendPlugin wiringsrc/router.ts — Express router (uses express-promise-router)src/services/ — service refs + implementationssrc/index.ts — re-exports the plugin as defaultdev/index.ts — local dev backend via createBackendsrc/plugin.test.ts — integration tests using startTestBackend + supertestsrc/plugin.ts — plugin + DI + routerThe scaffolded shape wires in a service ref (e.g. todoListServiceRef) alongside core services:
import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './router';
import { todoListServiceRef } from './services/TodoListService';
export const examplePlugin = createBackendPlugin({
pluginId: 'example',
register(env) {
env.registerInit({
deps: {
httpAuth: coreServices.httpAuth,
httpRouter: coreServices.httpRouter,
todoList: todoListServiceRef,
},
async init({ httpAuth, httpRouter, todoList }) {
httpRouter.use(await createRouter({ httpAuth, todoList }));
// Backends are secure-by-default. Open specific endpoints explicitly:
httpRouter.addAuthPolicy({ path: '/health', allow: 'unauthenticated' });
},
});
},
});
Add more core services to deps as you need them (e.g. logger, database, userInfo, auth, scheduler).
src/index.ts — default exportexport { examplePlugin as default } from './plugin';
Keep this line only in
index.ts. Don't duplicate it insideplugin.ts.
src/router.ts — minimal Express routerUse express-promise-router so thrown errors propagate to the root error handler. Validate request bodies with zod. Check user credentials inline with httpAuth.credentials:
import type { HttpAuthService } from '@backstage/backend-plugin-api';
import { InputError } from '@backstage/errors';
import express from 'express';
import Router from 'express-promise-router';
import { z } from 'zod/v3';
import type { todoListServiceRef } from './services/TodoListService';
export async function createRouter({
httpAuth,
todoList,
}: {
httpAuth: HttpAuthService;
todoList: typeof todoListServiceRef.T;
}): Promise<express.Router> {
const router = Router();
router.use(express.json());
const todoSchema = z.object({
title: z.string(),
entityRef: z.string().optional(),
});
router.post('/todos', async (req, res) => {
const parsed = todoSchema.safeParse(req.body);
if (!parsed.success) {
throw new InputError(parsed.error.toString());
}
const result = await todoList.createTodo(parsed.data, {
credentials: await httpAuth.credentials(req, { allow: ['user'] }),
});
res.status(201).json(result);
});
router.get('/todos', async (_req, res) => {
res.json(await todoList.listTodos());
});
router.get('/todos/:id', async (req, res) => {
res.json(await todoList.getTodo({ id: req.params.id }));
});
return router;
}
In packages/backend/src/index.ts:
const backend = createBackend();
backend.add(import('@internal/plugin-example-backend'));
backend.start();
Now GET http://localhost:7007/api/example/todos returns an empty list. addAuthPolicy allowed /health through unauthenticated; every other path requires a valid user or service credential.
createServiceRef<T>({ id: 'example.todoList' }), register with createServiceFactory({ service, deps, factory }). Pattern in the scaffold: services/TodoListService.ts exports the ref plus todoListServiceFactory which is registered via the plugin's register(env).coreServices.database to get a Knex client; run your own migrations with knex.migrate.latest(...) in init.coreServices.httpAuth + coreServices.userInfo to obtain the calling user and their entity refs.coreServices.auth to mint a token on behalf of the current caller, then coreServices.discovery for the target plugin's base URL — see Core Services reference.Modules are packages that extend an existing plugin at a pre-defined integration point. Use them to add processors, providers, actions, etc., without forking the plugin.
Exposing an extension point (in a plugin):
import {
createBackendPlugin,
createExtensionPoint,
} from '@backstage/backend-plugin-api';
interface ExampleProcessor {
process(value: string): Promise<string>;
}
export interface ExampleProcessingExtensionPoint {
addProcessor(p: ExampleProcessor): void;
}
export const exampleProcessingExtensionPoint =
createExtensionPoint<ExampleProcessingExtensionPoint>({
id: 'example.processing',
});
export const examplePlugin = createBackendPlugin({
pluginId: 'example',
register(env) {
const processors: ExampleProcessor[] = [];
env.registerExtensionPoint(exampleProcessingExtensionPoint, {
addProcessor(p) {
processors.push(p);
},
});
env.registerInit({
deps: { /* ... */ },
async init() {
// Use `processors` during plugin setup.
},
});
},
});
Extending a plugin (in a module):
import { createBackendModule } from '@backstage/backend-plugin-api';
import { exampleProcessingExtensionPoint } from '@example/plugin-example-node';
export const exampleModuleCustomProcessor = createBackendModule({
pluginId: 'example',
moduleId: 'custom-processor',
register(env) {
env.registerInit({
deps: { example: exampleProcessingExtensionPoint },
async init({ example }) {
example.addProcessor(new MyCustomProcessor());
},
});
},
});
// src/index.ts
export { exampleModuleCustomProcessor as default } from './module';
Scaffold a module with yarn new → backend-module. Generated packages live in plugins/<pluginId>-backend-module-<moduleId>/.
packages/backend/src/index.ts via backend.add(import('@internal/plugin-<id>-backend')).yarn start). Then check:
GET http://localhost:7007/api/example/health → { "status": "ok" } (if your plugin exposes a /health endpoint and opens it with addAuthPolicy).addAuthPolicy.# Always pass --watchAll=false when running non-interactively (CI, AI agents);
# without it jest starts in watch mode and never exits.
yarn backstage-cli package test --watchAll=false
yarn backstage-cli package lint
yarn backstage-cli repo lint
Keep routers small (/router.ts), inject dependencies (DB, auth, clients) from plugin.ts, and avoid in-memory state so the backend stays horizontally scalable.
| Problem | Solution |
| ------- | -------- |
| 404s under /api | Backstage prefixes plugin routers with /api/<pluginId>. Don't add the prefix yourself in your route definitions. |
| Auth unexpectedly required | Backends are secure by default; open endpoints explicitly via httpRouter.addAuthPolicy({ path, allow: 'unauthenticated' }). |
| Tight coupling between plugins | Never import other backend plugins' internals. Communicate over HTTP using discovery + auth (auth.getPluginRequestToken({ onBehalfOf, targetPluginId })), or use extension points/modules. |
| errorHandler not found | @backstage/backend-common has been removed. Use MiddlewareFactory.create({ config, logger }).error() from @backstage/backend-defaults/rootHttpRouter, or rely on the root error handler that's installed automatically. |
| Tests hang or timeout | Make sure startTestBackend is awaited, and use await request(server).get(...) (from supertest), not server.request(...). |
| MSW handlers stop matching | MSW v2 uses http.get(url, () => HttpResponse.json(...)), not rest.get. Update imports and handler signatures. |
| Migrations don't run in prod | Migrations must be .js files listed in your package.json files array, so they're copied into the published artifact. |
Load these resources as needed during development:
startTestBackendmockServices catalog and mockCredentials helperssupertest (using await request(server).get(...))TestDatabases (from @backstage/backend-test-utils)errorHandler → MiddlewareFactory).npx skills add rothenbergt/backstage-backend-plugin下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer