Merge branch 'master' into docs/golden-paths-prep-ga

This commit is contained in:
Peter Macdonald
2026-03-25 13:33:20 +01:00
16 changed files with 1746 additions and 12 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Added `showPaginationLabel` prop to `TablePagination` and `useTable` pagination options. When set to `false`, the pagination label (e.g., "1 - 20 of 150") is hidden while navigation controls remain visible. Defaults to `true`.
**Affected components:** `TablePagination`, `useTable`
@@ -520,6 +520,7 @@ toolsets
tooltip
tooltips
touchpoint
touchpoints
transpilation
transpile
transpiled
@@ -50,7 +50,8 @@ export const useTableOptionsPropDefs: Record<string, PropDef> = {
description: (
<>
Pagination configuration including <Chip>pageSize</Chip>,{' '}
<Chip>pageSizeOptions</Chip>, and <Chip>initialOffset</Chip>.
<Chip>pageSizeOptions</Chip>, <Chip>initialOffset</Chip>, and{' '}
<Chip>showPaginationLabel</Chip>.
</>
),
},
@@ -413,6 +414,12 @@ export const tablePaginationPropDefs: Record<string, PropDef> = {
values: ['(props) => string'],
description: 'Custom function to generate the pagination label text.',
},
showPaginationLabel: {
type: 'boolean',
default: 'true',
description:
'Whether to display the pagination label (e.g., "1 - 20 of 150"). When false, only navigation controls are shown.',
},
};
// =============================================================================
@@ -0,0 +1,651 @@
---
name: app-frontend-system-migration
description: 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`:
```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:
```yaml
# Only discover specific packages
app:
packages:
include:
- '@backstage/plugin-catalog'
- '@backstage/plugin-scaffolder'
```
```yaml
# Discover all except specific packages
app:
packages:
exclude:
- '@backstage/plugin-techdocs'
```
### Disabling Individual Extensions
Even with feature discovery enabled, you can disable specific extensions via config without removing the package:
```yaml
app:
extensions:
- page:techdocs: false
- nav-item:search: false
```
### How Discovery Works with Manual Imports
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.
## Phase 1: Minimal Hybrid Migration
### Step 1: Switch `createApp`
Replace the import source for `createApp`:
```typescript
// OLD
import { createApp } from '@backstage/app-defaults';
// NEW
import { createApp } from '@backstage/frontend-defaults';
```
### Step 2: Convert `createApp` options
Use `convertLegacyAppOptions` to wrap legacy options (`apis`, `icons`, `featureFlags`, `components`, `themes`) as a feature:
```tsx
import { createApp } from '@backstage/frontend-defaults';
import { convertLegacyAppOptions } from '@backstage/core-compat-api';
const convertedOptionsModule = convertLegacyAppOptions({
apis,
icons: { alert: AlarmIcon },
featureFlags: [
{
name: 'scaffolder-next-preview',
description: 'Preview the new Scaffolder Next',
pluginId: '',
},
],
components: {
SignInPage: props => (
<SignInPage
{...props}
providers={['guest', 'custom', ...providers]}
title="Select a sign-in method"
align="center"
/>
),
},
});
const app = createApp({
features: [convertedOptionsModule],
});
```
### Step 3: Convert the app root
Use `convertLegacyAppRoot` to convert the entire app element tree (routes, sidebar, root elements) into features:
```tsx
import { convertLegacyAppRoot } from '@backstage/core-compat-api';
const convertedRootFeatures = convertLegacyAppRoot(
<>
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
<VisitListener />
<Root>{routes}</Root>
</AppRouter>
</>,
);
const app = createApp({
features: [convertedOptionsModule, ...convertedRootFeatures],
});
export default app.createRoot();
```
Note: `app.createRoot()` now takes **no arguments** and returns a React **element** (not a component).
### Step 4: Update `index.tsx`
The default export is now an element, not a component:
```typescript
// OLD
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
// NEW
import app from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(app);
```
### Step 5: Update `App.test.tsx`
Same change for the test file:
```typescript
import app from './App';
const rendered = render(app);
```
## Phase 2: Full Migration
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'`:
```typescript
import { createFrontendModule } from '@backstage/frontend-plugin-api';
const app = createApp({
features: [
createFrontendModule({
pluginId: 'app',
extensions: [
lightTheme,
signInPage,
exampleIconBundle,
catalogTranslations,
],
}),
],
});
```
#### APIs → `ApiBlueprint`
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`:
```typescript
import { createApiRef } from '@backstage/frontend-plugin-api';
// Recommended: explicit pluginId makes ownership unambiguous
const myApiRef = createApiRef<MyApi>().with({
id: 'plugin.my-plugin.my-api',
pluginId: 'my-plugin',
});
// Legacy form: ownership inferred from the id string pattern
const 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:
```typescript
import { createFrontendModule, ApiBlueprint } from '@backstage/frontend-plugin-api';
// Core/app-level APIs → module for 'app'
const appApisModule = createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
name: 'scm-integrations',
params: defineParams =>
defineParams({
api: scmIntegrationsApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
}),
}),
],
});
// Overriding a plugin's API → module for THAT plugin
const catalogApiOverride = createFrontendModule({
pluginId: 'catalog',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: catalogApiRef, // id: 'plugin.catalog'
deps: { ... },
factory: ({ ... }) => new CustomCatalogClient({ ... }),
}),
}),
],
});
const app = createApp({
features: [appApisModule, catalogApiOverride],
});
```
**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.
#### Sign-in Page → `SignInPageBlueprint`
```tsx
import { SignInPageBlueprint } from '@backstage/plugin-app-react';
const signInPage = SignInPageBlueprint.make({
params: {
loader: async () => props =>
(
<SignInPage
{...props}
provider={{
id: 'github-auth-provider',
title: 'GitHub',
message: 'Sign in using GitHub',
apiRef: githubAuthApiRef,
}}
/>
),
},
});
```
#### Themes → `ThemeBlueprint`
```tsx
import { ThemeBlueprint } from '@backstage/plugin-app-react';
const customLightTheme = ThemeBlueprint.make({
name: 'custom-light',
params: {
theme: {
id: 'custom-light',
title: 'Light Theme',
variant: 'light',
icon: <LightIcon />,
Provider: ({ children }) => (
<UnifiedThemeProvider theme={customLightTheme} children={children} />
),
},
},
});
```
#### Icons → `IconBundleBlueprint`
Icon bundles attach to the `app` plugin's icons input, so they must be installed via a module for `app`:
```typescript
import { IconBundleBlueprint } from '@backstage/plugin-app-react';
import { createFrontendModule } 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],
}),
],
});
```
#### Translations → `TranslationBlueprint`
Translations attach to the `app` plugin's translations input. Note that `createTranslationMessages` takes a `messages` object with key-value pairs:
```typescript
import { TranslationBlueprint } from '@backstage/plugin-app-react';
import { createTranslationMessages } from '@backstage/frontend-plugin-api';
import { catalogTranslationRef } from '@backstage/plugin-catalog/alpha';
const catalogTranslations = TranslationBlueprint.make({
name: 'catalog-overrides',
params: {
resource: createTranslationMessages({
ref: catalogTranslationRef,
messages: {
'indexPage.title': 'Service directory',
'indexPage.createButtonTitle': 'Register new service',
},
}),
},
});
const app = createApp({
features: [
createFrontendModule({
pluginId: 'app',
extensions: [catalogTranslations],
}),
],
});
```
For adding full language translations, use `createTranslationResource` instead:
```typescript
import { createTranslationResource } from '@backstage/frontend-plugin-api';
const userSettingsTranslations = TranslationBlueprint.make({
name: 'user-settings-zh',
params: {
resource: createTranslationResource({
ref: userSettingsTranslationRef,
translations: {
zh: () => import('./userSettings-zh'),
},
}),
},
});
```
### Migrating Root Elements
Built-in elements like `AlertDisplay`, `OAuthRequestDialog`, and `VisitListener` are provided by the framework automatically. Remove them from `convertLegacyAppRoot`:
```tsx
// Before
const convertedRootFeatures = convertLegacyAppRoot(
<>
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
<VisitListener />
<Root>{routes}</Root>
</AppRouter>
</>,
);
// After
const convertedRootFeatures = convertLegacyAppRoot(routes);
```
Custom root elements use `AppRootElementBlueprint`, and custom wrappers use `AppRootWrapperBlueprint` from `@backstage/plugin-app-react`.
### Migrating the Sidebar
Create a `NavContentBlueprint` extension to replace the legacy `Root` component:
```tsx
import { NavContentBlueprint } from '@backstage/plugin-app-react';
import { createFrontendModule } from '@backstage/frontend-plugin-api';
const SidebarContent = NavContentBlueprint.make({
params: {
component: ({ navItems }) => {
const nav = navItems.withComponent(item => (
<SidebarItem icon={() => item.icon} to={item.href} text={item.title} />
));
return (
<Sidebar>
<SidebarLogo />
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SidebarSearchModal />
</SidebarGroup>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{nav.take('page:catalog')}
{nav.take('page:scaffolder')}
<SidebarDivider />
<SidebarScrollWrapper>
{nav.rest({ sortBy: 'title' })}
</SidebarScrollWrapper>
</SidebarGroup>
</Sidebar>
);
},
},
});
export const navModule = createFrontendModule({
pluginId: 'app',
extensions: [SidebarContent],
});
```
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 `take`n 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:
```tsx
// BEFORE: plugin page as a legacy route
const routes = (
<FlatRoutes>
<Route path="/create" element={<ScaffolderPage />} />
<Route path="/catalog" element={<CatalogIndexPage />} />
</FlatRoutes>
);
// AFTER: just remove the route — discovery handles the rest
const routes = (
<FlatRoutes>
<Route path="/catalog" element={<CatalogIndexPage />} />
</FlatRoutes>
);
```
If you are **not** using feature discovery, you need to manually import and install the new plugin version:
```typescript
import scaffolderPlugin from '@backstage/plugin-scaffolder/alpha';
const app = createApp({
features: [scaffolderPlugin, ...convertedRootFeatures],
});
```
#### All-at-once rule for plugin routes
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.
#### Setting up gradual entity page migration
Pass your entity pages to `convertLegacyAppRoot`:
```typescript
const convertedRootFeatures = convertLegacyAppRoot(routes, { entityPage });
```
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:
1. Remove the catalog routes from `FlatRoutes`:
```tsx
const routes = (
<FlatRoutes>
{/* Remove both catalog routes */}
{/* <Route path="/catalog" element={<CatalogIndexPage />} /> */}
{/* <Route path="/catalog/:namespace/:kind/:name" element={<CatalogEntityPage />}> */}
{/* {entityPage} */}
{/* </Route> */}
<Route path="/create" element={<ScaffolderPage />} />
</FlatRoutes>
);
```
2. Install the catalog plugin explicitly (before the converted features so it takes priority):
```typescript
import catalogPlugin from '@backstage/plugin-catalog/alpha';
const app = createApp({
features: [catalogPlugin, convertedOptionsModule, ...convertedRootFeatures],
});
```
3. 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:
1. **Remove the legacy JSX** from your entity page components (e.g. remove `<EntityAboutCard />`, `<EntityTechdocsContent />`, `<EntityKubernetesContent />`)
2. 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`:
```yaml
app:
extensions:
- page:catalog/entity:
config:
groups:
- overview:
title: Overview
- documentation:
title: Docs
```
#### When is it done?
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:
```yaml
# app-config.yaml
app:
routes:
bindings:
catalog.createComponent: scaffolder.root
```
## Dependencies
| Purpose | Old Package | New Package |
| --------------------- | ---------------------------- | -------------------------------- |
| App creation | `@backstage/app-defaults` | `@backstage/frontend-defaults` |
| Plugin/extension APIs | `@backstage/core-plugin-api` | `@backstage/frontend-plugin-api` |
| App components | `@backstage/core-components` | `@backstage/ui` + CSS Modules |
| Compatibility bridge | — | `@backstage/core-compat-api` |
| App blueprints | — | `@backstage/plugin-app-react` |
## Migration Checklist
### Phase 1 (Hybrid)
1. [ ] Add `@backstage/frontend-defaults` and `@backstage/core-compat-api` dependencies
2. [ ] Switch `createApp` import to `@backstage/frontend-defaults`
3. [ ] Enable feature discovery: add `app.packages: all` to `app-config.yaml`
4. [ ] Wrap legacy options with `convertLegacyAppOptions`
5. [ ] Wrap app element tree with `convertLegacyAppRoot`
6. [ ] Change `app.createRoot()` to take no arguments
7. [ ] Update `index.tsx` to render element instead of component
8. [ ] Update `App.test.tsx`
9. [ ] Verify app starts and works in hybrid mode
### Phase 2 (Full Migration)
1. [ ] Convert APIs to `ApiBlueprint` extensions
2. [ ] Convert sign-in page to `SignInPageBlueprint`
3. [ ] Convert themes to `ThemeBlueprint`
4. [ ] Convert icons to `IconBundleBlueprint`
5. [ ] Convert translations to `TranslationBlueprint`
6. [ ] Migrate sidebar to `NavContentBlueprint`
7. [ ] Remove built-in root elements (`AlertDisplay`, `OAuthRequestDialog`, etc.)
8. [ ] Migrate routes from `FlatRoutes` to plugin features (one plugin at a time, removing all routes + entity content for each plugin together)
9. [ ] Set up entity page migration with `convertLegacyAppRoot(routes, { entityPage })`
10. [ ] Migrate catalog plugin: remove catalog routes from `FlatRoutes`, install `catalogPlugin` as a feature
11. [ ] Gradually remove legacy entity card/content JSX as each contributing plugin is migrated
12. [ ] Remove `entityPage` option and legacy entity page component files
13. [ ] Remove `convertLegacyAppRoot` and `convertLegacyAppOptions` calls
14. [ ] Remove `@backstage/app-defaults`, `@backstage/core-app-api` dependencies
15. [ ] Run `yarn tsc` and `yarn lint` to verify
## Troubleshooting
- Install `@backstage/plugin-app-visualizer` to inspect the extension tree at `/visualizer`
- Duplicate entity cards: remove legacy card JSX from entity pages — plugins auto-provide them
- `Invalid element inside FlatRoutes`: push `FeatureFlagged`/`RequirePermissions` wrappers into plugin code instead of the route table
## Reference
- [App migration guide](https://backstage.io/docs/frontend-system/building-apps/migrating)
- [Architecture overview](https://backstage.io/docs/frontend-system/architecture/index)
- [Extension blueprints](https://backstage.io/docs/frontend-system/building-plugins/common-extension-blueprints)
- [Installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins)
+15
View File
@@ -4,6 +4,21 @@
"name": "mui-to-bui-migration",
"description": "Migrate Backstage plugins from Material-UI (MUI) to Backstage UI (BUI). Use this skill when migrating components, updating imports, replacing styling patterns, or converting MUI components to their BUI equivalents.",
"files": ["SKILL.md"]
},
{
"name": "app-frontend-system-migration",
"description": "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.",
"files": ["SKILL.md"]
},
{
"name": "plugin-new-frontend-system-support",
"description": "Add new frontend system support to an existing Backstage plugin while keeping the old system working. Use this skill for published or shared plugins that need to work in both old and new frontend system apps.",
"files": ["SKILL.md"]
},
{
"name": "plugin-full-frontend-system-migration",
"description": "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.",
"files": ["SKILL.md"]
}
]
}
@@ -43,7 +43,7 @@ Before starting migration:
- `Checkbox` - Checkbox input
- `Dialog` - Modal dialogs (`DialogTrigger`, `Dialog`, `DialogHeader`, `DialogBody`, `DialogFooter`)
- `Header` - Page headers
- `HeaderPage` - Full page header component
- `Header` - Full page header component
- `Link` - Navigation links
- `Menu` - Dropdown menus (`MenuTrigger`, `Menu`, `MenuItem`)
- `PasswordField` - Password input field
@@ -0,0 +1,545 @@
---
name: plugin-full-frontend-system-migration
description: 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.
## 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` |
| Internal routing | May keep legacy `<Route>` trees in components | Replaced with `SubPageBlueprint` tabbed pages |
| Compatibility | Not needed | Not needed |
## Step 1: Migrate Route Refs
Replace `createRouteRef` / `createSubRouteRef` / `createExternalRouteRef` imports:
```typescript
// OLD (src/routes.ts)
import {
createRouteRef,
createSubRouteRef,
createExternalRouteRef,
} from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({ id: 'my-plugin' });
export const detailsRouteRef = createSubRouteRef({
id: 'my-plugin-details',
parent: rootRouteRef,
path: '/details/:id',
});
export const externalDocsRouteRef = createExternalRouteRef({ id: 'docs' });
// NEW (src/routes.ts)
import {
createRouteRef,
createSubRouteRef,
createExternalRouteRef,
} from '@backstage/frontend-plugin-api';
export const rootRouteRef = createRouteRef();
export const detailsRouteRef = createSubRouteRef({
path: '/details/:id',
parent: rootRouteRef,
});
export const externalDocsRouteRef = createExternalRouteRef({
defaultTarget: 'techdocs.docRoot',
});
```
Key differences:
- `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:
```typescript
export const createComponentRouteRef = createExternalRouteRef({
defaultTarget: 'scaffolder.root',
});
export const viewTechDocRouteRef = createExternalRouteRef({
params: ['namespace', 'kind', 'name'],
defaultTarget: 'techdocs.docRoot',
});
export const catalogEntityRouteRef = createExternalRouteRef({
params: ['namespace', 'kind', 'name'],
defaultTarget: 'catalog.catalogEntity',
});
```
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:
```tsx
// NEW (src/plugin.tsx)
import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
import { RiToolsLine } from '@remixicon/react';
import { rootRouteRef, externalDocsRouteRef } from './routes';
import { myPage } from './extensions';
import { myPluginApi } from './apis';
export default createFrontendPlugin({
pluginId: 'my-plugin',
title: 'My Plugin',
icon: <RiToolsLine />,
info: {
packageJson: () => import('../package.json'),
},
routes: {
root: rootRouteRef,
},
externalRoutes: {
docs: externalDocsRouteRef,
},
extensions: [myPluginApi, myPage],
});
```
For the plugin `icon`, prefer using [Remix Icons](https://remixicon.com/) 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:
```json
{
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
}
}
```
```typescript
// src/index.ts
export { default } from './plugin';
export { rootRouteRef } from './routes';
```
## Step 3: Migrate API Factories to `ApiBlueprint`
```typescript
// OLD
import {
createApiFactory,
discoveryApiRef,
fetchApiRef,
} from '@backstage/core-plugin-api';
export const myApiFactory = createApiFactory({
api: myPluginApiRef,
deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
factory: ({ discoveryApi, fetchApi }) =>
new MyPluginClient({ discoveryApi, fetchApi }),
});
// NEW (src/apis.ts)
import {
ApiBlueprint,
discoveryApiRef,
fetchApiRef,
} from '@backstage/frontend-plugin-api';
import { myPluginApiRef } from './api';
export const myPluginApi = ApiBlueprint.make({
params: defineParams =>
defineParams({
api: myPluginApiRef,
deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
factory: ({ discoveryApi, fetchApi }) =>
new MyPluginClient({ discoveryApi, fetchApi }),
}),
});
```
Also update the API ref creation to the new builder pattern with explicit `pluginId`:
```typescript
// OLD
import { createApiRef } from '@backstage/core-plugin-api';
export const myPluginApiRef = createApiRef<MyPluginApi>({
id: 'plugin.my-plugin.client',
});
// NEW (recommended builder pattern with explicit pluginId)
import { createApiRef } from '@backstage/frontend-plugin-api';
export const myPluginApiRef = createApiRef<MyPluginApi>().with({
id: 'plugin.my-plugin.client',
pluginId: 'my-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:
1. The explicit `pluginId` on the `ApiRef` (if set via the builder pattern)
2. 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.
## Step 4: Migrate Pages to `PageBlueprint`
### Simple Page (No Sub-Routes)
```tsx
// src/extensions.tsx
import { PageBlueprint } from '@backstage/frontend-plugin-api';
import { rootRouteRef } from './routes';
export const myPage = PageBlueprint.make({
params: {
path: '/my-plugin',
routeRef: rootRouteRef,
loader: () => import('./components/MyPage').then(m => <m.MyPage />),
},
});
```
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`:
```tsx
// src/components/MyPage/MyPage.tsx
import { Header } from '@backstage/ui';
import { Content } from '@backstage/core-components';
export function MyPage() {
return (
<>
<Header
title="Subtitle or description"
customActions={
<>
<CreateButton title="Create" to="/my-plugin/create" />
<SupportButton>Help text</SupportButton>
</>
}
/>
<Content>
<MyPageContent />
</Content>
</>
);
}
```
### Page Without Header
For pages that manage their own layout entirely (e.g. home page, dashboards), set `noHeader: true`:
```tsx
export const myPage = PageBlueprint.make({
params: {
path: '/my-plugin',
routeRef: rootRouteRef,
noHeader: true,
loader: () => import('./components/MyPage').then(m => <m.MyPage />),
},
});
```
## Step 5: Replace Internal Routing with Sub-Pages
This is one of the biggest changes in a full migration. Old plugins often use React Router `<Route>` trees inside a router component to handle internal navigation. The new system replaces this with `SubPageBlueprint` for tabbed sub-pages.
### Old Pattern: Internal Router
```tsx
// OLD — plugin owns its own routing
import { Route, Routes } from 'react-router-dom';
export function MyPluginRouter() {
return (
<Page themeId="tool">
<Header title="My Plugin" />
<HeaderTabs
tabs={[
{ id: 'overview', label: 'Overview' },
{ id: 'settings', label: 'Settings' },
]}
/>
<Content>
<Routes>
<Route path="/" element={<OverviewPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Content>
</Page>
);
}
```
### New Pattern: `PageBlueprint` + `SubPageBlueprint`
```tsx
// src/extensions.tsx
import {
PageBlueprint,
SubPageBlueprint,
} from '@backstage/frontend-plugin-api';
// Parent page WITHOUT a loader — uses built-in tabbed rendering
export const myPluginPage = PageBlueprint.make({
params: {
path: '/my-plugin',
routeRef: rootRouteRef,
},
});
export const overviewSubPage = SubPageBlueprint.make({
name: 'overview',
params: {
path: 'overview',
title: 'Overview',
loader: () =>
import('./components/OverviewPage').then(m => <m.OverviewPageContent />),
},
});
export const settingsSubPage = SubPageBlueprint.make({
name: 'settings',
params: {
path: 'settings',
title: 'Settings',
loader: () =>
import('./components/SettingsPage').then(m => <m.SettingsPageContent />),
},
});
```
How this works:
- `PageBlueprint` **without a `loader`** automatically renders its sub-pages as tabs
- The first sub-page becomes the default (index redirect)
- Each `SubPageBlueprint` gets a tab in the header with its `title`
- Sub-page `path` values are **relative** (no leading `/`)
- Sub-page components render **content only** — no `Page`, `Header`, or `HeaderTabs`
If the sub-page content needs padding, use `Container` from `@backstage/ui` as a wrapper inside the component.
### When NOT to Use Sub-Pages
Not all internal routing maps to tabs. 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 `PageBlueprint` `loader` when:
- Routes are detail/drill-down pages (e.g. `/my-plugin/items/:id`)
- The routing is deeply nested or dynamic
In those cases, use a `PageBlueprint` **with** a `loader` that handles its own `Routes`:
```tsx
export const myPage = PageBlueprint.make({
params: {
path: '/my-plugin',
routeRef: rootRouteRef,
loader: () => import('./components/Router').then(m => <m.MyPluginRouter />),
},
});
```
## Step 6: Update Hooks and Imports
Replace all `@backstage/core-plugin-api` imports with `@backstage/frontend-plugin-api`:
```typescript
// OLD
import { useApi, useRouteRef, configApiRef } from '@backstage/core-plugin-api';
// NEW
import {
useApi,
useRouteRef,
configApiRef,
} from '@backstage/frontend-plugin-api';
```
### `useRouteRef` Behavior Change
In the new system, `useRouteRef` may return `undefined` for external route refs that aren't bound. Handle this:
```typescript
// OLD — throws if not bound
const docsLink = useRouteRef(externalDocsRouteRef);
// Always a function
// NEW — returns undefined if not bound
const 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
1. Delete `src/plugin.ts` (old `createPlugin`)
2. Delete any `createRoutableExtension` / `createComponentExtension` usage
3. Remove `Page`, `Header`, `PageWithHeader` wrapping from page components
4. Remove `HeaderTabs` if replaced by `SubPageBlueprint` tabs
5. Remove internal `<Routes>`/`<Route>` trees if replaced by sub-pages
6. Remove `@backstage/core-plugin-api` from `package.json` `dependencies`
7. Remove `@backstage/core-compat-api` from `package.json` `dependencies` 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:
```tsx
// plugins/auth/src/routes.ts
import { createRouteRef } from '@backstage/frontend-plugin-api';
export const rootRouteRef = createRouteRef();
// plugins/auth/src/plugin.tsx
import {
createFrontendPlugin,
PageBlueprint,
} from '@backstage/frontend-plugin-api';
import { rootRouteRef } from './routes';
export const AuthPage = PageBlueprint.make({
params: {
path: '/oauth2',
routeRef: rootRouteRef,
loader: () => import('./components/Router').then(m => <m.Router />),
},
});
export default createFrontendPlugin({
pluginId: 'auth',
extensions: [AuthPage],
routes: {
root: rootRouteRef,
},
});
```
## Real Example: Scaffolder Sub-Pages
The scaffolder plugin demonstrates the sub-page pattern (though it still has dual support — the pattern itself is what a full migration targets):
```tsx
// PageBlueprint WITHOUT loader — framework renders tabs
export const scaffolderPage = PageBlueprint.make({
params: {
path: '/create',
routeRef: rootRouteRef,
},
});
// Sub-pages with content only
export const templatesSubPage = SubPageBlueprint.make({
name: 'templates',
params: {
path: 'templates',
title: 'Templates',
loader: () => import('./TemplatesPage').then(m => <m.TemplatesSubPage />),
},
});
export const tasksSubPage = SubPageBlueprint.make({
name: 'tasks',
params: {
path: 'tasks',
title: 'Tasks',
loader: () => import('./TasksPage').then(m => <m.TasksSubPage />),
},
});
```
## Migration Checklist
1. [ ] Migrate route refs to `@backstage/frontend-plugin-api` (`createRouteRef`, `createSubRouteRef`, `createExternalRouteRef`)
2. [ ] Replace `createPlugin` with `createFrontendPlugin`
3. [ ] Convert all API factories to `ApiBlueprint` extensions
4. [ ] Convert pages to `PageBlueprint`
5. [ ] Replace internal tab routing with `SubPageBlueprint` where appropriate
6. [ ] Remove `Page`/`Header`/`PageWithHeader` from page components
7. [ ] Add `Header` from `@backstage/ui` where subtitle/custom actions are needed
8. [ ] Replace `HeaderTabs` with `SubPageBlueprint` tabs
9. [ ] Update all `@backstage/core-plugin-api` imports to `@backstage/frontend-plugin-api`
10. [ ] Handle `useRouteRef` possibly returning `undefined`
11. [ ] Remove `src/plugin.ts` (old system entry point)
12. [ ] Remove `src/alpha.tsx` if it existed (merge into main entry)
13. [ ] Remove `@backstage/core-plugin-api` from `package.json` dependencies
14. [ ] Remove `@backstage/core-compat-api` from `package.json` dependencies
15. [ ] Update `package.json` exports (remove `./alpha` if merged into main)
16. [ ] Run `yarn tsc` to check for type errors
17. [ ] Run `yarn lint` to check for missing dependencies
18. [ ] Run `yarn build:api-reports` to update API reports (if the project uses API reports)
19. [ ] Test in a new-system app (`packages/app`)
## Reference
- [Plugin migration guide](https://backstage.io/docs/frontend-system/building-plugins/migrating)
- [Extension blueprints](https://backstage.io/docs/frontend-system/building-plugins/common-extension-blueprints)
- [Utility APIs](https://backstage.io/docs/frontend-system/utility-apis/creating)
- MUI to BUI migration: `mui-to-bui-migration` skill
@@ -0,0 +1,425 @@
---
name: plugin-new-frontend-system-support
description: Add new frontend system support to an existing Backstage plugin while keeping the old system working. Use this skill for published or shared plugins that need to work in both old and new frontend system apps.
---
# Adding New Frontend System Support to an Existing Plugin
This skill helps add new frontend system (NFS) support to an existing Backstage plugin while keeping the old system fully functional. The result is a plugin that works in both old and new apps via a dual entry point pattern.
This is the preferred approach for published plugins or plugins that are used by external parties, since it avoids forcing consumers to migrate their app before they are ready.
## Key Concepts
- **Dual entry point:** The plugin keeps its existing `src/plugin.ts` (old system) and adds a new `src/alpha.tsx` (new system)
- **Old system:** `createPlugin` from `@backstage/core-plugin-api`, pages via `createRoutableExtension`, routes defined in the app
- **New system:** `createFrontendPlugin` from `@backstage/frontend-plugin-api`, pages via `PageBlueprint`, routes owned by the plugin
- **Dual header pattern:** Old system uses `Page`/`Header`/`PageWithHeader` from `@backstage/core-components`; new system relies on the framework's `PageLayout` which renders `PluginHeader` from `@backstage/ui` — so NFS page components should NOT include their own page shell
## Step 1: Create the Alpha Entry Point
Create `src/alpha.tsx` (or `src/alpha/index.ts` for larger plugins) with a `createFrontendPlugin` default export:
```tsx
// src/alpha.tsx
import {
createFrontendPlugin,
PageBlueprint,
} from '@backstage/frontend-plugin-api';
import { RiToolsLine } from '@remixicon/react';
import { rootRouteRef } from './routes';
const myPage = PageBlueprint.make({
params: {
path: '/my-plugin',
routeRef: rootRouteRef,
loader: () => import('./components/MyPage').then(m => <m.NfsMyPage />),
},
});
export default createFrontendPlugin({
pluginId: 'my-plugin',
title: 'My Plugin',
icon: <RiToolsLine />,
extensions: [myPage],
routes: {
root: rootRouteRef,
},
externalRoutes: {
// same external routes as the old plugin
},
});
```
For the plugin `icon`, prefer using [Remix Icons](https://remixicon.com/) 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.
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.
For larger plugins, organize into `src/alpha/plugin.tsx`, `src/alpha/pages.tsx`, `src/alpha/extensions.tsx`, etc., and re-export from `src/alpha/index.ts`.
## Step 2: Update `package.json` Exports
Add the `./alpha` subpath export and its `typesVersions` entry:
```json
{
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.tsx",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": ["src/alpha.tsx"],
"package.json": ["package.json"]
}
}
}
```
Apps import the new plugin as:
```typescript
import myPlugin from '@backstage/plugin-my-plugin/alpha';
```
## Step 3: Implement the Dual Header Pattern
The critical difference between old and new system page components is the **page shell**. In the old system, each page renders its own `Page` + `Header` (or `PageWithHeader`) wrapper. In the new system, the framework's `PageLayout` provides the header via `PluginHeader` automatically — so the NFS page component must **not** include its own page shell.
### Pattern A: Separate Components (Recommended for Simple Pages)
Create two exported components — one for each system:
```tsx
// src/components/MyPage/MyPage.tsx
import {
Content,
PageWithHeader,
ContentHeader,
SupportButton,
} from '@backstage/core-components';
import { Header } from '@backstage/ui';
// Used by the OLD system — includes the full page shell
export function MyPage() {
return (
<PageWithHeader title="My Plugin" themeId="tool">
<Content>
<ContentHeader title="">
<SupportButton>Some help text</SupportButton>
</ContentHeader>
<MyPageContent />
</Content>
</PageWithHeader>
);
}
// Used by the NEW system — no page shell, just content
// The framework's PageLayout/PluginHeader provides the title and header
export function NfsMyPage() {
return (
<>
<Header
title="My Plugin Subtitle"
customActions={<SupportButton>Some help text</SupportButton>}
/>
<Content>
<MyPageContent />
</Content>
</>
);
}
```
Key differences in the NFS variant:
- **No `Page`/`PageWithHeader`** — the framework provides the outer page shell
- **`Header` from `@backstage/ui`** is optional — use it only if you need a subtitle or custom actions below the framework header
- **No `ContentHeader`** — actions move to `Header`'s `customActions` prop
- The shared `<MyPageContent />` component contains the actual page body
### Forwarding Customization Props
If the old system exports a page component with props for customization (e.g. `<CatalogIndexPage actions={...} filters={...} />`), the NFS variant should accept the same props. Export the NFS variant with the same component name from the `./alpha` entry point, so that app adopters can customize it the same way:
```tsx
// src/components/MyPage/MyPage.tsx
export interface MyPageProps {
actions?: ReactNode;
filters?: ReactNode;
}
// Old system — exported from src/index.ts
export function MyPage(props: MyPageProps) {
return (
<PageWithHeader title="My Plugin" themeId="tool">
<Content>
<MyPageContent {...props} />
</Content>
</PageWithHeader>
);
}
// NFS variant — exported from src/alpha.tsx
export function NfsMyPage(props: MyPageProps) {
return (
<Content>
<MyPageContent {...props} />
</Content>
);
}
```
The NFS variant is then wired into the `PageBlueprint` loader, and the component itself is re-exported from `./alpha` so adopters can use `.withOverrides()` to pass custom props:
```typescript
// src/alpha.tsx
export { NfsMyPage as MyPage } from './components/MyPage';
```
This way, the old `MyPage` is available from the main entry point, and the same name `MyPage` is available from `./alpha` — both accepting the same props for customization.
### Pattern B: Header Variant Prop (Recommended for Complex Pages)
For pages with significant shared logic, use a `headerVariant` prop pattern:
```tsx
// src/components/MyPage/MyPage.tsx
import { Content, PageWithHeader } from '@backstage/core-components';
function MyPageContent(
props: MyPageProps & { headerVariant: 'legacy' | 'bui' },
) {
const { headerVariant, ...rest } = props;
// ... shared page logic, data fetching, etc.
const pageContent = <Content>{/* shared page body */}</Content>;
if (headerVariant === 'bui') {
return pageContent;
}
return (
<PageWithHeader title="My Plugin" themeId="tool">
{pageContent}
</PageWithHeader>
);
}
// Old system export
export const MyPage = (props: MyPageProps) => (
<MyPageContent {...props} headerVariant="legacy" />
);
// New system export
export const NfsMyPage = (props: MyPageProps) => (
<MyPageContent {...props} headerVariant="bui" />
);
```
### Pattern C: Content-Only Sub-Pages (For Tabbed Plugins)
When using `SubPageBlueprint` for tabbed pages, sub-page loaders should render only the content — the parent `PageBlueprint` provides the header and tabs:
```tsx
// src/alpha/extensions.tsx
import {
PageBlueprint,
SubPageBlueprint,
} from '@backstage/frontend-plugin-api';
export const myPluginPage = PageBlueprint.make({
params: {
path: '/my-plugin',
routeRef: rootRouteRef,
},
});
export const overviewSubPage = SubPageBlueprint.make({
name: 'overview',
params: {
path: 'overview',
title: 'Overview',
loader: () =>
import('../components/OverviewPage').then(m => <m.OverviewPageContent />),
},
});
export const settingsSubPage = SubPageBlueprint.make({
name: 'settings',
params: {
path: 'settings',
title: 'Settings',
loader: () =>
import('../components/SettingsPage').then(m => <m.SettingsPageContent />),
},
});
```
Note: when using `SubPageBlueprint`, omit the `loader` from `PageBlueprint` to use the built-in tabbed sub-page rendering. The `PageBlueprint` without a `loader` creates a parent page that renders sub-pages as tabs automatically. If the sub-page content needs padding, use `Container` from `@backstage/ui` as a wrapper inside the component.
## Step 4: Migrate APIs to `ApiBlueprint`
APIs that were part of the old `createPlugin({ apis: [...] })` become `ApiBlueprint` extensions added to the plugin's `extensions` array.
### API Ownership
In the new system, each API has an **owner plugin** that controls who can provide or override it. Ownership can be set explicitly via `pluginId` on the `ApiRef` (recommended), or inferred from the `ApiRef` ID string pattern:
- Explicit `pluginId` on the ref → that plugin owns it
- `plugin.<pluginId>.*` ID → owned by that plugin
- `core.*` ID → owned by the `app` plugin
The recommended way to define API refs in the new system uses the builder pattern with an explicit `pluginId`:
```typescript
// In your -react package
import { createApiRef } from '@backstage/frontend-plugin-api';
export const myPluginApiRef = createApiRef<MyPluginApi>().with({
id: 'plugin.my-plugin.client',
pluginId: 'my-plugin',
});
```
When your plugin provides an `ApiBlueprint` in its `extensions` array, the extension is automatically namespaced under your plugin — so the ownership is correct by default:
```typescript
// src/alpha/apis.ts
import {
ApiBlueprint,
discoveryApiRef,
fetchApiRef,
} from '@backstage/frontend-plugin-api';
import { myPluginApiRef } from '@internal/plugin-my-plugin-react';
import { MyPluginClient } from '../api';
export const myPluginApi = ApiBlueprint.make({
params: defineParams =>
defineParams({
api: myPluginApiRef,
deps: {
discoveryApi: discoveryApiRef,
fetchApi: fetchApiRef,
},
factory: ({ discoveryApi, fetchApi }) =>
new MyPluginClient({ discoveryApi, fetchApi }),
}),
});
```
Add the API extension to the plugin's `extensions` array.
App adopters who want to override your plugin's API must do so using a `createFrontendModule` targeting your plugin's ID — they cannot override it from a module for a different plugin.
## Step 5: Route Refs
### Reusing Existing Route Refs
Route refs defined using `createRouteRef` from `@backstage/core-plugin-api` can be used directly in the new system — no conversion needed. They work when passed to `createFrontendPlugin`'s `routes`/`externalRoutes` and to `PageBlueprint`'s `routeRef` param:
```tsx
// routes.ts — keep using your existing route refs from @backstage/core-plugin-api
import { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({ id: 'my-plugin' });
// alpha.tsx — pass them directly, no conversion needed
const myPage = PageBlueprint.make({
params: {
path: '/my-plugin',
routeRef: rootRouteRef,
loader: () => import('./MyPage').then(m => <m.NfsMyPage />),
},
});
```
There is no need for `convertLegacyRouteRef` or `compatWrapper` from `@backstage/core-compat-api` — these are no longer required for plugin migration.
### Default Targets for External Route Refs
When adding new-system support, set `defaultTarget` on your external route refs so that apps don't need explicit route bindings for common cases. The target string uses the `<pluginId>.<routeName>` format, matching the `routes` map of the target plugin. The default is only used when the target plugin is actually installed — otherwise the route remains unbound.
```typescript
// routes.ts
import { createExternalRouteRef } from '@backstage/core-plugin-api';
export const viewTechDocRouteRef = createExternalRouteRef({
id: 'view-techdoc',
optional: true,
params: ['namespace', 'kind', 'name'],
defaultTarget: 'techdocs.docRoot',
});
export const createComponentRouteRef = createExternalRouteRef({
id: 'create-component',
optional: true,
defaultTarget: 'scaffolder.root',
});
```
This significantly improves the out-of-the-box experience — plugins with sensible defaults "just work" when installed without requiring the app to configure `bindRoutes`.
### `useRouteRef` Behavior Difference
In the new system, `useRouteRef` from `@backstage/frontend-plugin-api` may return `undefined` for unbound external routes. Legacy `useRouteRef` from `@backstage/core-plugin-api` throws an error instead. When writing NFS components, handle the `undefined` case.
## Step 6: Translations
If the plugin uses translations, the translation ref should be exported from the main entry point (`src/index.ts`). There is no need to re-export it from `./alpha` — consumers import translation refs from the main entry point regardless of which frontend system they use.
The same applies to other refs like API refs and route refs: keep them exported from the main entry point (or the `-react` package) and avoid duplicating exports in `./alpha`.
## Real Examples from the Backstage Repo
### Catalog Plugin (Dual Entry Point)
- Old: `plugins/catalog/src/plugin.ts``createPlugin` with `createRoutableExtension`
- New: `plugins/catalog/src/alpha/plugin.tsx``createFrontendPlugin` with `PageBlueprint`
- Header split: `plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx`
- `BaseCatalogPage` (old) uses `PageWithHeader` + `ContentHeader`
- `NfsBaseCatalogPage` (new) uses `Header` from `@backstage/ui` + `Content`
### Scaffolder Plugin (Sub-Pages)
- Old: `plugins/scaffolder/src/plugin.tsx` — single `ScaffolderPage` with internal routing
- New: `plugins/scaffolder/src/alpha/extensions.tsx``PageBlueprint` (no loader) + multiple `SubPageBlueprint` entries for templates, tasks, actions, editor
- Sub-page loaders wrap content in `<Content>` only — no page shell
### Notifications Plugin (Header Variant Pattern)
- `plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx`
- Uses `headerVariant: 'legacy' | 'bui'` prop
- `NfsNotificationsPage` returns content only (no `PageWithHeader`)
- `NotificationsPage` wraps in `PageWithHeader`
### API Docs Plugin (Simple Dual Page)
- `plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.tsx`
- `DefaultApiExplorerPage` (old) uses `PageWithHeader` + `ContentHeader`
- `NfsApiExplorerPage` (new) uses `Header` + `Content`
## Migration Checklist
1. [ ] Create `src/alpha.tsx` (or `src/alpha/` directory) with `createFrontendPlugin`
2. [ ] Add `./alpha` to `package.json` `exports` and `typesVersions`
3. [ ] Create `PageBlueprint` for each top-level page
4. [ ] Create `SubPageBlueprint` for tabbed sub-pages (if applicable)
5. [ ] Convert API factories to `ApiBlueprint` extensions
6. [ ] Implement NFS page variants without page shell (`Page`/`Header`/`PageWithHeader`)
7. [ ] Use `Header` from `@backstage/ui` for subtitle/custom actions in NFS pages
8. [ ] Wire route refs (existing `@backstage/core-plugin-api` refs work directly, no conversion needed)
9. [ ] Ensure translation refs and API refs are exported from the main entry point (not duplicated in `./alpha`)
10. [ ] Add `@backstage/frontend-plugin-api` to `package.json` dependencies
11. [ ] Add `@backstage/ui` to dependencies (if using `Header`)
12. [ ] Run `yarn tsc` to check for type errors
13. [ ] Run `yarn lint` to check for missing dependencies
14. [ ] Test in both old app (`packages/app-legacy`) and new app (`packages/app`)
15. [ ] Run `yarn build:api-reports` to update API reports (if the project uses API reports)
## Reference
- [Plugin migration guide](https://backstage.io/docs/frontend-system/building-plugins/migrating)
- [Extension blueprints](https://backstage.io/docs/frontend-system/building-plugins/common-extension-blueprints)
- [Utility APIs](https://backstage.io/docs/frontend-system/utility-apis/creating)
@@ -5,14 +5,82 @@ title: Plugin Ownership
description: How to establish and manage plugin ownership across your organization
---
You're now well on your way to a healthy Backstage instance! It's been launched to the whole company and you're loving the feedback developers are giving you. Some developers have even started broaching writing their own plugins.
You're now well on your way to a healthy Backstage instance! It's been launched to the whole company and you're loving the feedback developers are giving you. Some developers have even started expressing interest in writing their own plugins.
## Inner source
Accepting internal contributions from other teams is a good sign that you are on the road to a developer portal tailored for your developers. This is a well paved path with many upsides, but a few downsides as well. As your Backstage instance grows in size and age, those same developers may be difficult to find. Your team may start to experience more and more struggle updating Backstage.
Accepting internal contributions from other teams is a good sign that you are on the road to a developer portal tailored for your developers. This is a well-paved path with many upsides, but a few downsides as well. As your Backstage instance grows in size and age, those same developers may be difficult to find and your team may start to experience more friction updating Backstage.
<!--TODO-->
### Readiness before you start
## Registering Plugins in Your Catalog
Inner sourcing is a genuinely rewarding initiative, but it works best when some
groundwork is already in place. Jumping into it too early can create unnecessary
friction for both the contributing teams and the team that owns Backstage.
<!--TODO-->
Before inviting contributions, its a good idea to have the following in place:
- **A consistent starting point for new plugins.** Running `yarn new` out of the
box will scaffold a working Backstage plugin, which is a great starting point.
However, the CLI also supports
[custom templates](../../tooling/cli/04-templates.md#creating-your-own-cli-templates)
that you define yourself. With a custom template you can bake in your
company's best practices, coding conventions, and shared components from the
start, so every plugin begins from a baseline that already meets your
standards rather than having contributors piece that together themselves.
- **Style guides.** Document the coding conventions your plugins follow. This
includes TypeScript standards, test coverage expectations, and naming
conventions.
- **UI and UX guidelines.** Contributors should know how their plugin is
expected to look and behave within Backstage. Share your design system, any
component libraries you use, and patterns for things like loading states and
error handling.
Sharing these resources with a contributing team before they start is far more
effective than reviewing them in a pull request after the fact.
### Have a conversation before a team starts building
When a team comes to you wanting to build a plugin, that is a great sign. Take
some time to chat with them about what they have in mind. This is not a review
process, it is a chance to understand what they are building and make sure you
can set them up for success.
Some useful things to explore together:
- What does the plugin do, and what problem does it solve?
- Is this something specific to their team, or would it be useful to a broader
audience across the organization?
- How do they plan to keep it maintained over time?
These conversations give you the context you need to point them toward the right
starting template, flag any overlap with existing plugins, and think about where
it fits.
## Registering plugins in your catalog
Once a team has built a plugin and it is ready for wider use, they should register it in the Software Catalog. This makes the plugin discoverable, gives
it a clear owner, and surfaces any dependencies that other teams need to be
aware of.
### Fill out catalog-info.yaml completely
When the plugin is registered in the catalog, the contributing team should make
sure their `catalog-info.yaml` is complete. At a minimum this means listing
themselves as the owner and documenting any dependencies on backend APIs,
external services, or other Backstage plugins. This gives other teams a clear
point of contact for questions or feedback, and an accurate picture of what the
plugin depends on.
```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: my-plugin
description: A short description of what this plugin does.
spec:
type: plugin
lifecycle: production
owner: team-name
dependsOn:
- component:my-backend-api
```
+5
View File
@@ -1959,6 +1959,7 @@ export interface PaginationOptions
| 'onPreviousPage'
| 'showPageSizeOptions'
| 'getLabel'
| 'showPaginationLabel'
>
> {
// (undocumented)
@@ -2618,6 +2619,9 @@ export const TablePaginationDefinition: {
readonly default: true;
};
readonly getLabel: {};
readonly showPaginationLabel: {
readonly default: true;
};
};
};
@@ -2638,6 +2642,7 @@ export type TablePaginationOwnProps = {
offset?: number;
totalCount?: number;
}) => string;
showPaginationLabel?: boolean;
};
// @public (undocumented)
@@ -272,6 +272,7 @@ export function Table<T extends TableItem>({
onPageSizeChange={pagination.onPageSizeChange}
showPageSizeOptions={pagination.showPageSizeOptions}
getLabel={pagination.getLabel}
showPaginationLabel={pagination.showPaginationLabel}
/>
)}
</div>
@@ -56,6 +56,7 @@ export interface PaginationOptions
| 'onPreviousPage'
| 'showPageSizeOptions'
| 'getLabel'
| 'showPaginationLabel'
>
> {
initialOffset?: number;
@@ -41,6 +41,7 @@ function useTableProps<T extends TableItem>(
onNextPage: onNextPageCallback,
onPreviousPage: onPreviousPageCallback,
getLabel,
showPaginationLabel,
} = paginationOptions;
const previousDataRef = useRef(paginationResult.data);
@@ -74,6 +75,7 @@ function useTableProps<T extends TableItem>(
},
showPageSizeOptions,
getLabel,
showPaginationLabel,
}),
[
paginationResult.pageSize,
@@ -88,6 +90,9 @@ function useTableProps<T extends TableItem>(
onNextPageCallback,
onPreviousPageCallback,
onPageSizeChangeCallback,
showPageSizeOptions,
getLabel,
showPaginationLabel,
],
);
@@ -106,8 +111,6 @@ function useTableProps<T extends TableItem>(
isStale,
paginationResult.error,
pagination,
showPageSizeOptions,
getLabel,
sortState,
],
);
@@ -66,6 +66,7 @@ export function TablePagination(props: TablePaginationProps) {
onPageSizeChange,
showPageSizeOptions,
getLabel,
showPaginationLabel,
} = ownProps;
const labelId = useId();
@@ -90,6 +91,8 @@ export function TablePagination(props: TablePaginationProps) {
const hasItems = totalCount !== undefined && totalCount !== 0;
const showLabel = hasItems && showPaginationLabel !== false;
let label = `${totalCount} items`;
if (getLabel) {
label = getLabel({ pageSize: effectivePageSize, offset, totalCount });
@@ -121,7 +124,7 @@ export function TablePagination(props: TablePaginationProps) {
)}
</div>
<div className={classes.right}>
{hasItems && (
{showLabel && (
<Text as="p" variant="body-medium" id={labelId}>
{label}
</Text>
@@ -133,7 +136,7 @@ export function TablePagination(props: TablePaginationProps) {
isDisabled={!hasPreviousPage}
icon={<RiArrowLeftSLine />}
aria-label="Previous table page"
aria-describedby={hasItems ? labelId : undefined}
aria-describedby={showLabel ? labelId : undefined}
/>
<ButtonIcon
variant="secondary"
@@ -142,7 +145,7 @@ export function TablePagination(props: TablePaginationProps) {
isDisabled={!hasNextPage}
icon={<RiArrowRightSLine />}
aria-label="Next table page"
aria-describedby={hasItems ? labelId : undefined}
aria-describedby={showLabel ? labelId : undefined}
/>
</div>
</div>
@@ -52,5 +52,6 @@ export const TablePaginationDefinition =
onPageSizeChange: {},
showPageSizeOptions: { default: true },
getLabel: {},
showPaginationLabel: { default: true },
},
});
@@ -37,6 +37,7 @@ export type TablePaginationOwnProps = {
offset?: number;
totalCount?: number;
}) => string;
showPaginationLabel?: boolean;
};
/** @public */