Skip to main content

Plugin authoring

How to grow a copy of the example into a real plugin. The quickstart walks you to a working hello render; this page covers the surface beyond that.

The contract this page describes lives in Reference. Read that page when a specific field or type is unclear; this page covers usage.

Frontend plugin structure

The example layout, annotated:

my-plugin/
package.json # backstage.role + deps + scalprum block + scripts
src/
index.ts # re-exports dynamicPluginsExports + named components
plugin.ts # createPlugin + createRoutableExtension
routes.ts # rootRouteRef + sub-route refs
dynamic/PluginRoot.tsx # declares dynamicPluginsExports.dynamicRoutes
components/
MyPage.tsx # the routed page (the one Sentry of your UI)
MyOtherPage.tsx # subsequent pages, each at its own route

package.json

Three blocks matter beyond the obvious:

{
"backstage": {
"role": "frontend-plugin",
"pluginId": "my-plugin"
},
"scalprum": {
"name": "my-plugin",
"exposedModules": {
"PluginRoot": "./src/dynamic/PluginRoot.tsx"
}
},
"scripts": {
"build": "backstage-cli package build",
"export-dynamic": "rhdh-cli plugin export --clean"
}
}

backstage.role tells the Backstage CLI how to build the package. scalprum.exposedModules is the rhdh-cli hint for which file to use as the federation expose source (the build collapses all named exports into the . expose, but rhdh-cli reads this block to know where to start). pluginId is the URL prefix discoveryApi resolves for routes the plugin registers.

The dependency list is the standard Backstage frontend plugin set plus React 18, react-router-dom 6.x, Material UI v4 (both @material-ui/core and let rhdh-cli pull @material-ui/styles as a transitive). The example has the working dependency set; copy it.

src/index.ts

The portal's loader reads the package root (the . expose). Your src/index.ts is what ends up there. Re-export both the dynamicPluginsExports declaration and every named component the declaration references:

export { helloDynamicPlugin, HelloDynamicPluginPage } from './plugin';
export { dynamicPluginsExports } from './dynamic/PluginRoot';
export { MyPage } from './components/MyPage';
export { MyOtherPage } from './components/MyOtherPage';

If you forget to re-export MyPage, the portal's loader fails to resolve the route's importName and the route mounts as the 404 fallback. The example-build-test workflow asserts the bundled output contains the named exports dynamicPluginsExports and HelloPage for the canonical example; the same shape applies to your plugin.

src/plugin.ts

Standard Backstage plugin shape -- the only difference is that you do not mount the page through the host app's static route table. The dynamic-plugin runtime mounts it from dynamicPluginsExports instead.

