// Fully migrate a Backstage plugin to the new frontend system, dropping all old system support. Use this skill for internal plugins that only need to run in a single app, or when you are ready to remove backward compatibility entirely.
Fully migrate a Backstage plugin to the new frontend system, dropping all old system support. Use this skill for internal plugins that only need to run in a single app, or when you are ready to remove backward compatibility entirely.
Full Plugin Migration to the New Frontend System
This skill helps fully migrate an existing Backstage plugin from the old frontend system to the new one. Unlike adding dual support (which keeps the old system working), this is a complete migration that removes all @backstage/core-plugin-api usage and makes the plugin work exclusively with the new frontend system.
This is the preferred approach for internal plugins that are only used in a single app, since there is no need to maintain backward compatibility. It can also be used for published plugins when you're ready to drop old system support entirely.
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 Differences from Dual Support
Aspect
Dual Support
Full Migration
Entry point
Old src/plugin.ts + new src/alpha.tsx
Single src/plugin.tsx
Plugin creation
Both createPlugin and createFrontendPlugin
Only createFrontendPlugin
Core dependency
Keeps @backstage/core-plugin-api
Removes it, uses only @backstage/frontend-plugin-api
Route refs
Reuses @backstage/core-plugin-api refs directly
Uses createRouteRef from @backstage/frontend-plugin-api
Page shell
Old pages keep Page/Header, NFS pages skip it
All pages rely on framework's PageLayout/PluginHeader
createRouteRef() no longer takes an id — the ID is derived from the extension
createSubRouteRef path must start with / and must not end with /
createExternalRouteRef() no longer takes an id or optional flag
Set Default Targets for External Route Refs
When migrating external route refs, always set defaultTarget to the most common binding target. This removes the need for apps to explicitly bind routes via bindRoutes for standard plugin combinations:
The defaultTarget string uses the <pluginId>.<routeName> format, where routeName matches a key in the target plugin's routes map. The default is only activated when the target plugin is installed — otherwise the route stays unbound and useRouteRef returns undefined.
This is especially important for a full migration because in the old system, apps typically had explicit bindRoutes calls. With default targets, most of those bindings become unnecessary, improving the plug-and-play experience.
Step 2: Migrate the Plugin Definition
Replace src/plugin.ts with a createFrontendPlugin-based definition:
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.
Since this is the only entry point now, export it as default from src/index.ts or update package.json exports accordingly. If the plugin was previously consumed via its main entry point, you can make the main entry point export the new plugin:
The builder form (createApiRef<T>().with(...)) is preferred because ownership is explicit via pluginId rather than parsed from the ID string. The id must still be globally unique across the app — the pluginId is ownership metadata, not a namespace prefix.
API Ownership and Override Rules
The new system enforces API ownership — only the owning plugin (or a module targeting it) can provide or override a given API. Ownership is determined by:
The explicit pluginId on the ApiRef (if set via the builder pattern)
Falling back to inference from the ApiRef ID string:
plugin.<pluginId>.* → owned by that plugin
core.* → owned by the app plugin
If app adopters want to replace your plugin's default API implementation, they must use a createFrontendModule with pluginId matching your plugin — they cannot override it from a different plugin or from a generic app module. This is a stricter model than the old system where any API could be overridden from the app's apis array.
The MyPage component should not include Page, Header, or PageWithHeader from @backstage/core-components. The framework's PageLayout renders PluginHeader automatically.
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.
Page with Header for Custom Actions
If your page needs a subtitle or action buttons below the framework header, use Header from @backstage/ui:
Old frontend plugins often use React Router <Route> trees inside a router component to handle internal navigation. Before migrating, determine which routing pattern fits the plugin.
Decide Which Routing Pattern to Use
Not all internal routing maps to tabs. Read the plugin's existing router component and ask the user:
"Does your plugin use top-level tabs that users navigate between via a header (e.g. Overview / Settings)? Or does it use detail/drill-down routes (e.g. /my-plugin/items/:id)?"
Use SubPageBlueprint when:
The sub-routes represent top-level tabs/sections of the plugin
Users navigate between them via the header
Keep internal routing within a PageBlueprintloader when:
Routes are detail/drill-down pages (e.g. /my-plugin/items/:id)
The routing is deeply nested or dynamic
If the plugin uses drill-down routing only, use a PageBlueprint with a loader that handles its own <Routes> and skip the rest of this step:
In the new system, useRouteRef may return undefined for external route refs that aren't bound. Handle this:
// OLD — throws if not boundconst docsLink = useRouteRef(externalDocsRouteRef);
// Always a function// NEW — returns undefined if not boundconst docsLink = useRouteRef(externalDocsRouteRef);
if (docsLink) {
// render link
}
Common Import Mappings
Old Import (@backstage/core-plugin-api)
New Import (@backstage/frontend-plugin-api)
createPlugin
createFrontendPlugin
createRouteRef
createRouteRef
createSubRouteRef
createSubRouteRef
createExternalRouteRef
createExternalRouteRef
createApiRef
createApiRef
createApiFactory
ApiBlueprint.make
useApi
useApi
useRouteRef
useRouteRef
configApiRef
configApiRef
discoveryApiRef
discoveryApiRef
fetchApiRef
fetchApiRef
identityApiRef
identityApiRef
storageApiRef
storageApiRef
analyticsApiRef
analyticsApiRef
createRoutableExtension
PageBlueprint.make
createComponentExtension
Depends on context — blueprint or createExtension
Step 7: Remove Old System Code
Delete src/plugin.ts (old createPlugin)
Delete any createRoutableExtension / createComponentExtension usage
Remove Page, Header, PageWithHeader wrapping from page components
Remove HeaderTabs if replaced by SubPageBlueprint tabs
Remove internal <Routes>/<Route> trees if replaced by sub-pages
Remove @backstage/core-plugin-api from package.jsondependencies
Remove @backstage/core-compat-api from package.jsondependencies if present
Step 8: Update Page Components for BUI
With the full migration, page components should use @backstage/ui components and patterns. See the mui-to-bui-migration skill for detailed component migration guidance.
Key page-level changes:
Replace PageWithHeader / Page + Header with framework-provided PluginHeader (automatic via PageLayout)
Use Header from @backstage/ui for optional subtitle/custom actions
Use Content from @backstage/core-components for page body padding (this is still used even in NFS pages)
Replace ContentHeader with Header's customActions prop
Replace HeaderTabs with SubPageBlueprint (tabs are rendered by the framework)
Real Example: Auth Plugin (Fully Migrated)
The @backstage/plugin-auth plugin is a fully migrated example with no @backstage/core-plugin-api dependency: