Example walkthrough
Line-by-line read of the canonical example pair that the live
butler-portal-live portal ships as its dynamic-plugin showcase. The
pair lives at
examples/dynamic-plugin-hello
(frontend) and
examples/dynamic-plugin-hello-backend
(backend).
What you see at
portal.butlerlabs.dev/hello-dynamic-plugin
is built from these two directories, by the same npm run export-dynamic + oras push flow the quickstart
walks through, pulled and verified by the same chart-installer combo
chart-config covers. Reading this page after a
live look at the showcase makes every file map to something you
already saw working.
The pair is CI-tested on every PR that touches it (or the portal- side surface it depends on) by the example-build-test workflow. The starters cannot rot silently.
Frontend layout
examples/dynamic-plugin-hello/
package.json # plugin metadata + deps + scripts
tsconfig.json # extends @backstage/cli config
.gitignore # node_modules, dist, dist-dynamic
src/
index.ts # re-exports the runtime contract
plugin.ts # createPlugin + createRoutableExtension
routes.ts # rootRouteRef
dynamic/PluginRoot.tsx # dynamicPluginsExports declaration
components/HelloPage.tsx # the rendered page (the showcase card)
package.json
{
"name": "butler-hello-dynamic-plugin",
"version": "0.1.0",
"main": "src/index.ts",
"backstage": {
"role": "frontend-plugin",
"pluginId": "hello-dynamic-plugin"
},
"scripts": {
"build": "backstage-cli package build",
"export-dynamic": "rhdh-cli plugin export --clean"
},
"dependencies": {
"@backstage/core-components": "^0.16.2",
"@backstage/core-plugin-api": "^1.10.2",
"@backstage/theme": "^0.6.3",
"@material-ui/core": "^4.12.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@backstage/cli": "0.36.0",
"@red-hat-developer-hub/cli": "^1.11.1",
"@types/react": "^18.2.0",
"jest": "^29.0.0"
},
"scalprum": {
"name": "butler-hello-dynamic-plugin",
"exposedModules": {
"PluginRoot": "./src/dynamic/PluginRoot.tsx",
"HelloPage": "./src/components/HelloPage.tsx"
}
}
}
Three blocks beyond the obvious:
backstage.role: frontend-plugin-- the Backstage CLI build recipe.scalprum.exposedModules-- rhdh-cli reads this for the federation expose graph. The build collapses everything under the.expose but uses these as the source files.dependenciesincludes the universal dynamic-plugin set: React 18, react-router-dom 6, Material UI v4 core, plus the three@backstage/*packages every frontend plugin needs. Jest is in devDeps for completeness even though this example has no tests.
src/index.ts
export { helloDynamicPlugin, HelloDynamicPluginPage } from './plugin';
export { dynamicPluginsExports } from './dynamic/PluginRoot';
export { HelloPage } from './components/HelloPage';
The portal's loader reads the package root (the . expose) and
expects:
dynamicPluginsExports-- the route declaration.HelloPage-- the named component the route'simportNamereferences.
The other two exports (helloDynamicPlugin, HelloDynamicPluginPage)
exist so the same package can be consumed as a static Backstage
plugin if you also published to NPM. Re-exporting them here is
zero-cost.
src/dynamic/PluginRoot.tsx
export const dynamicPluginsExports = {
dynamicRoutes: [
{
path: '/hello-dynamic-plugin',
importName: 'HelloPage',
menuItem: { text: 'Hello (Dynamic)' },
},
],
};
One route: /hello-dynamic-plugin mounts the HelloPage component
the portal loads off the package root. The menuItem adds a
sidebar entry under the Butler Labs group.
src/components/HelloPage.tsx
The showcase card. Notable bits:
- MUI v4 Card layout with chips, source URI, and the marker text inside a styled Box.
data-testid="hello-dynamic-plugin-marker"on the marker Box for the Playwright marker assertion.useState/useCallbackfor the ping round-trip; the response renders as pretty-printed JSON.- Plain
fetch('/api/hello-dynamic-backend/ping', { credentials: 'include' })-- no Backstage discoveryApi. The browser hits the same origin the portal serves from, so the relative URL resolves to the backend dynamic plugin's mount point. PLUGIN_METADATAhardcoded at the top of the file. Hardcoding the source URI as part of the plugin's own contract makes the page self-describing -- the visitor sees where the artifact came from without reading the chart values.
The whole file is ~150 lines and stays in plain React + MUI v4 idioms. Nothing federation-specific or dynamic-plugin-specific appears in the JSX; the component does not know it was loaded dynamically.
Backend layout
examples/dynamic-plugin-hello-backend/
package.json # role: backend-plugin
tsconfig.json
.gitignore
src/
index.ts # default-exports the plugin
plugin.ts # createBackendPlugin + httpRouter registration
package.json
{
"name": "butler-hello-dynamic-backend",
"version": "0.1.0",
"main": "dist/index.cjs.js",
"backstage": {
"role": "backend-plugin",
"pluginId": "hello-dynamic-backend"
},
"scripts": {
"build": "backstage-cli package build",
"export-dynamic": "rhdh-cli plugin export --clean"
},
"dependencies": {
"@backstage/backend-plugin-api": "^1.6.0",
"@backstage/errors": "^1.2.7",
"express": "^4.21.2"
},
"devDependencies": {
"@backstage/cli": "0.36.0",
"@red-hat-developer-hub/cli": "^1.11.1",
"@types/express": "^4.17.21",
"jest": "^29.0.0"
}
}
Differences from the frontend:
backstage.role: backend-plugin.main: dist/index.cjs.js-- the CJS entry the portal's backend loader reads.- Dependencies are server-side:
@backstage/backend-plugin-api,express. No React. - No
scalprumblock (backend plugins do not federate).
src/plugin.ts
export const helloDynamicBackendPlugin = createBackendPlugin({
pluginId: 'hello-dynamic-backend',
register(env) {
env.registerInit({
deps: {
httpRouter: coreServices.httpRouter,
logger: coreServices.logger,
},
async init({ httpRouter, logger }) {
const router = Router();
router.get('/ping', (_req, res) => {
res.json({
ok: true,
marker:
'Hello from the Butler Portal dynamic-plugins runtime (backend)',
pluginName: PLUGIN_NAME,
pluginVersion: PLUGIN_VERSION,
backendStartedAt: BACKEND_STARTED_AT,
receivedAt: new Date().toISOString(),
podHostname: os.hostname(),
nodeVersion: process.version,
});
});
httpRouter.use(router);
httpRouter.addAuthPolicy({
path: '/ping',
allow: 'unauthenticated',
});
},
});
},
});
Notable bits:
pluginId: 'hello-dynamic-backend'-- the portal mounts this plugin's router at/api/hello-dynamic-backend, so the route the frontend hits is/api/hello-dynamic-backend/ping.- The response includes the marker string the release-boot-test asserts substring of.
addAuthPolicy({ path: '/ping', allow: 'unauthenticated' })-- intentional for the showcase. Production plugins should not copy this for routes that touch data.- Pod hostname + node version + plugin version in the response -- the visible payload reinforces that this is a real backend in a real pod, not a hardcoded fake.
src/index.ts
import { helloDynamicBackendPlugin } from './plugin';
export { helloDynamicBackendPlugin };
export default helloDynamicBackendPlugin;
The portal's backend loader reads the default export and calls
backend.add(plugin) with it.
Building both
The frontend builds with npm, the backend with yarn (rhdh-cli's
backend export reads yarn.lock for the dep closure).
# Frontend, out-of-tree (monorepo isolation):
cp -R examples/dynamic-plugin-hello/. /tmp/build-fe/
cd /tmp/build-fe
npm install --legacy-peer-deps
npm run export-dynamic
# Backend, out-of-tree:
cp -R examples/dynamic-plugin-hello-backend/. /tmp/build-be/
cd /tmp/build-be
yarn install
npx tsc
yarn export-dynamic
Each produces a dist-dynamic/ directory the
publishing flow packages, integrity-computes, and
pushes.
How the live portal got these
The Butler Labs production butler-portal-live HelmRelease references
both artifacts in dynamicPlugins.plugins[]:
dynamicPlugins:
enabled: true
allowedSources:
- oci://ghcr.io/butlerdotdev/butler-portal-test-fixture
plugins:
- package: oci://ghcr.io/butlerdotdev/butler-portal-test-fixture:hello-dynamic-0.5.1
integrity: sha512-bdd/gEtWJpDnCbf86jAZoYYuKnj87CAychME0aBNjISRiPN4Vg31AoyERe7b7QvDiTiwm94U1TTYhD5iu/KTZA==
- package: oci://ghcr.io/butlerdotdev/butler-portal-test-fixture:hello-dynamic-backend-0.5.1
integrity: sha512-+jHUltsqu4RVZzK1S934ozBvQhlTQjqdVUUfaaKWGF28WeL/A1k61Zn74pDHqlczV9WgPBolk9SoIl5hBUEnjA==
The source package is public, the portal's installer pulls anonymously, the sha512 verifies on every install. On every chart release, the release-boot-test workflow re-asserts both halves against the released amd64 portal image before any production cutover.
Copy this for your own plugin
The whole pair is ~300 lines of source plus the build/publish plumbing. Start by copying both directories outside any Yarn workspace (the monorepo isolation step zero in the quickstart). Rename the package, change the pluginId, replace the page content and the route, and you have your own dynamic plugin in the same shape as the live showcase.