import {
createPlugin,
createRoutableExtension,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';

export const myPlugin = createPlugin({
id: 'my-plugin',
routes: { root: rootRouteRef },
});

export const MyPageExtension = myPlugin.provide(
createRoutableExtension({
name: 'MyPageExtension',
component: () => import('./components/MyPage').then(m => m.MyPage),
mountPoint: rootRouteRef,
}),
);

The example also re-exports the extension as HelloDynamicPluginPage; the convention helps reuse the component as a standalone Backstage page if you also publish to NPM as a static plugin.

src/dynamic/PluginRoot.tsx

The runtime contract:

export const dynamicPluginsExports = {
dynamicRoutes: [
{
path: '/my-plugin',
importName: 'MyPage',
menuItem: { text: 'My Plugin' },
},
{
path: '/my-plugin/details',
importName: 'MyOtherPage',
},
],
menuItems: [
// optional: standalone sidebar items not tied to a route
{
key: 'my-plugin:docs',
text: 'My Plugin Docs',
to: 'https://docs.example.com',
priority: 100,
},
],
};

Every importName must match an export from your package root (src/index.ts). menuItem on a route adds a sidebar entry under the Butler Labs group pointing at that path. menuItems (the top-level array) adds standalone sidebar entries that link elsewhere -- internal routes, external docs, anything.

priority controls render order in the sidebar (lower first).

src/components/MyPage.tsx

A standard Backstage page. The dynamic-plugin runtime gives you the host's React, react-router-dom, Material UI v4, and Backstage core APIs as shared singletons. Code that runs in a static Backstage plugin runs in a dynamic plugin without change.

import { Page, Header, Content } from '@backstage/core-components';
import { useApi, configApiRef } from '@backstage/core-plugin-api';

export const MyPage = () => {
const config = useApi(configApiRef);
const baseUrl = config.getString('app.baseUrl');
return (
<Page themeId="tool">
<Header title="My Plugin" subtitle="Loaded dynamically" />
<Content>
<p>Hello from {baseUrl}.</p>
</Content>
</Page>
);
};

The data-testid attribute the canonical example puts on the marker text exists for the boot-test assertion. Your plugin does not need one unless you wire your own browser tests.

Backend plugin

When your plugin needs server-side logic, see Talking to a backend for the three patterns. For pattern 2 (bundled backend dynamic plugin), the shape is:

my-plugin-backend/
package.json # backstage.role: backend-plugin
tsconfig.json
src/
index.ts # default-exports the plugin
plugin.ts # createBackendPlugin

package.json differs from the frontend:

{
"backstage": {
"role": "backend-plugin",
"pluginId": "my-plugin"
},
"main": "dist/index.cjs.js",
"scripts": {
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"export-dynamic": "rhdh-cli plugin export --clean"
},
"dependencies": {
"@backstage/backend-plugin-api": "^1.6.0",
"express": "^4.21.2"
},
"devDependencies": {
"@backstage/cli": "0.36.0",
"@red-hat-developer-hub/cli": "^1.11.1",
"@types/express": "^4.17.21"
}
}

Backend dynamic plugins build with yarn, not npm. rhdh-cli's backend code path reads yarn.lock to compute the production dependency closure. yarn install && npx tsc && yarn export-dynamic.

The hello-dynamic-backend example is the CI-tested starter for backend plugins.

Reaching the catalog and other plugins

A dynamic plugin can call Backstage's existing APIs the same way a static plugin would:

  • catalogApiRef -- read catalog entities, refresh them, post new ones (subject to permissions).
  • permissionApiRef -- check a permission for the current user.
  • identityApiRef -- read the signed-in user.
  • discoveryApiRef -- resolve the URL for any plugin's backend.
  • fetchApiRef -- the auth-aware fetch.
import { useApi, catalogApiRef } from '@backstage/plugin-catalog-react';

const catalog = useApi(catalogApiRef);
const entities = await catalog.getEntities({
filter: { kind: 'Component' },
});

The host provides these as shared singletons via the API factories configured in the portal. Your plugin lists the catalogApiRef package (@backstage/plugin-catalog-react) as a dependency; rhdh-cli bundles it. At runtime the host's instance wins (singleton resolution).

Permissions

Dynamic plugins participate in Backstage's permissions framework. A backend dynamic plugin declares its own permissions:

import { createPermission } from '@backstage/plugin-permission-common';

export const myPluginRead = createPermission({
name: 'my-plugin.read',
attributes: { action: 'read' },
});

The frontend gates UI on the same permission:

import { usePermission } from '@backstage/plugin-permission-react';

const { allowed } = usePermission({ permission: myPluginRead });
if (!allowed) return <div>Not authorized.</div>;

The portal's PermissionPolicy decides who gets what. As a plugin author you declare the permission; as the operator you configure the policy.

Testing locally

Local development of a dynamic plugin does not require running the portal. The plugin builds and exports the same way regardless of where it lands.

yarn build              # or npm run build
yarn export-dynamic # or npm run export-dynamic

For interactive development of a Backstage plugin, the standard yarn start workflow in a Backstage app works (the plugin loads as a static dep). The dynamic-plugin runtime is the deployment-time layer; local dev does not need it.

To exercise the dynamic-plugin runtime locally, copy your built dist-dynamic/ into a portal's dynamicPlugins.rootDirectory and restart the portal pod. The run-051-marker-test.sh script in the portal repo does exactly this against a local portal image.

What rhdh-cli plugin export produces

Calling npm run export-dynamic (frontend) or yarn export-dynamic (backend) produces a dist-dynamic/ directory. Frontend output:

dist-dynamic/
package.json # mirrored for the portal's loader
dist/
mf-manifest.json # Module Federation v2 manifest
remoteEntry.js # the entry the portal loads
static/*.chunk.js # federated chunks
.config-schema.json # config schema for portal validation

Backend output:

dist-dynamic/
package.json # peerDependencies set to host-provided
node_modules/ # private dep closure
yarn.lock
dist/
index.cjs.js # CJS entry the loader requires
plugin.cjs.js # the plugin module
index.d.ts # types
configSchema.json # config schema for portal validation

The portal's loader reads dist/mf-manifest.json (frontend) or dist/index.cjs.js (backend). The Publishing page covers packaging this directory as an OCI artifact and pushing it.