Build Backstage frontend plugins with the new Frontend System: createFrontendPlugin, blueprints, routes, Utility APIs, testing. Use for pages, nav, entity content, or cards.
This skill provides specialized knowledge and workflows for building Backstage frontend plugins using the New Frontend System. It guides the development of UI features including pages, navigation items, entity cards/content, and shared Utility APIs.
Use this skill when creating UI features for Backstage: pages, navigation items, entity cards/content, or shared Utility APIs.
Before building a frontend plugin, clearly understand:
Load reference files as needed based on the plugin requirements:
For Extension Development:
For Utility API Development:
For Testing:
Follow the Golden Path workflow below for implementation, referring to reference files as needed.
After implementing the plugin:
renderInTestAppcreateExtensionTesteryarn backstage-cli package test --coverage
Before publishing:
yarn new → select plugin; it generates plugins/<pluginId>/.createFrontendPlugin from @backstage/frontend-plugin-api. Export it as the default from src/index.ts.PageBlueprint, NavItemBlueprint, EntityContentBlueprint). These are lazy‑loaded using dynamic imports.createRouteRef (usually in src/routes.ts) and use them in blueprints.createApiRef + ApiBlueprint), consumed via useApi.@backstage/frontend-defaults discover plugin extensions when the plugin is included at app creation.PageBlueprint are mounted automatically.@backstage/app-defaults and manual FlatRoutes, add a <Route> that renders your page component directly.routeRefs in src/routes.tscreateSubRouteRef for nested pathssrc/index.ts)Use dynamic imports in loaders and wrap rendered elements in Suspense with a lightweight fallback. Add an error boundary for resilience:
// Suspense and error boundary around a lazy extension element
const Example = React.lazy(() => import('./components/ExamplePage'));
function ExampleWrapper() {
return (
<ErrorBoundary>
<React.Suspense fallback={<div>Loading…</div>}>
<Example />
</React.Suspense>
</ErrorBoundary>
);
}
ApiBlueprint and consume with useApiHide/show entity content based on permissions or ownership to avoid broken UX for unauthorized users:
import { usePermission } from '@backstage/plugin-permission-react';
import { somePermission } from '@backstage/plugin-permission-common';
export function ExampleEntityContent() {
const { loading, allowed } = usePermission({ permission: somePermission });
if (loading) return null;
if (!allowed) return null; // or render a friendly message/banner
return <div>Secret content</div>;
}
@testing-library/react to test extension outputuseRouteRef where navigation matters⚠️ CRITICAL: yarn new generates LEGACY frontend plugins using the old createPlugin API. You MUST convert the generated code to the New Frontend System for everything to work properly.
# From the repository root (interactive)
yarn new
# Select: frontend-plugin
# Enter plugin id (kebab case, e.g. example)
# Non-interactive (for AI agents/automation)
yarn new --select frontend-plugin --option pluginId=example --option owner=""
This creates plugins/example/ with legacy code. Follow steps 2-5 below to convert it to the New Frontend System.
src/routes.ts)Replace the generated legacy code with New Frontend System:
import { createRouteRef } from '@backstage/frontend-plugin-api';
// Keep routes here to avoid circular imports.
export const rootRouteRef = createRouteRef();
Change from legacy: Import from @backstage/frontend-plugin-api (not @backstage/core-plugin-api). Remove the id parameter from createRouteRef().
src/plugin.ts)COMPLETELY REPLACE the generated legacy code with New Frontend System:
import {
createFrontendPlugin,
PageBlueprint,
NavItemBlueprint,
} from '@backstage/frontend-plugin-api';
import { rootRouteRef } from './routes';
import ExampleIcon from '@material-ui/icons/Extension';
// Page (lazy-loaded via dynamic import)
const examplePage = PageBlueprint.make({
params: {
routeRef: rootRouteRef,
path: '/example',
loader: () => import('./components/ExampleComponent').then(m => <m.ExampleComponent />),
},
});
// Sidebar navigation item
const exampleNavItem = NavItemBlueprint.make({
params: {
routeRef: rootRouteRef,
title: 'Example',
icon: ExampleIcon,
},
});
// Export plugin instance; do NOT export extensions from the package
export const examplePlugin = createFrontendPlugin({
pluginId: 'example',
extensions: [examplePage, exampleNavItem],
routes: { root: rootRouteRef },
});
Changes from legacy:
createFrontendPlugin (not createPlugin)PageBlueprint and NavItemBlueprint (not createRoutableExtension)src/components/ExampleComponent.tsx)The scaffolded component is already compatible with the New Frontend System. You can modify it as needed:
export function ExampleComponent() {
return (
<div>
<h1>Example</h1>
<p>Hello from the New Frontend System!</p>
</div>
);
}
Note: The component name should match what's referenced in the loader in plugin.ts.
src/index.ts)Update the exports to only export the plugin instance:
export { examplePlugin as default } from './plugin';
Changes from legacy: Remove all component exports (like HelloWorldPage). Only export the plugin.
// src/api.ts
import { createApiRef } from '@backstage/frontend-plugin-api';
export interface ExampleApi {
getExample(): { example: string };
}
export const exampleApiRef = createApiRef<ExampleApi>({ id: 'plugin.example' });
export class DefaultExampleApi implements ExampleApi {
getExample() {
return { example: 'Hello World!' };
}
}
Register it with the ApiBlueprint and consume via useApi:
// src/plugin.ts
import { ApiBlueprint } from '@backstage/frontend-plugin-api';
import { exampleApiRef, DefaultExampleApi } from './api';
const exampleApi = ApiBlueprint.make({
name: 'example',
params: define =>
define({
api: exampleApiRef,
deps: {},
factory: () => new DefaultExampleApi(),
}),
});
export const examplePlugin = createFrontendPlugin({
pluginId: 'example',
extensions: [exampleApi, examplePage, exampleNavItem],
routes: { root: rootRouteRef },
});
import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha';
const exampleEntityContent = EntityContentBlueprint.make({
params: {
path: 'example',
title: 'Example',
loader: () =>
import('./components/ExampleEntityContent').then(m => <m.ExampleEntityContent />),
},
});
@backstage/frontend-defaults and your plugin is included at app creation.PageBlueprint.<Route path="/example" element={<ExamplePage />} /> under FlatRoutes./example.Run tests and lints with Backstage's CLI:
yarn backstage-cli package test
yarn backstage-cli package lint
yarn backstage-cli repo lint
Keep a predictable structure (API layer, hooks, components, routes.ts, plugin.ts, index.ts).
| Problem | Solution | Reference |
|---|---|---|
| Extensions don't render | Ensure they're passed in the plugin's extensions array; components must be lazy-loaded via dynamic imports |
Backstage |
| Navigation/links break | Keep routeRefs in src/routes.ts and use useRouteRef to generate links |
Backstage |
| Consumers can't install your plugin | Export the plugin as the default from src/index.ts |
Backstage |
Load these resources as needed during development:
makeWithOverridescreateApiRefuseApirenderInTestAppcreateExtensionTesterTestApiProvider