Skip to main content
Version: Next

Migrating Apps

Overview

This section describes how to migrate an existing Backstage app package to use the new frontend system. The app package is typically found at packages/app in your project and is responsible for wiring together the Backstage frontend application.

Who is this for?
This guide is intended for maintainers of Backstage app packages (packages/app) who want to upgrade from the legacy frontend system to the new extension-based architecture.

Prerequisites:

  • Familiarity with your app’s current structure and configuration
  • Yarn workspaces and monorepo setup
  • Access to run yarn commands and update dependencies

Migration

We recommend a two-phase migration process to ensure a smooth and manageable transition:

  • Phase 1: Minimal Changes for Hybrid Configuration
    In this phase, you make the smallest set of changes necessary to enable your app to run in a hybrid mode. This allows you to start using the new frontend system while still relying on compatibility helpers and legacy code. The goal is to unblock your migration quickly, so you can benefit from the new system without a full rewrite.

  • Phase 2: Complete Transition to the New Frontend System
    After your app is running in hybrid mode, you can gradually refactor your codebase to remove legacy code and compatibility helpers. This phase focuses on fully adopting the new frontend architecture, ensuring your codebase is clean, maintainable, and takes full advantage of the new features.

warning

Staying in hybrid mode for too long is not recommended. Support for the legacy version and compatibility helpers will be dropped in the future, so we recommend planning to fully migrate your codebase as soon as possible.

Checklist

Before you begin, review this checklist to track your progress:

  • Complete minimal changes for hybrid configuration (Phase 1)
  • App starts and works in hybrid mode
  • Gradually migrate and remove legacy code and helpers (Phase 2)
  • App runs fully on the new frontend system
info

If you encounter issues, check GitHub issues or ask in Discord.

Phase 1: Minimal Changes for Hybrid Configuration

There are 5 steps to minimally change your app to start experimenting with the new frontend system in a hybrid mode.

After completing these steps you should be able to start up the app and see that it still works.

1) Switching out createApp

To start we'll need to add the new @backstage/frontend-defaults package:

yarn --cwd packages/app add @backstage/frontend-defaults

The next step is to switch out the createApp function for the new one from @backstage/frontend-defaults:

in packages/app/src/App.tsx
import { createApp } from '@backstage/app-defaults';
import { createApp } from '@backstage/frontend-defaults';

This immediate switch will lead to a lot of breakages that will be fixed in the upcoming steps.

2) Converting the createApp options

Most of the legacy createApp options — with the exception of bindRoutes — can be converted into features that are compatible with the new frontend system. This is accomplished using the convertLegacyAppOptions helper, which allows you to continue using your existing configuration while gradually migrating to the new architecture.

To do so, start by adding this dependency to your app package:

yarn --cwd packages/app add @backstage/core-compat-api

Open the file where your application was created. Currently, you should be doing something like this:

in packages/app/src/App.tsx
const app = createApp({
apis,
icons: {
// Custom icon example
alert: AlarmIcon,
},
featureFlags: [
{
name: 'scaffolder-next-preview',
description: 'Preview the new Scaffolder Next',
pluginId: '',
},
],
components: {
SignInPage: props => {
return (
<SignInPage
{...props}
providers={['guest', 'custom', ...providers]}
title="Select a sign-in method"
align="center"
/>
);
},
},
});

Migrate it to the following:

in packages/app/src/App.tsx
import { createApp } from '@backstage/frontend-defaults';
import { convertLegacyAppOptions } from '@backstage/core-compat-api';

const convertedOptionsModule = convertLegacyAppOptions({
/* legacy options such as apis, icons, plugins, components, themes and featureFlags */
apis,
icons: {
// Custom icon example
alert: AlarmIcon,
},
featureFlags: [
{
name: 'scaffolder-next-preview',
description: 'Preview the new Scaffolder Next',
pluginId: '',
},
],
components: {
SignInPage: props => {
return (
<SignInPage
{...props}
providers={['guest', 'custom', ...providers]}
title="Select a sign-in method"
align="center"
/>
);
},
},
}});

const app = createApp({
features: [
// ...
convertedOptionsModule,
],
});

