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)