From fe6f4a03ab1bfa60d9f05fb6532f4dc667fd7106 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 25 Mar 2026 09:58:27 +0100 Subject: [PATCH] docs: add frontend system migration skills Add three new skill documents for migrating to the new Backstage frontend system: - App migration (old app-defaults to new frontend-defaults) - Plugin dual-support (adding new system support while keeping old system working) - Plugin full migration (completely moving to the new frontend system) Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../config/vocabularies/Backstage/accept.txt | 1 + .../app-frontend-system-migration/SKILL.md | 651 ++++++++++++++++++ docs/.well-known/skills/index.json | 15 + .../SKILL.md | 545 +++++++++++++++ .../SKILL.md | 422 ++++++++++++ 5 files changed, 1634 insertions(+) create mode 100644 docs/.well-known/skills/app-frontend-system-migration/SKILL.md create mode 100644 docs/.well-known/skills/plugin-full-frontend-system-migration/SKILL.md create mode 100644 docs/.well-known/skills/plugin-new-frontend-system-support/SKILL.md diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 5b9d088e23..0a96735646 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -520,6 +520,7 @@ toolsets tooltip tooltips touchpoint +touchpoints transpilation transpile transpiled diff --git a/docs/.well-known/skills/app-frontend-system-migration/SKILL.md b/docs/.well-known/skills/app-frontend-system-migration/SKILL.md new file mode 100644 index 0000000000..a7a43ef515 --- /dev/null +++ b/docs/.well-known/skills/app-frontend-system-migration/SKILL.md @@ -0,0 +1,651 @@ +--- +name: app-frontend-system-migration +description: Migrate a Backstage app from the old frontend system to the new extension-based frontend system. Use this skill when migrating packages/app from app-defaults to frontend-defaults, converting routes, sidebar, plugins, and other app-level configuration. +--- + +# 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 `` 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 => ( + + ), + }, +}); + +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( + <> + + + + + {routes} + + , +); + +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(); + +// 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..*` 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().with({ + id: 'plugin.my-plugin.my-api', + pluginId: 'my-plugin', +}); + +// Legacy form: ownership inferred from the id string pattern +const legacyRef = createApiRef({ id: 'plugin.my-plugin.my-api' }); +``` + +The builder form (`createApiRef().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 => + ( + + ), + }, +}); +``` + +#### 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: , + Provider: ({ 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( + <> + + + + + {routes} + + , +); + +// 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 => ( + item.icon} to={item.href} text={item.title} /> + )); + + return ( + + + } to="/search"> + + + + }> + {nav.take('page:catalog')} + {nav.take('page:scaffolder')} + + + {nav.rest({ sortBy: 'title' })} + + + + ); + }, + }, +}); + +export const navModule = createFrontendModule({ + pluginId: 'app', + extensions: [SidebarContent], +}); +``` + +Nav items are auto-discovered from page extensions. Use `nav.take('page:')` 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 = ( + + } /> + } /> + +); + +// AFTER: just remove the route — discovery handles the rest +const routes = ( + + } /> + +); +``` + +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 = ( + + {/* Remove both catalog routes */} + {/* } /> */} + {/* }> */} + {/* {entityPage} */} + {/* */} + } /> + +); +``` + +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 ``, ``, ``) +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) diff --git a/docs/.well-known/skills/index.json b/docs/.well-known/skills/index.json index 56c16ff056..8327a19692 100644 --- a/docs/.well-known/skills/index.json +++ b/docs/.well-known/skills/index.json @@ -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 extension-based frontend system. Use this skill when migrating packages/app from createApp (app-defaults) to createApp (frontend-defaults), converting routes, sidebar, plugins, and other app-level configuration.", + "files": ["SKILL.md"] + }, + { + "name": "plugin-new-frontend-system-support", + "description": "Add new frontend system support to an existing Backstage plugin while maintaining backward compatibility with the old system. Use this skill when adding an alpha entry point, creating PageBlueprint/SubPageBlueprint extensions, and implementing the dual-header pattern (old Header vs new HeaderPage/PluginHeader).", + "files": ["SKILL.md"] + }, + { + "name": "plugin-full-frontend-system-migration", + "description": "Fully migrate an existing Backstage plugin to the new frontend system, removing all old system dependencies. Use this skill when converting a plugin to exclusively use @backstage/frontend-plugin-api, replacing internal routing with PageBlueprint/SubPageBlueprint, adopting Backstage UI headers and page layout, and removing @backstage/core-plugin-api usage.", + "files": ["SKILL.md"] } ] } diff --git a/docs/.well-known/skills/plugin-full-frontend-system-migration/SKILL.md b/docs/.well-known/skills/plugin-full-frontend-system-migration/SKILL.md new file mode 100644 index 0000000000..14f2f1d6a0 --- /dev/null +++ b/docs/.well-known/skills/plugin-full-frontend-system-migration/SKILL.md @@ -0,0 +1,545 @@ +--- +name: plugin-full-frontend-system-migration +description: Fully migrate an existing Backstage plugin to the new frontend system, removing all old system dependencies. Use this skill when converting a plugin to exclusively use @backstage/frontend-plugin-api, replacing internal routing with PageBlueprint/SubPageBlueprint, adopting Backstage UI headers and page layout, and removing @backstage/core-plugin-api usage. +--- + +# 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 `` 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 `.` 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: , + 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. ``), 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({ + id: 'plugin.my-plugin.client', +}); + +// NEW (recommended builder pattern with explicit pluginId) +import { createApiRef } from '@backstage/frontend-plugin-api'; + +export const myPluginApiRef = createApiRef().with({ + id: 'plugin.my-plugin.client', + pluginId: 'my-plugin', +}); +``` + +The builder form (`createApiRef().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..*` → 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 => ), + }, +}); +``` + +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 `HeaderPage` for Custom Actions + +If your page needs a subtitle or action buttons below the framework header, use `HeaderPage` from `@backstage/ui`: + +```tsx +// src/components/MyPage/MyPage.tsx +import { HeaderPage } from '@backstage/ui'; +import { Content } from '@backstage/core-components'; + +export function MyPage() { + return ( + <> + + + Help text + + } + /> + + + + + ); +} +``` + +### 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 => ), + }, +}); +``` + +## 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 `` 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 ( + +
+ + + + } /> + } /> + + + + ); +} +``` + +### 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 => ), + }, +}); + +export const settingsSubPage = SubPageBlueprint.make({ + name: 'settings', + params: { + path: 'settings', + title: 'Settings', + loader: () => + import('./components/SettingsPage').then(m => ), + }, +}); +``` + +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 => ), + }, +}); +``` + +## 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 ``/`` 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 `HeaderPage` 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 `HeaderPage`'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 => ), + }, +}); + +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 => ), + }, +}); + +export const tasksSubPage = SubPageBlueprint.make({ + name: 'tasks', + params: { + path: 'tasks', + title: 'Tasks', + loader: () => import('./TasksPage').then(m => ), + }, +}); +``` + +## 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 `HeaderPage` 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 diff --git a/docs/.well-known/skills/plugin-new-frontend-system-support/SKILL.md b/docs/.well-known/skills/plugin-new-frontend-system-support/SKILL.md new file mode 100644 index 0000000000..c78c3bbfbf --- /dev/null +++ b/docs/.well-known/skills/plugin-new-frontend-system-support/SKILL.md @@ -0,0 +1,422 @@ +--- +name: plugin-new-frontend-system-support +description: Add new frontend system support to an existing Backstage plugin while maintaining backward compatibility with the old system. Use this skill when adding an alpha entry point, creating PageBlueprint/SubPageBlueprint extensions, and implementing the dual-header pattern (old Header vs new HeaderPage/PluginHeader). +--- + +# 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 } 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 => ), + }, +}); + +export default createFrontendPlugin({ + pluginId: 'my-plugin', + title: 'My Plugin', + icon: , + 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. ``), 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 { HeaderPage } from '@backstage/ui'; + +// Used by the OLD system — includes the full page shell +export function MyPage() { + return ( + + + + Some help text + + + + + ); +} + +// Used by the NEW system — no page shell, just content +// The framework's PageLayout/PluginHeader provides the title and header +export function NfsMyPage() { + return ( + <> + Some help text} + /> + + + + + ); +} +``` + +Key differences in the NFS variant: + +- **No `Page`/`PageWithHeader`** — the framework provides the outer page shell +- **`HeaderPage` 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 `HeaderPage`'s `customActions` prop +- The shared `` component contains the actual page body + +### Forwarding Customization Props + +If the old system exports a page component with props for customization (e.g. ``), 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 ( + + + + + + ); +} + +// NFS variant — exported from src/alpha.tsx +export function NfsMyPage(props: MyPageProps) { + return ( + + + + ); +} +``` + +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 = {/* shared page body */}; + + if (headerVariant === 'bui') { + return pageContent; + } + + return ( + + {pageContent} + + ); +} + +// Old system export +export const MyPage = (props: MyPageProps) => ( + +); + +// New system export +export const NfsMyPage = (props: MyPageProps) => ( + +); +``` + +### 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 => ), + }, +}); + +export const settingsSubPage = SubPageBlueprint.make({ + name: 'settings', + params: { + path: 'settings', + title: 'Settings', + loader: () => + import('../components/SettingsPage').then(m => ), + }, +}); +``` + +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..*` 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().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 => ), + }, +}); +``` + +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 `.` 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 `HeaderPage` 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 `` 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 `HeaderPage` + `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 `HeaderPage` 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 `HeaderPage`) +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)