If you were binding routes from a legacy createApp, you will need to use the convertLegacyRouteRefs and/or convertLegacyRouteRef to convert the routes to be compatible with the new system.

For example, if both the catalogPlugin and scaffolderPlugin are legacy plugins, you can bind their routes like this:

import { createApp } from '@backstage/frontend-defaults';
import {
// ...
convertLegacyRouteRefs,
convertLegacyRouteRef,
} from '@backstage/core-compat-api';

// Ommitting converted options changes
//...

const app = createApp({
features: [
// ...
convertedOptionsModule,
],
bindRoutes({ bind }) {
bind(convertLegacyRouteRefs(catalogPlugin.externalRoutes), {
createComponent: convertLegacyRouteRef(scaffolderPlugin.routes.root),
});
},
});

3) Fixing the app.createRoot call

The app.createRoot(...) no longer accepts any arguments. This represents a fundamental change that the new frontend system introduces. In the old system the app element tree that you passed to app.createRoot(...) was the primary way that you installed and configured plugins and features in your app. In the new system this is instead replaced by extensions that are wired together into an extension tree. Much more responsibility has now been shifted to plugins, for example you no longer have to manually provide the route path for each plugin page, but instead only configure it if you want to override the default. For more information on how the new system works, see the architecture section.

Given that the app element tree is most of what builds up the app, it's likely also going to be the majority of the migration effort. In order to make the migration as smooth as possible we have provided a helper that lets you convert an existing app element tree into plugins that you can install in a new app. This in turn allows for a gradual migration of individual plugins, rather than needing to migrate the entire app structure at once.

The helper is called convertLegacyAppRoot and is exported from the @backstage/core-compat-api package. Once installed, import convertLegacyAppRoot. If your app currently looks like this:

in packages/app/src/App.tsx
const app = createApp({
/* All legacy options except route bindings */
});

export default app.createRoot(
<>
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
<Root>{routes}</Root>
</AppRouter>
</>,
);

Migrate it to the following:

in packages/app/src/App.tsx
import {
// ...
convertLegacyAppRoot,
} from '@backstage/core-compat-api';

const convertedRootFeatures = convertLegacyAppRoot(
<>
<AlertDisplay />
<OAuthRequestDialog />
<AppRouter>
<Root>{routes}</Root>
</AppRouter>
</>,
);

const app = createApp({
features: [
// ...
...convertedRootFeatures,
],
});

export default app.createRoot();

We've taken all the elements that were previously passed to app.createRoot(...), and instead passed them to convertLegacyAppRoot(...). We then pass the features returned by convertLegacyAppRoot and forward them to the features option of the new createApp.

4) Adjusting the app rendering

There is one more detail that we need to deal with before moving on. The app.createRoot() function now returns a React element rather than a component, so we need to update our app index.tsx as follows:

in packages/app/src/index.tsx
import '@backstage/cli/asset-types';
import ReactDOM from 'react-dom/client';
import App from './App';
import app from './App';

ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
ReactDOM.createRoot(document.getElementById('root')!).render(app);

5) Updating the app test file

You'll also need to make similar changes to your App.test.tsx file as well:

import { render, waitFor } from '@testing-library/react';
import App from './App';
import app from './App';

describe('App', () => {
it('should render', async () => {
process.env = {
NODE_ENV: 'test',
APP_CONFIG: [
{
data: {
app: { title: 'Test' },
backend: { baseUrl: 'http://localhost:7007' },
},
context: 'test',
},
] as any,
};

const rendered = render(<App />);
const rendered = render(app);

await waitFor(() => {
expect(rendered.baseElement).toBeInTheDocument();
});
});
});

Phase 2: Complete Transition to the New Frontend System

If your app starts and works in hybrid mode, you’re ready to begin Phase 2. If not, review the error messages, check the GitHub issues, or ask for help in our community Discord.

At this point, the contents of your app should have moved past the initial migration stage. Let's continue by gradually removing legacy code and helpers to fully adopt the new system.

Migrating createApp options

Many of the createApp options have been migrated to use extensions instead. Each will have their own extension blueprint that you use to create a custom extension. To add these standalone extensions to the app they need to be passed to createFrontendModule, which bundles them into a feature that you can install in the app. See the frontend module section for more information.

