plugin-new-frontend-system-support
Adding New Frontend System Support to an Existing Plugin
This skill helps add new frontend system (NFS) support to an existing Backstage plugin while keeping the old system fully functional. The result is a plugin that works in both old and new apps via a dual entry point pattern.
This is the preferred approach for published plugins or plugins that are used by external parties, since it avoids forcing consumers to migrate their app before they are ready.
It is highly recommended to be on Backstage version 1.49.x or above before starting this, although not mandatory, you may face issues with some of the instructions below. This can be verified by looking in the backstage.json file in the root of the repository.
Key Concepts
- Dual entry point: The plugin keeps its existing
src/plugin.ts(old system) and adds a newsrc/alpha.tsx(new system) - Old system:
createPluginfrom@backstage/core-plugin-api, pages viacreateRoutableExtension, routes defined in the app - New system:
createFrontendPluginfrom@backstage/frontend-plugin-api, pages viaPageBlueprint, routes owned by the plugin - Dual header pattern: Old system uses
Page/Header/PageWithHeaderfrom@backstage/core-components; new system relies on the framework'sPageLayoutwhich rendersPluginHeaderfrom@backstage/ui— so NFS page components should NOT include their own page shell
Step 1: Create the Alpha Entry Point
Create src/alpha.tsx (or src/alpha/index.ts for larger plugins) with a createFrontendPlugin default export:
// src/alpha.tsx
import {
createFrontendPlugin,
PageBlueprint,
} from '@backstage/frontend-plugin-api';
import { RiToolsLine } from '@remixicon/react';
import { rootRouteRef } from './routes';
const myPage = PageBlueprint.make({
params: {
path: '/my-plugin',
routeRef: rootRouteRef,
loader: () => import('./components/MyPage').then(m => <m.NfsMyPage />),
},
});
export default createFrontendPlugin({
pluginId: 'my-plugin',
title: 'My Plugin',
icon: <RiToolsLine />,
extensions: [myPage],
routes: {
root: rootRouteRef,
},
externalRoutes: {
// same external routes as the old plugin
},
});
For the plugin icon, prefer using Remix Icons from @remixicon/react. If the plugin already has an existing MUI icon, it can be kept with fontSize="inherit" (e.g. <CategoryIcon fontSize="inherit" />), but for new icons Remix is the recommended choice.
The title and icon params on PageBlueprint are only needed if they should differ from the plugin's own title and icon (set in createFrontendPlugin). If omitted, the plugin-level values are used.
For larger plugins, organize into src/alpha/plugin.tsx, src/alpha/pages.tsx, src/alpha/extensions.tsx, etc., and re-export from src/alpha/index.ts.
Step 2: Update package.json Exports
Add the ./alpha subpath export and its typesVersions entry:
{
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.tsx",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": ["src/alpha.tsx"],
"package.json": ["package.json"]
}
}
}
Apps import the new plugin as:
import myPlugin from '@backstage/plugin-my-plugin/alpha';
Step 3: Implement the Dual Header Pattern
The critical difference between old and new system page components is the page shell. In the old system, each page renders its own Page + Header (or PageWithHeader) wrapper. In the new system, the framework's PageLayout provides the header via PluginHeader automatically — so the NFS page component must not include its own page shell.
Pattern A: Separate Components (Recommended for Simple Pages)
Create two exported components — one for each system:
// src/components/MyPage/MyPage.tsx
import {
Content,
PageWithHeader,
ContentHeader,
SupportButton,
} from '@backstage/core-components';
import { Header } from '@backstage/ui';
// Used by the OLD system — includes the full page shell
export function MyPage() {
return (
<PageWithHeader title="My Plugin" themeId="tool">
<Content>
<ContentHeader title="">
<SupportButton>Some help text</SupportButton>
</ContentHeader>
<MyPageContent />
</Content>
</PageWithHeader>
);
}
// Used by the NEW system — no page shell, just content
// The framework's PageLayout/PluginHeader provides the title and header
export function NfsMyPage() {
return (
<>
<Header
title="My Plugin Subtitle"
customActions={<SupportButton>Some help text</SupportButton>}
/>
<Content>
<MyPageContent />
</Content>
</>
);
}
Key differences in the NFS variant:
- No
Page/PageWithHeader— the framework provides the outer page shell Headerfrom@backstage/uiis optional — use it only if you need a subtitle or custom actions below the framework header- No
ContentHeader— actions move toHeader'scustomActionsprop - The shared
<MyPageContent />component contains the actual page body
Forwarding Customization Props
If the old system exports a page component with props for customization (e.g. <CatalogIndexPage actions={...} filters={...} />), the NFS variant should accept the same props. Export the NFS variant with the same component name from the ./alpha entry point, so that app adopters can customize it the same way:
// src/components/MyPage/MyPage.tsx
export interface MyPageProps {
actions?: ReactNode;
filters?: ReactNode;
}
// Old system — exported from src/index.ts
export function MyPage(props: MyPageProps) {
return (
<PageWithHeader title="My Plugin" themeId="tool">
<Content>
<MyPageContent {...props} />
</Content>
</PageWithHeader>
);
}
// NFS variant — exported from src/alpha.tsx
export function NfsMyPage(props: MyPageProps) {
return (
<Content>
<MyPageContent {...props} />
</Content>
);
}
The NFS variant is then wired into the PageBlueprint loader, and the component itself is re-exported from ./alpha so adopters can use .withOverrides() to pass custom props:
// src/alpha.tsx
export { NfsMyPage as MyPage } from './components/MyPage';
This way, the old MyPage is available from the main entry point, and the same name MyPage is available from ./alpha — both accepting the same props for customization.
Pattern B: Header Variant Prop (Recommended for Complex Pages)
For pages with significant shared logic, use a headerVariant prop pattern:
// src/components/MyPage/MyPage.tsx
import { Content, PageWithHeader } from '@backstage/core-components';
function MyPageContent(
props: MyPageProps & { headerVariant: 'legacy' | 'bui' },
) {
const { headerVariant, ...rest } = props;
// ... shared page logic, data fetching, etc.
const pageContent = <Content>{/* shared page body */}</Content>;
if (headerVariant === 'bui') {
return pageContent;
}
return (
<PageWithHeader title="My Plugin" themeId="tool">
{pageContent}
</PageWithHeader>
);
}
// Old system export
export const MyPage = (props: MyPageProps) => (
<MyPageContent {...props} headerVariant="legacy" />
);
// New system export
export const NfsMyPage = (props: MyPageProps) => (
<MyPageContent {...props} headerVariant="bui" />
);
Pattern C: Content-Only Sub-Pages (For Tabbed Plugins)
When using SubPageBlueprint for tabbed pages, sub-page loaders should render only the content — the parent PageBlueprint provides the header and tabs:
// src/alpha/extensions.tsx
import {
PageBlueprint,
SubPageBlueprint,
} from '@backstage/frontend-plugin-api';
export const myPluginPage = PageBlueprint.make({
params: {
path: '/my-plugin',
routeRef: rootRouteRef,
},
});
export const overviewSubPage = SubPageBlueprint.make({
name: 'overview',
params: {
path: 'overview',
title: 'Overview',
loader: () =>
import('../components/OverviewPage').then(m => <m.OverviewPageContent />),
},
});
export const settingsSubPage = SubPageBlueprint.make({
name: 'settings',
params: {
path: 'settings',
title: 'Settings',
loader: () =>
import('../components/SettingsPage').then(m => <m.SettingsPageContent />),
},
});
Note: when using SubPageBlueprint, omit the loader from PageBlueprint to use the built-in tabbed sub-page rendering. The PageBlueprint without a loader creates a parent page that renders sub-pages as tabs automatically. If the sub-page content needs padding, use Container from @backstage/ui as a wrapper inside the component.
Step 4: Migrate APIs to ApiBlueprint
APIs that were part of the old createPlugin({ apis: [...] }) become ApiBlueprint extensions added to the plugin's extensions array.
API Ownership
In the new system, each API has an owner plugin that controls who can provide or override it. Ownership can be set explicitly via pluginId on the ApiRef (recommended), or inferred from the ApiRef ID string pattern:
- Explicit
pluginIdon the ref → that plugin owns it plugin.<pluginId>.*ID → owned by that plugincore.*ID → owned by theappplugin
The recommended way to define API refs in the new system uses the builder pattern with an explicit pluginId:
// In your -react package
import { createApiRef } from '@backstage/frontend-plugin-api';
export const myPluginApiRef = createApiRef<MyPluginApi>().with({
id: 'plugin.my-plugin.client',
pluginId: 'my-plugin',
});
When your plugin provides an ApiBlueprint in its extensions array, the extension is automatically namespaced under your plugin — so the ownership is correct by default:
// src/alpha/apis.ts
import {
ApiBlueprint,
discoveryApiRef,
fetchApiRef,
} from '@backstage/frontend-plugin-api';
import { myPluginApiRef } from '@internal/plugin-my-plugin-react';
import { MyPluginClient } from '../api';
export const myPluginApi = ApiBlueprint.make({
params: defineParams =>
defineParams({
api: myPluginApiRef,
deps: {
discoveryApi: discoveryApiRef,
fetchApi: fetchApiRef,
},
factory: ({ discoveryApi, fetchApi }) =>
new MyPluginClient({ discoveryApi, fetchApi }),
}),
});
Add the API extension to the plugin's extensions array.
App adopters who want to override your plugin's API must do so using a createFrontendModule targeting your plugin's ID — they cannot override it from a module for a different plugin.
Step 5: Route Refs
Reusing Existing Route Refs
Route refs defined using createRouteRef from @backstage/core-plugin-api can be used directly in the new system — no conversion needed. They work when passed to createFrontendPlugin's routes/externalRoutes and to PageBlueprint's routeRef param:
// routes.ts — keep using your existing route refs from @backstage/core-plugin-api
import { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({ id: 'my-plugin' });
// alpha.tsx — pass them directly, no conversion needed
const myPage = PageBlueprint.make({
params: {
path: '/my-plugin',
routeRef: rootRouteRef,
loader: () => import('./MyPage').then(m => <m.NfsMyPage />),
},
});
There is no need for convertLegacyRouteRef or compatWrapper from @backstage/core-compat-api — these are no longer required for plugin migration.
Default Targets for External Route Refs
When adding new-system support, set defaultTarget on your external route refs so that apps don't need explicit route bindings for common cases. The target string uses the <pluginId>.<routeName> format, matching the routes map of the target plugin. The default is only used when the target plugin is actually installed — otherwise the route remains unbound.
// routes.ts
import { createExternalRouteRef } from '@backstage/core-plugin-api';
export const viewTechDocRouteRef = createExternalRouteRef({
id: 'view-techdoc',
optional: true,
params: ['namespace', 'kind', 'name'],
defaultTarget: 'techdocs.docRoot',
});
export const createComponentRouteRef = createExternalRouteRef({
id: 'create-component',
optional: true,
defaultTarget: 'scaffolder.root',
});
This significantly improves the out-of-the-box experience — plugins with sensible defaults "just work" when installed without requiring the app to configure bindRoutes.
useRouteRef Behavior Difference
In the new system, useRouteRef from @backstage/frontend-plugin-api may return undefined for unbound external routes. Legacy useRouteRef from @backstage/core-plugin-api throws an error instead. When writing NFS components, handle the undefined case.
Step 6: Translations
If the plugin uses translations, the translation ref should be exported from the main entry point (src/index.ts). There is no need to re-export it from ./alpha — consumers import translation refs from the main entry point regardless of which frontend system they use.
The same applies to other refs like API refs and route refs: keep them exported from the main entry point (or the -react package) and avoid duplicating exports in ./alpha.
Real Examples from the Backstage Repo
Catalog Plugin (Dual Entry Point)
- Old:
plugins/catalog/src/plugin.ts—createPluginwithcreateRoutableExtension - New:
plugins/catalog/src/alpha/plugin.tsx—createFrontendPluginwithPageBlueprint - Header split:
plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsxBaseCatalogPage(old) usesPageWithHeader+ContentHeaderNfsBaseCatalogPage(new) usesHeaderfrom@backstage/ui+Content
Scaffolder Plugin (Sub-Pages)
- Old:
plugins/scaffolder/src/plugin.tsx— singleScaffolderPagewith internal routing - New:
plugins/scaffolder/src/alpha/extensions.tsx—PageBlueprint(no loader) + multipleSubPageBlueprintentries for templates, tasks, actions, editor - Sub-page loaders wrap content in
<Content>only — no page shell
Notifications Plugin (Header Variant Pattern)
plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx- Uses
headerVariant: 'legacy' | 'bui'prop NfsNotificationsPagereturns content only (noPageWithHeader)NotificationsPagewraps inPageWithHeader
API Docs Plugin (Simple Dual Page)
plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.tsxDefaultApiExplorerPage(old) usesPageWithHeader+ContentHeaderNfsApiExplorerPage(new) usesHeader+Content
Migration Checklist
- Create
src/alpha.tsx(orsrc/alpha/directory) withcreateFrontendPlugin - Add
./alphatopackage.jsonexportsandtypesVersions - Create
PageBlueprintfor each top-level page - Create
SubPageBlueprintfor tabbed sub-pages (if applicable) - Convert API factories to
ApiBlueprintextensions - Implement NFS page variants without page shell (
Page/Header/PageWithHeader) - Use
Headerfrom@backstage/uifor subtitle/custom actions in NFS pages - Wire route refs (existing
@backstage/core-plugin-apirefs work directly, no conversion needed) - Ensure translation refs and API refs are exported from the main entry point (not duplicated in
./alpha) - Add
@backstage/frontend-plugin-apitopackage.jsondependencies - Add
@backstage/uito dependencies (if usingHeader) - Run
yarn tscto check for type errors - Run
yarn lintto check for missing dependencies - Test in both old app (
packages/app-legacy) and new app (packages/app) - Run
yarn build:api-reportsto update API reports (if the project uses API reports)