Skip to main content

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.

Switching out createApp

The first step in migrating an app is to switch out the createApp function for the new one from @backstage/frontend-api-app:

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

This immediate switch will lead to a lot of breakages that we need to fix.

Let's start by addressing the change to app.createRoot(...), which 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 convertLegacyApp and is exported from the @backstage/core-compat-api package, which you will need to add as a dependency to your app package:

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

Once installed, import convertLegacyApp. If your app currently looks like this:

in packages/app/src/App.tsx
const app = createApp({
/* other options */
});

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

Migrate it to the following:

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

const app = createApp({
/* other options */
features: [...legacyFeatures],
});

export default app.createRoot();

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

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 React from 'react';
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);

At this point the contents of your app should be past the initial migration stage, and we can move on to migrating any remaining options that you may have passed to createApp.

Migrating createApp Options

Many of the createApp options have been migrated to use extensions instead. Each will have their own extension creator that you use to create a custom extension. To add these standalone extensions to the app they need to be passed to createExtensionOverrides, which bundles them into a feature that you can install in the app. See the standalone extensions 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:

const app = createApp({
features: [
createExtensionOverrides({
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 createApiExtension 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:

const scmIntegrationsApi = createApiExtension({
factory: createApiFactory({
api: scmIntegrationsApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
}),
});

icons

Icons are currently installed through the usual options to createApp, but will be switched to use extensions in the future.

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
experimental: '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:

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

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 using the createSignInPageExtension 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:

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

themes

Themes are now installed as extensions, using createThemeExtension.

For example, the following theme configuration:

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

Can be converted to the following extension:

const lightTheme = createThemeExtension({
id: 'light',
title: 'Light Theme',
variant: 'light',
icon: <LightIcon />,
Provider: ({ children }) => (
<UnifiedThemeProvider theme={builtinThemes.light} children={children} />
),
});

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.

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

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 convertLegacyApp, 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: convertLegacyApp(...),
bindRoutes({ bind }) {
bind(convertLegacyRouteRefs(catalogPlugin.externalRoutes), {
createComponent: convertLegacyRouteRef(scaffolderPlugin.routes.root),
});
},
});

__experimentalTranslations

Translations are now installed as extensions, using createTranslationExtension.

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:

createTranslationExtension({
resource: createTranslationMessages({
ref: catalogTranslationRef,
catalog_page_create_button_title: 'Create Software',
}),
});

Gradual Migration

After updating all createApp options as well as using convertLegacyApp to use your existing app structure, you should be able to start up the app and see that it still works. If that is not the case, make sure you read any error messages that you may see in the app as they can provide hints on what you need to fix. If you are still stuck, you can check if anyone else ran into the same issue in our GitHub issues, or ask for help in our community Discord.

Assuming your app is now working, let's continue by migrating the rest of the app element tree to use the new system.

First off we'll want to trim away any top-level elements in the app so that only the routes are left. For example, continuing where we left off with the following elements:

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

You can remove all surrounding elements and just keep the routes:

in packages/app/src/App.tsx
const legacyFeatures = convertLegacyApp(routes);

This will remove many extension overrides that convertLegacyApp 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 convertLegacyApp(...) call that you can now remove, and your app should be fully migrated to the new system! 🎉

Top-level 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.

Entity Pages

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. At the moment we do not provide a way to gradually migrate entity pages to the new system, although that is planned as a future improvement. This means that the entire entity page and all of its plugins need to be migrated at once, including any other usages of those plugins.

New apps feature a built-in sidebar extension (app/nav) that will render all nav item extensions provided by plugins. This is a placeholder implementation and not intended as a long-term solution. In the future we will aim to provide a more flexible sidebar extension that allows for more customization out of the box.

Because the built-in sidebar is quite limited you may want to override the sidebar with your own custom implementation. To do so, use createExtension directly and refer to the original sidebar implementation. The following is an example of how to take your existing sidebar from the Root component that you typically find in packages/app/src/components/Root.tsx, and use it in an extension override:

const nav = createExtension({
namespace: 'app',
name: 'nav',
attachTo: { id: 'app/layout', input: 'nav' },
output: {
element: coreExtensionData.reactElement,
},
factory({ inputs }) {
return {
element: (
<Sidebar>
{/* Sidebar contents from packages/app/src/components/Root.tsx go here */}
</Sidebar>
),
};
},
});

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:

export default app.createRoot(
<>
<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. 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:

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

Any app root wrapper needs to be migrated to be an extension, using createAppRootWrapperExtension. 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: [
createExtensionOverrides({
extensions: [
createAppRootWrapperExtension({
name: 'CustomAppBarrier',
// Whenever your component uses legacy core packages, wrap it with "compatWrapper"
// e.g. props => compatWrapper(<CustomAppBarrier {...props} />)
Component: CustomAppBarrier,
}),
],
}),
],
// ...
});