For example, assuming you have a lightTheme extension that you want to add to your app, you can use the following:

First we add the @backstage/frontend-plugin-api package

yarn --cwd packages/app add @backstage/frontend-plugin-api

Then we can use it like this:

import { createFrontendModule } from '@backstage/frontend-plugin-api';

const app = createApp({
features: [
// ...
createFrontendModule({
pluginId: 'app',
extensions: [lightTheme],
}),
],
});

You can then also add any additional extensions that you may need to create as part of this migration to the extensions array as well.

apis

Utility API factories are now installed as extensions instead. Pass the existing factory to ApiBlueprint and install it in the app. For more information, see the section on configuring Utility APIs.

For example, the following apis configuration:

const app = createApp({
apis: [
createApiFactory({
api: scmIntegrationsApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
}),
],
});

Can be converted to the following extension:

import { ApiBlueprint } from '@backstage/frontend-plugin-api';

const scmIntegrationsApi = ApiBlueprint.make({
name: 'scm-integrations',
params: defineParams =>
defineParams({
api: scmIntegrationsApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
}),
});

You would then add scmIntegrationsApi as an extension like you did with lightTheme in the Migrating createApp Options section.

plugins

Plugins are now passed through the features options instead.

For example, the following plugins configuration:

import { homePlugin } from '@backstage/plugin-home';

createApp({
// ...
plugins: [homePlugin],
// ...
});

Can be converted to the following features configuration:

// plugins are now default exported via alpha subpath
import homePlugin from '@backstage/plugin-home/alpha';

createApp({
// ...
features: [homePlugin],
// ...
});

Plugins don't even have to be imported manually after installing their package if features discovery is enabled.

in app-config.yaml
app:
# Enabling plugin and override features discovery
packages: all # ✨

featureFlags

Declaring features flags in the app is no longer supported, move these declarations to the appropriate plugins instead.

For example, the following app feature flags configuration:

createApp({
// ...
featureFlags: [
{
pluginId: '',
name: 'tech-radar',
description: 'Enables the tech radar plugin',
},
],
// ...
});

Can be converted to the following plugin configuration:

import { createFrontendPlugin } from '@backstage/frontend-plugin-api';

createFrontendPlugin({
pluginId: 'tech-radar',
// ...
featureFlags: [{ name: 'tech-radar' }],
// ...
});

This would get added to the features array as part of your createApp options.

components

Many app components are now installed as extensions instead using createComponentExtension. See the section on configuring app components for more information.

The Router component is now a built-in extension that you can override using createRouterExtension.

The Sign-in page is now installed as an extension, created using the SignInPageBlueprint instead.

For example, the following sign-in page configuration:

const app = createApp({
components: {
SignInPage: props => (
<SignInPage
{...props}
provider={{
id: 'github-auth-provider',
title: 'GitHub',
message: 'Sign in using GitHub',
apiRef: githubAuthApiRef,
}}
/>
),
},
});

Can be converted to the following extension:

import { SignInPageBlueprint } from '@backstage/frontend-plugin-api';

const signInPage = SignInPageBlueprint.make({
params: {
loader: async () => props =>
(
<SignInPage
{...props}
provider={{
id: 'github-auth-provider',
title: 'GitHub',
message: 'Sign in using GitHub',
apiRef: githubAuthApiRef,
}}
/>
),
},
});

You would then add signInPage as an extension like you did with lightTheme in the Migrating createApp Options section.

themes

Themes are now installed as extensions, created using ThemeBlueprint.

For example, the following theme configuration:

const app = createApp({
themes: [
{
id: 'custom-light',
title: 'Light',
variant: 'light',
Provider: ({ children }) => (
<UnifiedThemeProvider theme={customLightTheme}>
{children}
</UnifiedThemeProvider>
),
},
],
});

Can be converted to the following extension:

import { ThemeBlueprint } from '@backstage/frontend-plugin-api';

const customLightThemeExtension = ThemeBlueprint.make({
name: 'custom-light',
params: {
theme: {
id: 'custom-light',
title: 'Light Theme',
variant: 'light',
icon: <LightIcon />,
Provider: ({ children }) => (
<UnifiedThemeProvider theme={customLightTheme} children={children} />
),
},
},
});

You would then add customLightThemeExtension as an extension like you did with lightTheme in the Migrating createApp Options section.

configLoader

The config loader API has been slightly changed. Rather than returning a promise for an array of AppConfig objects, it should now return the ConfigApi directly.

import { ConfigReader } from '@backstage/core-app-api';

const app = createApp({
async configLoader() {
const appConfigs = await loadAppConfigs();
return appConfigs;
return { config: ConfigReader.fromConfigs(appConfigs) };
},
});

icons

Icons are now installed as extensions, using the IconBundleBlueprint to make new instances which can be added to the app.

import { IconBundleBlueprint } from '@backstage/frontend-plugin-api';

const exampleIconBundle = IconBundleBlueprint.make({
name: 'example-bundle',
params: {
icons: {
user: MyOwnUserIcon,
},
},
});

const app = createApp({
features: [
createFrontendModule({
pluginId: 'app',
extensions: [exampleIconBundle],
}),
],
});

bindRoutes

Route bindings can still be done using this option, but you now also have the ability to bind routes using static configuration instead. See the section on binding routes for more information.

Note that if you are binding routes from a legacy plugin that was converted using convertLegacyAppRoot, you will need to use the convertLegacyRouteRefs and/or convertLegacyRouteRef to convert the routes to be compatible with the new system.

For example, if both the catalogPlugin and scaffolderPlugin are legacy plugins, you can bind their routes like this:

const app = createApp({
features: convertLegacyAppRoot(...),
bindRoutes({ bind }) {
bind(convertLegacyRouteRefs(catalogPlugin.externalRoutes), {
createComponent: convertLegacyRouteRef(scaffolderPlugin.routes.root),
});
},
});

__experimentalTranslations

Translations are now installed as extensions, created using TranslationBlueprint.

For example, the following translations configuration:

import { catalogTranslationRef } from '@backstage/plugin-catalog/alpha';
createApp({
// ...
__experimentalTranslations: {
resources: [
createTranslationMessages({
ref: catalogTranslationRef,
catalog_page_create_button_title: 'Create Software',
}),
],
},
// ...
});

Can be converted to the following extension:

import { catalogTranslationRef } from '@backstage/plugin-catalog/alpha';
import {
createTranslationMessages,
TranslationBlueprint,
} from '@backstage/frontend-plugin-api';

const catalogTranslations = TranslationBlueprint.make({
name: 'catalog-overrides',
params: {
resource: createTranslationMessages({
ref: catalogTranslationRef,
catalog_page_create_button_title: 'Create Software',
}),
},
});

You would then add catalogTranslations as an extension like you did with lightTheme in the Migrating createApp Options section.

Migrating createRoot components

This will remove many extension overrides that convertLegacyAppRoot put in place, and switch over the shell of the app to the new system. This includes the root layout of the app along with the elements, router, and sidebar. The app will likely not look the same as before, and you'll need to refer to the sidebar, app root elements and app root wrappers sections below for information on how to migrate those.

Once that step is complete the work that remains is to migrate all of the routes and entity pages in the app, including any plugins that do not yet support the new system. For information on how to migrate your own internal plugins, refer to the plugin migration guide. For external plugins you will need to check the migration status of each plugin and potentially contribute to the effort.

Once these migrations are complete you should be left with an empty convertLegacyAppRoot(...) call that you can now remove, and your app should be fully migrated to the new system! 🎉

App Root Elements

App root elements are React elements that are rendered adjacent to your current Root component. For example, in this snippet AlertDisplay, OAuthRequestDialog and VisitListener are all app root elements:

in packages/app/src/App.tsx
const convertedRootFeatures = convertLegacyAppRoot(
<>
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
<VisitListener />
<Root>{routes}</Root>
</AppRouter>
</>,
);

The AlertDisplay and OAuthRequestDialog are already provided as built-in extensions, and so will VisitListener, so you can remove all surrounding elements and just keep the routes:

in packages/app/src/App.tsx
const convertedRootFeatures = convertLegacyAppRoot(routes);

But, if you have your own custom root elements you will need to migrate them to be extensions that you install in the app instead. Use createAppRootElementExtension to create said extension and then install it in the app.

Whether the element used to be rendered as a child of the AppRouter or not doesn't matter. All new root app elements will be rendered as a child of the app router.

App Root Wrappers

App root wrappers are React elements that are rendered as a parent of the current Root elements. For example, in this snippet the CustomAppBarrier is an app root wrapper:

const convertedRootFeatures = convertLegacyAppRoot(
<>
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
<CustomAppBarrier>
<Root>{routes}</Root>
</CustomAppBarrier>
</AppRouter>
</>,
);

Any app root wrapper needs to be migrated to be an extension, created using AppRootWrapperBlueprint. Note that if you have multiple wrappers they must be completely independent of each other, i.e. the order in which they the appear in the React tree should not matter. If that is not the case then you should group them into a single wrapper.

Here is an example converting the CustomAppBarrier into extension:

createApp({
// ...
features: [
createFrontendModule({
pluginId: 'app',
extensions: [
AppRootWrapperBlueprint.make({
name: 'custom-app-barrier',
params: {
// Whenever your component uses legacy core packages, wrap it with "compatWrapper"
// e.g. props => compatWrapper(<CustomAppBarrier {...props} />)
Component: CustomAppBarrier,
},
}),
],
}),
],
// ...
});

App Root Sidebar

New apps feature a built-in sidebar extension which is created by using the NavContentBlueprint in src/modules/nav/Sidebar.tsx. The default implementation of the sidebar in this blueprint will render some items explicitly in different groups, and then render the rest of the items which are the other NavItem extensions provided by the system.

In order to migrate your existing sidebar, you will want to create an override for the app/nav extension. You can do this by copying the standard of having a src/modules/nav/ folder, which can contain an extension which you can install into the app in the form of a module.

in packages/app/src/modules/nav/index.ts
import { createFrontendModule } from '@backstage/frontend-plugin-api';
import { SidebarContent } from './Sidebar';

export const navModule = createFrontendModule({
pluginId: 'app',
extensions: [SidebarContent],
});

Then in the actual implementation for the SidebarContent extension, you can provide something like the following, where the component that is passed to the compatWrapper is the entire Sidebar component from your Root component.

The compatWrapper is there to ensure that any legacy plugins using things like useRouteRef work well in the new system, so if you run into some errors which look like compatibility issues, make sure that this wrapper is used in the relevant places.

in packages/app/src/modules/nav/Sidebar.tsx
import { compatWrapper } from '@backstage/core-compat-api';
import { NavContentBlueprint } from '@backstage/frontend-plugin-api';

export const SidebarContent = NavContentBlueprint.make({
params: {
component: ({ items }) =>
compatWrapper(
<Sidebar>
<SidebarLogo />
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SidebarSearchModal />
</SidebarGroup>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
...
</SidebarGroup>
<SidebarGroup label="Plugins">
<SidebarScrollWrapper>
{/* Items in this group will be scrollable if they run out of space */}
{items.map((item, index) => (
<SidebarItem {...item} key={index} />
))}
</SidebarScrollWrapper>
</SidebarGroup>
</Sidebar>,
),
},
});

The items property is a list of all extensions provided by the NavItemBlueprint that are currently installed in the App. If you don't want to auto populate this list you can simply remove the rendering of that SidebarGroup, but otherwise you can see from the above example how a SidebarItem element is rendered for each of the items in the list.

You might also notice that when you're rendering additional fixed icons for plugins that these might become duplicated as the plugin provides a NavItem extension and you're also rendering one in the Sidebar manually. In order to remove the item from the list of items which is passed through, we recommend that you disable that extension using config:

in app-config.yaml
app:
extensions:
- nav-item:search: false
- nav-item:catalog: false

You can also determine the order of the provided auto installed NavItems that you get from the system in config. The below example ensures that the catalog navigation item will proceed the search navigation item when being passed through as the item prop.

in app-config.yaml
app:
extensions:
- nav-item:catalog
- nav-item:search

App Root Routes

Your top-level routes are the routes directly under the AppRouter component with the <FlatRoutes> element. In a small app they might look something like this:

in packages/app/src/App.tsx
const routes = (
<FlatRoutes>
<Route path="/catalog" element={<CatalogIndexPage />} />
<Route
path="/catalog/:namespace/:kind/:name"
element={<CatalogEntityPage />}
>
{entityPage}
</Route>
<Route path="/create" element={<ScaffolderPage />} />
<Route
path="/tech-radar"
element={<TechRadarPage width={1500} height={800} />}
/>
</FlatRoutes>
);

Each of these routes needs to be migrated to the new system. You can do it as gradually as you want, with the only restriction being that all routes from a single plugin must be migrated at once. This is because plugins discovered from these legacy routes will override any plugins that are installed in your app. If you for example only migrate one of the two routes defined by a plugin, the other route will remain and still override any plugin with the same ID, and you're left with a partial and likely broken plugin.

To migrate a route, you need to remove it from your list of routes and instead install the new version of the plugin in your app. Before doing this you should make sure that the plugin supports the new system. Let's remove the scaffolder route as an example:

in packages/app/src/App.tsx
const routes = (
<FlatRoutes>
<Route path="/catalog" element={<CatalogIndexPage />} />
<Route
path="/catalog/:namespace/:kind/:name"
element={<CatalogEntityPage />}
>
{entityPage}
</Route>
<Route path="/create" element={<ScaffolderPage />} />
<Route
path="/tech-radar"
element={<TechRadarPage width={1500} height={800} />}
/>
</FlatRoutes>
);

If you are using app feature discovery the installation step is simple, it's already done! The new version of the scaffolder plugin was already discovered and present in the app, it was simply disabled because the plugin created from the legacy route had higher priority. If you do not use feature discovery, you will instead need to manually install the new scaffolder plugin in your app through the features option of createApp.

Continue this process for each of your legacy routes until you have migrated all of them. For any plugin with additional extensions installed as children of the Route, refer to the plugin READMEs for more detailed instructions. For the entity pages, refer to the separate section.

Migrating core, internal and third-party plugins

For certain core plugins — such as the Catalog plugin's entity page — we provide a dedicated step-by-step migration guide, since these plugins often require a more gradual approach due to their complexity.

Refer to the plugin migration guide for instructions on migrating internal plugins. For external plugins, check their migration status and contribute if needed.

Catalog Entity Page

The entity pages are typically defined in packages/app/src/components/catalog and rendered as a child of the /catalog/:namespace/:kind/:name route. The entity pages are typically quite large and bringing in content from quite a lot of different plugins. To help gradually migrate entity pages we provide the entityPage option in the convertLegacyAppRoot helper. This option lets you pass in an entity page app element tree that will be converted to extensions that are added to the features returned from convertLegacyAppRoot.

To start the gradual migration of entity pages, add your entityPages to the convertLegacyAppRoot call:

in packages/app/src/App.tsx
const convertedRootFeatures = convertLegacyAppRoot(routes);
const convertedRootFeatures = convertLegacyAppRoot(routes, { entityPage });

Next, you will need to fully migrate the catalog plugin itself. This is because only a single version of a plugin can be installed in the app at a time, so in order to start using the new version of the catalog plugin you need to remove all usage of the old one. This includes both the routes and entity pages. You will need to keep the structural helpers for the entity pages, such as EntityLayout and EntitySwitch, but remove any extensions like the <CatalogIndexPage/> and entity cards and content like <EntityAboutCard/> and <EntityOrphanWarning/>.

Remove the following routes:

in packages/app/src/App.tsx
const routes = (
<FlatRoutes>
...
<Route path="/catalog" element={<CatalogIndexPage />} />
<Route
path="/catalog/:namespace/:kind/:name"
element={<CatalogEntityPage />}
>
{entityPage}
</Route>
...
</FlatRoutes>
);

And explicitly install the catalog plugin before the converted legacy features:

in packages/app/src/App.tsx
import { default as catalogPlugin } from '@backstage/plugin-catalog/alpha';

const app = createApp({
features: [convertedOptionsModule, ...convertedRootFeatures],
features: [catalogPlugin, convertedOptionsModule, ...convertedRootFeatures],
});

If you are not using the default <CatalogIndexPage /> you can install your custom catalog page as an override for now instead, and fully migrate it to the new system later.

in packages/app/src/App.tsx
const catalogPluginOverride = catalogPlugin.withOverrides({
extensions: [
catalogPlugin.getExtension('page:catalog').override({
params: {
loader: async () => (
<CatalogIndexPage
pagination={{ mode: 'offset', limit: 20 }}
filters={<>{/* ... */}</>}
/>
),
},
}),
],
});

const app = createApp({
features: [
catalogPlugin,
catalogPluginOverride,
convertedOptionsModule,
...convertedRootFeatures,
],
});

At this point you should be able to run the app and see that you're not using the new version of the catalog plugin. If you navigate to the entity pages you will likely see a lot of duplicate content at the bottom of the page. These are the duplicates of the entity cards provided by the catalog plugin itself that we mentioned earlier that you need to remove. Clean up the entity pages by removing cards and content from the catalog plugin such as <EntityAboutCard/> and <EntityOrphanWarning/>.

Once the cleanup is complete you should be left with clean entity pages that are built using a mix of the old and new frontend system. From this point you can continue to gradually migrate plugins that provide content for the entity pages, until all plugins have been fully moved to the new system and the entityPage option can be removed.

Migrating across the tabs for the Entity Pages should be as simple as removing the EntityLayout.Route for each of the plugins that provide tab content, and then this tab should be sourced from the EntityContent extensions created by the plugins themselves which will be automatically detected and added to the App.

Enable the new templates for yarn new

It's encouraged that once you switch over to using the new frontend system, that new plugins that you create are using the new frontend system. This means that you're not instantly creating legacy plugins that will eventually need migration.

This practice is also pretty important early on, as it's going to help you get familiar with the practices of the new frontend system.

When creating a new Backstage app with create-app and using the --next flag you'll automatically get these choices in the yarn new command, but if you want to bring these templates to an older app, you can add the following to your root package.json:

{
...
"scripts": {
...
"new": "backstage-cli new"
},
"backstage": {
"cli": {
"new": {
"globals": {
"license": "UNLICENSED"
},
"templates": [
"@backstage/cli/templates/new-frontend-plugin",
"@backstage/cli/templates/new-frontend-plugin-module",
"@backstage/cli/templates/backend-plugin",
"@backstage/cli/templates/backend-plugin-module",
"@backstage/cli/templates/plugin-web-library",
"@backstage/cli/templates/plugin-node-library",
"@backstage/cli/templates/plugin-common-library",
"@backstage/cli/templates/web-library",
"@backstage/cli/templates/node-library",
"@backstage/cli/templates/scaffolder-backend-module"
]
}
}
}
}

Troubleshooting

We'd recommend that you install the app-visualizer plugin to help your troubleshooting. If you run yarn add @backstage/plugin-app-visualizer in packages/app it should be automatically added to the sidebar, and available on /visualizer.

There is a tree mode that can be very helpful in understanding which plugins are being automatically detected and the extensions that they are providing to the system. You should also be able to see any legacy extensions which are being converted and added to the app.

This can be really useful output when raising any issue to the main repository too, so we can dig in to see what's happening with the system.

I'm seeing duplicate cards for Entity Pages

When using the entityPage option with convertLegacyAppRoot, you may notice duplicate cards appearing on your Entity Pages. This happens because the migration helper automatically extracts cards from your existing Entity Page component and adds them to the new system, while the new Entity Page system also automatically includes cards from any plugins installed in your packages/app package. This results in the same card appearing twice - once from your legacy component and once from the plugin.

To fix this, simply remove the card definitions from your old Entity Page component. The new system will automatically provide these cards through the installed plugins, so your manual definitions are no longer needed.

Error: Invalid element inside FlatRoutes, expected Route but found element of type ...

This means that the Routes inside FlatRoutes contains something other than a Route element. This could be for example a FeatureFlag or RequirePermissions element. These are not currently supported by the new frontend system. Workarounds include pushing this logic down from the App.tsx routes into the plugins themselves as these elements no longer need to live in the App.tsx for the system to be able to walk and collect the plugins and routes that are available in the App.

If you have a use case where these are required, please reach out to us either through a bug report or the community Discord.

Next Steps