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 (NFS). 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:
renderInTestApp with the apis optioncreateExtensionTester(...).reactElement()yarn backstage-cli package test --coverage --watchAll=false
Before publishing:
extensions arrayloaderyarn new → select frontend-plugin; it generates plugins/<pluginId>/ already wired for the New Frontend System.createFrontendPlugin from @backstage/frontend-plugin-api. Export it as the default from src/index.ts.PageBlueprint, SubPageBlueprint, ApiBlueprint, EntityContentBlueprint, etc.). Page content is always lazy-loaded using dynamic imports.createRouteRef (usually in src/routes.ts) and use them in blueprints.createApiRef + ApiBlueprint), consumed via useApi.useApi, useRouteRef) and built-in API refs (discoveryApiRef, fetchApiRef, identityApiRef, alertApiRef, errorApiRef, storageApiRef) should be imported from @backstage/frontend-plugin-api in NFS code — they are still re-exported from @backstage/core-plugin-api but that is the legacy import path.dev/index.tsx entrypoint uses createDevApp from @backstage/frontend-dev-utils and is started via yarn start inside the plugin directory.@backstage/frontend-defaults auto-discover a plugin's extensions when the package is installed and listed as a feature.PageBlueprint mount automatically and their title/icon drive the sidebar.@backstage/app-defaults and manual FlatRoutes. Most new work should target NFS.routeRefs in src/routes.tscreateSubRouteRef for nested pathssrc/index.ts)PageBlueprint automatically wraps the loader with React Suspense and an error boundary — no need to add them yourself at the extension level. Just keep your loader as a dynamic import:
loader: () => import('./components/ExamplePage').then(m => <m.ExamplePage />),
If you need an error boundary inside a component (e.g. around a risky subtree), use ErrorBoundary from @backstage/core-components.
ApiBlueprint and consume with useApi from @backstage/frontend-plugin-apiuseAsync from react-use)Hide/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>;
}
filter param on EntityContentBlueprint to limit to certain entity kindsapis to renderInTestApp/createExtensionTester over wrapping with TestApiProvidermockApis.* helpers (mockApis.identity(...), etc.) for common mocksuseRouteRef via the mountedRoutes option where navigation matters# 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/ already wired for the New Frontend System:
src/plugin.tsx — createFrontendPlugin + PageBlueprintsrc/routes.ts — createRouteRef()src/index.ts — export { examplePlugin as default } from './plugin'src/components/ — example page + list componentsdev/index.tsx — createDevApp({ features: [plugin] }) for standalone devThe scaffold ships a working example todo app (src/components/TodoPage/ and src/components/TodoList/). Run the cleanup script to strip the example and leave a minimal named page component:
node scripts/cleanup-scaffolding.js plugins/example
Legacy apps only: If
yarn newdetects a legacy-app repo, it will offer thelegacy-frontend-plugintemplate instead. In that case you'll need to convert manually to NFS — see the migration guide atdocs/frontend-system/building-plugins/05-migrating.md.
src/routes.ts)import { createRouteRef, createSubRouteRef } from '@backstage/frontend-plugin-api';
export const rootRouteRef = createRouteRef();
// Optional: nested paths
export const detailsRouteRef = createSubRouteRef({
parent: rootRouteRef,
path: '/details/:id',
});
src/plugin.tsx)import {
createFrontendPlugin,
PageBlueprint,
} from '@backstage/frontend-plugin-api';
import ExampleIcon from '@material-ui/icons/Extension';
import { rootRouteRef } from './routes';
// Sidebar entry is auto-inferred from the page's title + icon.
const examplePage = PageBlueprint.make({
params: {
routeRef: rootRouteRef,
path: '/example',
title: 'Example',
icon: ExampleIcon,
loader: () => import('./components/ExamplePage').then(m => <m.ExamplePage />),
},
});
export const examplePlugin = createFrontendPlugin({
pluginId: 'example',
extensions: [examplePage],
routes: { root: rootRouteRef },
});
Note:
NavItemBlueprintis deprecated and has been removed entirely on current Backstage main. Nav items are now auto-inferred from anyPageBlueprintthat has atitleandicon, or from the plugin-leveltitle/icononcreateFrontendPlugin. New code should not useNavItemBlueprint.
src/components/ExamplePage.tsx)Standard React component, no NFS-specific wrapping required:
export function ExamplePage() {
return (
<div>
<h1>Example</h1>
<p>Hello from the New Frontend System!</p>
</div>
);
}
src/index.ts)Only export the plugin as default:
export { examplePlugin as default } from './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.api' });
export class DefaultExampleApi implements ExampleApi {
getExample() {
return { example: 'Hello World!' };
}
}
Register it with ApiBlueprint and consume via useApi:
// src/plugin.tsx
import { ApiBlueprint } from '@backstage/frontend-plugin-api';
import { exampleApiRef, DefaultExampleApi } from './api';
const exampleApi = ApiBlueprint.make({
name: 'example',
params: defineParams =>
defineParams({
api: exampleApiRef,
deps: {},
factory: () => new DefaultExampleApi(),
}),
});
export const examplePlugin = createFrontendPlugin({
pluginId: 'example',
extensions: [exampleApi, examplePage],
routes: { root: rootRouteRef },
});
Note the callback parameter is defineParams — all upstream docs and real-world plugin code use this name.
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 />),
// Optional: limit to specific entity kinds
// filter: 'kind:component,api',
// Optional: tab grouping
// group: 'overview',
},
});
Create a tabbed parent page by combining PageBlueprint (without a loader) with several SubPageBlueprint children that attach to the parent:
import {
PageBlueprint,
SubPageBlueprint,
} from '@backstage/frontend-plugin-api';
const parentPage = PageBlueprint.make({
params: {
routeRef: rootRouteRef,
path: '/example',
title: 'Example',
// No loader — inputs.pages render as tabs
},
});
const overviewTab = SubPageBlueprint.make({
name: 'overview',
params: {
path: '',
title: 'Overview',
loader: () => import('./components/Overview').then(m => <m.Overview />),
},
});
@backstage/frontend-defaults):yarn start) and visit the path declared by your PageBlueprint.yarn start; dev/index.tsx uses createDevApp to render your plugin with just the features you pass in.Run tests and lints with Backstage's CLI:
# 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 a predictable structure (API layer, hooks, components, routes.ts, plugin.tsx, index.ts).
| Problem | Solution |
|---|---|
| Extensions don't render | Ensure they're passed in the plugin's extensions array; loaders must use dynamic imports. |
| Navigation/links break | Keep routeRefs in src/routes.ts and use useRouteRef from @backstage/frontend-plugin-api to generate links. |
| Consumers can't install your plugin | Export the plugin as the default export from src/index.ts. |
| "Extension must be attached to a parent" | Use the correct blueprint (PageBlueprint attaches to app/routes; SubPageBlueprint to a parent page; EntityContentBlueprint to the entity page). |
| Sidebar item missing | Provide title + icon on PageBlueprint.params, or set plugin-level title/icon on createFrontendPlugin. |
| Import hygiene warnings | In NFS code, import useApi, useRouteRef, and the built-in API refs from @backstage/frontend-plugin-api, not @backstage/core-plugin-api. |
Load these resources as needed during development:
PageBlueprint / SubPageBlueprint for pages and tab hierarchiesApiBlueprint for Utility APIsEntityContentBlueprint / EntityCardBlueprint / EntityHeaderBlueprint etc. (catalog)PluginHeaderActionBlueprint, PluginWrapperBlueprint (alpha)@backstage/plugin-app-react (ThemeBlueprint, SignInPageBlueprint, IconBundleBlueprint, etc.)makeWithOverrides and custom blueprint authoringcreateApiRefApiBlueprintuseApirenderInTestApp and the apis optioncreateExtensionTester(...).reactElement()mockApis.* helpers and TestApiProvider for standalone rendering