Before we start: where are we?
This post assumes you've already done the initial hybrid-mode migration — switching createApp to the new version, wrapping legacy options and your root component with the compatibility helpers, and confirming the app still boots. If you haven't done that yet, follow the official migration guide first and come back. That part is fairly mechanical and the docs cover it well.
Once your app is running in hybrid mode, you have something like this: a FlatRoutes tree that still manually wires up all your routes (including the catalog ones), and a large EntityPage.tsx that manually composes tabs and cards for every entity kind. At this point, convertLegacyAppRoot is doing the heavy lifting of keeping things working while we incrementally replace them.
Step 1: Drop in the new Catalog plugin
The first real NFS(new frontend system) step is to import the catalog plugin from its alpha entrypoint and add it to your createApp features list:
import catalogPlugin from '@backstage/plugin-catalog/alpha';
const app = createApp({
features: [
catalogPlugin,
convertedOptionsModule, // from the hybrid-mode migration
...convertedRootFeatures, // from the hybrid-mode migration
],
});
Once that's in place, you can remove the legacy catalog routes from your FlatRoutes tree:
const routes = (
<FlatRoutes>
{/* remove these two */}
{/* <Route path="/catalog" element={<CatalogIndexPage />} />
<Route
path="/catalog/:namespace/:kind/:name"
element={<CatalogEntityPage />}
>
{entityPage}
</Route> */}
{/* ...the rest of your routes stay for now */}
<Route path="/settings" element={<UserSettingsPage />} />
{/* ... */}
</FlatRoutes>
);
At this point, you can navigate to /catalog and see the index page, and you can click into entity pages too. But the entity page probably looks a bit odd, maybe even broken. That's expected, and it's exactly what we're going to fix.
Step 1.1: Understand what just happened
Here's the key thing to understand about the new frontend system. In the legacy world, plugins handed you a bag of React components — EntityAboutCard, EntityLinksCard, and so on — and it was your job to compose them in EntityPage.tsx. You were the integrator.
In the new system, plugins declare their own extensions and those extensions are added directly to the extension tree. The catalog plugin already ships with extensions for the most common cards:
-
entity-content:catalog/overview— the overview tab for all entities -
entity-card:catalog/about— replacesEntityAboutCard -
entity-card:catalog/links— replacesEntityLinksCard -
entity-card:catalog/labels— replacesEntityLabelsCard - ...and several more
So right now, with convertLegacyAppRoot still processing your old EntityPage.tsx, you're rendering both the new plugin-provided cards and the legacy ones. Duplicate about cards, duplicate links cards — the works.
The same thing happened to the catalog index page: it's now coming entirely from the plugin, which means any customizations you had — custom columns, custom actions, initial filters — are gone for now. We'll address how to bring those back in the Catalog Index Page section.
This is also a good moment to add @backstage/plugin-app-visualizer to your app. Navigate to /visualizer and you'll get a full tree view of every extension in the system — what's enabled, what's disabled, how they're connected. It's invaluable for understanding how all these pieces couple together before you start pulling the legacy code apart.
Step 2: Entity Pages Overview cards
This is the most involved part of the migration. The entity page in a typical Backstage app is a large hand-composed JSX tree — different layouts and tabs per entity kind. In the new frontend system, all of that gets replaced piece by piece with extensions. Here's how.
Step 2.1: Clean up the duplicate cards
Go through your EntityPage.tsx and remove the cards that the catalog plugin now provides natively. For a typical overview page, that might look like this:
const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
{/* Remove entityWarningContent — the default layout now handles this */}
{/* Remove EntityAboutCard — catalog plugin provides entity-card:catalog/about */}
{/* Keep: EntityCatalogGraphCard — not yet provided by catalog plugin */}
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
</Grid>
{/* Keep: your custom cards */}
<Grid item md={6} xs={12}>
<EntityCustomCard />
</Grid>
{/* Remove EntityLinksCard — catalog plugin provides entity-card:catalog/links */}
{/* Remove EntityHasSubcomponentsCard — catalog plugin provides this */}
</Grid>
);
The only items you keep here are the ones that aren't provided by the catalog plugin, cards from other plugins, and anything you built yourself. Things could still look a bit out of order at this point, but that's okay, we will arrange things later.
Step 2.2: Bring in other plugins as NFS features
Some of the cards you kept around are provided by other Backstage plugins, just not yet added in the new way. For example, EntityCatalogGraphCard comes from the catalog-graph plugin. Add it the same way you added the catalog plugin:
import catalogGraph from '@backstage/plugin-catalog-graph/alpha';
const app = createApp({
features: [
catalogPlugin,
catalogGraph, // adds entity-card:catalog-graph/relations and friends
...convertedRootFeatures,
],
});
Then remove the corresponding card from your legacy entity page. Keep doing this for every plugin that has an /alpha NFS export. You'll be surprised how much of your EntityPage.tsx just... evaporates.
If a third-party plugin you depend on doesn't have an NFS release yet, you can try converting it yourself following the plugin conversion guide. And don't forget to ping the maintainers 😄
Step 2.3: Convert your custom cards into extensions
Whatever is left after the plugin cleanup is either a card you built in-house or something that doesn't have an NFS equivalent yet. For in-house cards, you need to create an extension using EntityCardBlueprint from @backstage/plugin-catalog-react/alpha.
Here's a straightforward example:
import { createFrontendModule } from '@backstage/frontend-plugin-api';
import { EntityCardBlueprint } from '@backstage/plugin-catalog-react/alpha';
const customCardExtension = EntityCardBlueprint.make({
name: 'custom-internal-card',
params: {
type: 'info',
filter: entity => entity.kind === 'Component',
loader: async () =>
import('./components/EntityCustomCard').then(m => (
<m.EntityCustomCard />
)),
},
});
const myCustomModule = createFrontendModule({
pluginId: 'catalog',
extensions: [customCardExtension],
});
Then add myCustomModule to your features list in createApp.
The filter prop is how you scope a card to specific entity kinds (or any other condition you want). The type controls where in the layout the card appears: 'info' places the card in the narrow right-hand info sticky column (like the default About and Links cards), while 'content' spans the full width of the main area. When in doubt, check how similar cards look in the /visualizer and match accordingly.
If your card needs configurable props (say, a title the operator can set per-deployment), you can use makeWithOverrides to define a config schema:
import { z } from 'zod';
import { EntityCardBlueprint } from '@backstage/plugin-catalog-react/alpha';
const customEntityCardExtension = EntityCardBlueprint.makeWithOverrides({
name: 'custom-internal-card',
config: {
schema: {
title: z => z.string().optional().describe('Title for the custom card'),
},
},
factory(originalFactory, { config }) {
return originalFactory({
filter: entity => entity.kind === 'Component',
type: 'info',
loader: async () =>
import('./components/EntityCustomCard').then(m => (
<m.EntityCustomCard title={config.title ?? 'Custom Card'} />
)),
});
},
});
Now the card title can be overridden per environment via app-config.yaml — no code change needed.
Step 2.4: Control ordering and per-card config
Once all the cards are coming through extensions, you'll probably notice the order isn't quite what you had before. Extensions render in the order they're declared in your config, so you can control this from app-config.yaml:
app:
extensions:
- "entity-card:catalog-graph/relations":
- "entity-card:catalog/depends-on-components":
disabled: true # you can also disable cards you don't want showing up
- "entity-card:catalog/about"
- "entity-card:catalog/custom-internal-card":
config:
type: content # we can still override config options
title: "Custom Internal Card" # our config option can be set here
A couple of things to note here: you can disabled: true any card you don't want showing up, and you can pass configuration to the cards that expose a config schema (like the one we built with makeWithOverrides). To find the exact extension names, I'd recommend using the /visualizer but basically it's entity-card:${pluginId}/${cardName} for cards, and entity-content:${pluginId}/${contentName} for content extensions.
Step 2.5: Custom layouts per entity kind
The default entity layout looks quite nice, but if you had a completely custom grid arrangement for specific entity kinds, you can replicate that with a layout extension.
First, create your layout component. It receives a cards prop that you can render however you want:
import { EntityContentLayoutProps } from '@backstage/plugin-catalog-react/alpha';
export const CustomEntityLayout = (props: EntityContentLayoutProps) => {
return (
<div>
<h2>Custom Entity Layout</h2>
{props.cards.map((card, index) => (
<div key={index}>{card.element}</div>
))}
</div>
);
};
That's a minimal example — in practice you'd want to replicate the warning/error components that the default layout handles. Take a look at the default layout implementation for inspiration.
Then wrap it in a blueprint and add it to your module:
import { EntityContentLayoutBlueprint } from '@backstage/plugin-catalog-react/alpha';
const componentLayout = EntityContentLayoutBlueprint.make({
name: 'custom-layout',
params: {
filter: entity => entity.kind === 'Component',
loader: async () =>
import('./components/catalog/CustomEntityLayout').then(
m => m.CustomEntityLayout,
),
},
});
The filter here means this layout only applies to Component entities. Any entity kind not matched will fall back to the default layout.
Step 3: Entity Page Tabs and Custom Content
Step 3.1: Custom tabs and content
The entity page overview tab isn't the only thing that lives in EntityPage.tsx. You probably also have tabs for CI/CD, documentation, Kubernetes, and so on. The approach here is the same: if the plugin supporting that tab has an NFS export, import it and remove the tab from the legacy entity page.
For Kubernetes, for example:
import kubernetes from '@backstage/plugin-kubernetes/alpha';
const app = createApp({
features: [
...convertedRootFeatures,
catalogPlugin,
kubernetes, // adds the Kubernetes tab automatically
],
});
Then remove the EntityLayout.Route for Kubernetes from your legacy entity page — the plugin now owns it.
For tabs that don't have an NFS plugin yet, or for truly custom content you built in-house, use EntityContentBlueprint:
import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha';
const customEntityContent = EntityContentBlueprint.make({
name: 'custom-content',
params: {
filter: entity =>
entity.kind === 'Component' && entity.spec?.type === 'website',
path: '/custom',
title: 'Custom Tab',
group: 'deployment',
icon: 'laptop',
loader: async () =>
import('./components/catalog/CustomEntityContent').then(m => (
<m.CustomEntityContent />
)),
},
});
The group and icon fields control how the tab appears in the entity page navigation. Tab ordering and grouping can also be tuned through config — the Backstage docs have the full details.
One thing worth flagging: at the time of writing, I did not find a way to feature-flag a content extension in whole. There's an open discussion about this in the Backstage repo if you need to follow along.
Step 4: Catalog Index Page
With the entity pages sorted, let's look at what we can recover. Many of the props you used to pass directly to <CatalogIndexPage /> now map to config options on their corresponding extensions. For example:
-
initialKind→catalog-filter:catalog/kindconfiginitialFilter -
ownerPickerMode→catalog-filter:catalog/modeconfigmode -
initiallySelectedFilter→catalog-filter:catalog/listconfiginitialFilter -
pagination→page:catalogconfigpagination
So instead of passing those as JSX props, you configure them per extension:
app:
extensions:
- "page:catalog":
config:
pagination:
mode: cursor
limit: 20
- "catalog-filter:catalog/mode":
config:
mode: owners-only
- "catalog-filter:catalog/kind":
config:
initialFilter: system
- "catalog-filter:catalog/list":
config:
initialFilter: all
In the same way we had with cards, the order of the filter entries in the config also controls the order they appear in the UI.
If you had custom filters built in-house, those become extensions too, using CatalogFilterBlueprint:
import { CatalogFilterBlueprint } from '@backstage/plugin-catalog-react/alpha';
const customCatalogFilter = CatalogFilterBlueprint.make({
name: 'custom-catalog-filter',
params: {
loader: async () =>
import('./components/catalog/CustomCatalogFilter').then(m => (
<m.CustomCatalogFilter />
)),
},
});
For more advanced catalog page customizations like custom table columns and action buttons, there aren't dedicated extension points yet. If you need those you can override the whole page extension:
const customCatalogIndexPage = catalogPlugin
.getExtension('page:catalog')
.override({
params: {
path: '/catalog',
loader: async () => (
<CatalogIndexPage
initialKind="Component"
ownerPickerMode="all"
columns={customColumnFunc}
tableOptions={{ search: false }}
/>
),
},
});
This gives you an escape hatch when the standard config options aren't enough.
Wrapping up
The catalog plugin is probably the most involved NFS migration you'll do, it sits at the center of the entity page, it has its own layout system, and it exposes more extension points than most other plugins. That's exactly why I picked it as a learning ground.
The mental shift that helped me most: you're no longer the composer. In the legacy system, you assembled all the pieces in React. In NFS, each plugin brings its own pieces and your job is to configure and override rather than wire. Once that clicks, the whole migration approach starts to click.
Reference
All the code shown in this post is based on a scaffolded Backstage app I set up specifically to test and validate each step of this migration: Sarabadu/backstage-catalog-migration. It's not a production app — just a clean starting point to try things out without breaking anything real. If something in the post isn't clicking, looking at the repository might help.
Did this help?
If you went through this migration yourself, I'd love to hear how it went. Did something not work the way I described? Did you run into edge cases I missed? And what would you like to see covered next — maybe migrating the other plugin, converting a third-party plugin that doesn't have an NFS export yet, or going deeper on writing custom blueprints?
Drop a comment below or reach out directly. The more feedback I get, the more useful the next one can be.
Top comments (0)