// Migrate a Backstage app from the old frontend system to the new one. Use this skill when converting an app to use the new extension-based frontend system, including the hybrid migration phase and the full migration of routes, sidebar, plugins, APIs, themes, and other app-level concerns.
Migrate a Backstage app from the old frontend system to the new one. Use this skill when converting an app to use the new extension-based frontend system, including the hybrid migration phase and the full migration of routes, sidebar, plugins, APIs, themes, and other app-level concerns.
App Frontend System Migration Skill
This skill helps migrate a Backstage app package (packages/app) from the old frontend system (@backstage/app-defaults) to the new extension-based frontend system (@backstage/frontend-defaults).
The migration follows a two-phase approach: first get the app running in hybrid mode with compatibility helpers, then gradually remove legacy code until the app is fully on the new system.
Key Concepts
Old system:createApp from @backstage/app-defaults, plugins installed via <Route> elements in FlatRoutes, manual app shell with AppRouter + Root
New system:createApp from @backstage/frontend-defaults, plugins installed as features, extensions wired into an extension tree, no manual app shell
Feature discovery: The new system can automatically discover and install plugins from your app's dependencies — no manual imports needed. This is the default for new apps and should be enabled early in migration.
Hybrid mode: The new createApp with convertLegacyAppRoot and convertLegacyAppOptions from @backstage/core-compat-api to bridge old code
Feature Discovery
Feature discovery is one of the biggest quality-of-life improvements in the new frontend system. Once enabled, any plugin added as a package.json dependency that exports a new-system plugin is automatically detected and installed — no code changes in App.tsx needed.
Enabling Feature Discovery
Add this to your app-config.yaml:
app:packages:all
This is the recommended default for all apps using the new frontend system. Enable it as early as Phase 1.
Filtering Discovered Packages
You can control which packages are discovered using include or exclude filters:
# Only discover specific packagesapp:packages:include:-'@backstage/plugin-catalog'-'@backstage/plugin-scaffolder'
# Discover all except specific packagesapp:packages:exclude:-'@backstage/plugin-techdocs'
Disabling Individual Extensions
Even with feature discovery enabled, you can disable specific extensions via config without removing the package:
Plugins that are both manually imported in features and auto-discovered are deduplicated — no conflicts. This means you can safely enable discovery while still explicitly importing plugins that need customization via .withOverrides().
When NOT to Use Discovery
Omit app.packages from config entirely (not app.packages: none — just leave it out) to disable discovery. You might do this if:
You need full control over which plugins are loaded
You're in early Phase 1 and want to introduce features one at a time
You're running in an environment where the @backstage/cli webpack integration isn't available
Feature discovery requires that the app is built using @backstage/cli, which is the default for all Backstage apps.
Once the app works in hybrid mode, gradually remove legacy code and compatibility helpers.
Migrating createApp Options
Legacy options become extensions. App-level extensions (themes, icons, sign-in page, translations) must be installed via createFrontendModule targeting pluginId: 'app':
In the new system, APIs are extensions that follow ownership rules. Understanding which pluginId to use when wrapping an API in a createFrontendModule is critical — using the wrong one will cause conflict errors at runtime.
Ownership rules:
Each API has an owner plugin. This can be set explicitly via pluginId on the ApiRef, or inferred from the ApiRef ID string:
Explicit pluginId on the ref (recommended) → that plugin owns it
core.* ID → owned by the app plugin
plugin.<pluginId>.* ID → owned by that plugin (e.g. plugin.catalog.starred-entities is owned by catalog)
Other ID prefixes → the prefix itself is the owner
Only modules for the owning plugin can provide or override an API. If plugin A tries to provide an API owned by plugin B, the system reports an API_FACTORY_CONFLICT error and rejects the override.
Modules for the same plugin override the plugin's own factory. This is how apps replace default implementations.
The recommended way to create API refs in the new system uses the builder pattern with an explicit pluginId:
import { createApiRef } from'@backstage/frontend-plugin-api';
// Recommended: explicit pluginId makes ownership unambiguousconst myApiRef = createApiRef<MyApi>().with({
id: 'plugin.my-plugin.my-api',
pluginId: 'my-plugin',
});
// Legacy form: ownership inferred from the id string patternconst legacyRef = createApiRef<MyApi>({ id: 'plugin.my-plugin.my-api' });
The builder form (createApiRef<T>().with(...)) is preferred because the pluginId is explicit 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.
Practical impact for app migration:
Most APIs that were in the old createApp({ apis: [...] }) are either core APIs (owned by app) or plugin-specific APIs. You need to group them into the right modules:
Common mistake: Putting all API overrides in a single createFrontendModule({ pluginId: 'app' }). This only works for APIs owned by app (i.e. core.* APIs like core.config, core.discovery, etc.). Plugin-specific APIs like plugin.catalog.* or plugin.scaffolder.* must be overridden using a module with the matching pluginId.
The old createApp({ apis: [...] }) pattern didn't have these restrictions — any API could be overridden from the app. In the new system, the ownership model is stricter to prevent accidental conflicts between plugins.
Built-in elements like AlertDisplay, OAuthRequestDialog, and VisitListener are provided by the framework automatically. Remove them from convertLegacyAppRoot:
Nav items are auto-discovered from page extensions. Use nav.take('page:<pluginId>') to place specific items, and nav.rest() for the remainder. Items that are taken are excluded from rest().
Migrating Routes
Remove routes from FlatRoutes one at a time. With feature discovery enabled (the recommended default), this is the only step needed — the new plugin version is already discovered and waiting; it was simply overridden by the legacy route which had higher priority:
// BEFORE: plugin page as a legacy routeconst routes = (
<FlatRoutes><Routepath="/create"element={<ScaffolderPage />} />
<Routepath="/catalog"element={<CatalogIndexPage />} />
</FlatRoutes>
);
// AFTER: just remove the route — discovery handles the restconst routes = (
<FlatRoutes><Routepath="/catalog"element={<CatalogIndexPage />} />
</FlatRoutes>
);
If you are not using feature discovery, you need to manually import and install the new plugin version:
Only one version of a plugin can be active in the app at a time. When legacy routes remain in FlatRoutes, convertLegacyAppRoot creates a plugin from them using the same plugin ID as the real plugin. This shadow plugin overrides the new-system version entirely. Because of this:
All routes from a single plugin must be removed at the same time. You cannot migrate one route of a multi-route plugin while keeping others in FlatRoutes. For example, if a plugin provides both /foo and /foo/settings, you must remove both routes together.
Entity page content counts as part of the plugin. Many plugins contribute both a top-level route (in FlatRoutes) and entity page cards/content (in the entity pages). These are all part of the same plugin. If you remove the route from FlatRoutes but keep the entity page card as JSX in your entity pages, the old entity card JSX is now orphaned — and the new plugin may auto-provide its own version of that card, leading to duplicates or missing content.
The practical consequence: when you migrate a plugin, remove all of its legacy touchpoints — routes and entity page extensions — at the same time.
Migrating Entity Pages
Entity pages are typically the most complex part of the migration because they pull in content from many different plugins. The entityPage option in convertLegacyAppRoot provides a way to migrate them gradually.
This converts your legacy entity page JSX tree into extensions. The structural pieces (EntityLayout, EntitySwitch) are preserved, while entity cards and content are converted into extensions that live alongside any auto-discovered new-system cards.
Migrating the catalog plugin itself
The catalog plugin is special because it owns both the /catalog route and the entity page route (/catalog/:namespace/:kind/:name). You must migrate both together:
Pass entityPage to convertLegacyAppRoot (if not already done) so your existing entity page layout is preserved.
Migrating individual plugins out of entity pages
Once the catalog plugin itself is migrated, you can gradually remove legacy entity content from the entity pages. For each plugin that provides entity cards or content:
Remove the legacy JSX from your entity page components (e.g. remove <EntityAboutCard />, <EntityTechdocsContent />, <EntityKubernetesContent />)
The new-system plugin auto-provides these as EntityCardBlueprint / EntityContentBlueprint extensions that are discovered automatically
If you see duplicate cards after removing routes but before removing entity page JSX, that's expected — the new plugin is auto-providing cards while the legacy JSX still renders them. Remove the legacy JSX to resolve the duplication.
Migrating entity page tabs
Tabs in entity pages (the EntityLayout.Route entries) are provided by EntityContentBlueprint extensions in the new system. As you remove legacy entity content JSX, the tabs are automatically sourced from the new-system extensions. The order and grouping of tabs can be configured via app-config.yaml:
Once all plugins contributing to entity pages have been migrated, the entityPage option can be removed from convertLegacyAppRoot, and the entity page component files in packages/app/src/components/catalog/ can be deleted.
Migrating Route Bindings
In the new system, plugins should define defaultTarget on their external route refs (e.g. createExternalRouteRef({ defaultTarget: 'scaffolder.root' })). When plugins set sensible defaults, most bindRoutes calls in the app become unnecessary — the routes resolve automatically when the target plugin is installed.
Review your existing bindRoutes configuration and remove any bindings that are already covered by default targets in the plugins. For the remaining cases that need custom bindings, you can still use bindRoutes or configure them via static config: