diff --git a/README.md b/README.md
index 755de14..39d5b17 100644
--- a/README.md
+++ b/README.md
@@ -26,11 +26,7 @@ Build dynamic plugins from scratch — backend or frontend — and get them depl
Migrate your plugins from the legacy Backstage frontend system to the New Frontend System (NFS).
-Start with the **[NFS Migration Guide](./docs/nfs-migration-guide.md)** -- it covers what NFS is, why you need to migrate, the deprecation timeline, and walks through every migration pattern with code examples.
-
-When you're ready to migrate, use the Agent Skill to automate it:
-
-- **[nfs-migration](./skills/nfs-migration/SKILL.md)** -- Analyzes your existing plugin, applies the right Blueprint patterns, updates exports, and verifies the result. Two approaches: direct-to-GA (recommended) or phased with backward compatibility.
+- **[nfs-migration](./skills/nfs-migration/SKILL.md)** -- Analyzes your existing plugin, applies the right Blueprint patterns, updates exports, and verifies the result. Two approaches: direct-to-GA (recommended) or phased with backward compatibility. Reference files cover every extension type, mount point mapping, operator config, gotchas, and verification.
### Backstage Upgrade
diff --git a/docs/nfs-migration-guide.md b/docs/nfs-migration-guide.md
deleted file mode 100644
index a519cd7..0000000
--- a/docs/nfs-migration-guide.md
+++ /dev/null
@@ -1,484 +0,0 @@
-# Migrating RHDH Plugins to the New Frontend System (NFS)
-
-A practical guide for Red Hat Developer Hub plugin authors migrating from the legacy Backstage frontend system to NFS.
-
-> **Agent skill users:** The `nfs-migration` skill (`skills/nfs-migration/`) contains the same patterns broken into reference files optimized for agent consumption. This guide is the authoritative human-readable source. When updating migration patterns, update this guide first, then sync the corresponding reference file.
-
----
-
-## 1. What is the New Frontend System
-
-The Backstage New Frontend System (NFS) replaces the legacy frontend plugin API. Instead of manually wiring plugins into an app with `createPlugin`, `createRoutableExtension`, `FlatRoutes`, and imperative JSX route trees, NFS uses declarative extension **Blueprints** (`PageBlueprint`, `ApiBlueprint`, `EntityContentBlueprint`, etc.) and `createFrontendPlugin` from `@backstage/frontend-plugin-api`.
-
-The app assembles itself from features:
-
-```ts
-import { createApp } from '@backstage/frontend-defaults';
-
-const app = createApp({ features: [myPlugin, catalogPlugin, ...] });
-```
-
-Plugins declare what they provide. The app decides what to render.
-
----
-
-## 2. Why Migrate
-
-- **Declarative**: plugins describe their own routes, nav items, and APIs -- no more manual wiring in the app
-- **Configurable**: extensions can be enabled, disabled, or reordered via `app-config.yaml`
-- **Auto-discoverable**: apps can detect installed plugins automatically
-- **Composable**: modules can inject extensions into other plugins (e.g. entity tabs into catalog)
-- **Required**: the legacy APIs are being deprecated and will be removed
-
----
-
-## 3. Deprecation Timeline
-
-| Phase | What happens |
-|-------|-------------|
-| **Current (RHDH 1.10)** | NFS available as `/alpha` exports alongside legacy |
-| **Next release (GA)** | NFS becomes the root export (`.`); legacy moves to `/legacy` with `@deprecated` tags |
-| **GA + 2 releases** | Legacy `/legacy` exports removed entirely |
-
----
-
-## 4. Key Concepts
-
-### Blueprints vs Legacy Extension Factories
-
-Blueprints are declarative factories that replace imperative helpers like `createRoutableExtension()`. Each blueprint type (`PageBlueprint`, `ApiBlueprint`, `EntityContentBlueprint`) knows how to register itself with the app. Nav items are auto-discovered from pages -- no separate blueprint needed.
-
-```ts
-// Legacy
-export const MyPage = myPlugin.provide(createRoutableExtension({ ... }));
-
-// NFS
-const myPage = PageBlueprint.make({ params: { path: '/my-plugin', loader: () => ... } });
-```
-
-### `createFrontendPlugin` vs `createPlugin`
-
-| Legacy `createPlugin` | NFS `createFrontendPlugin` |
-|---|---|
-| `id: 'my-plugin'` | `pluginId: 'my-plugin'` |
-| `apis: [createApiFactory(...)]` | APIs go in `extensions` array as `ApiBlueprint` |
-| Pages/routes wired externally | Pages declared as `PageBlueprint` in `extensions` |
-| Named export | **Default export** |
-
-### `createFrontendModule`
-
-Bundles extensions that target *another* plugin. Common cases:
-
-- **Translations** target `pluginId: 'app'`
-- **Homepage widgets** target `pluginId: 'home'`
-
-Modules are separate exports, not part of the plugin itself. Note: entity content and cards can go directly in your plugin's `extensions` array (they declare their own attach point) — a separate catalog module is only needed when injecting content from outside a plugin you don't own.
-
-### Route Refs
-
-You can reuse existing route refs from `@backstage/core-plugin-api` or create new ones from `@backstage/frontend-plugin-api`. Both work -- no need to migrate route refs immediately.
-
----
-
-## 5. Migration Patterns
-
-### Plugin Definition
-
-**Legacy:**
-
-```ts
-import { createPlugin, createApiFactory, configApiRef, fetchApiRef } from '@backstage/core-plugin-api';
-import { rootRouteRef } from './routes';
-import { myApiRef, MyApiClient } from './api';
-
-export const myPlugin = createPlugin({
- id: 'my-plugin',
- routes: { root: rootRouteRef },
- apis: [
- createApiFactory({
- api: myApiRef,
- deps: { configApi: configApiRef, fetchApi: fetchApiRef },
- factory: ({ configApi, fetchApi }) => new MyApiClient({ configApi, fetchApi }),
- }),
- ],
-});
-```
-
-**NFS:**
-
-```tsx
-import {
- createFrontendPlugin, ApiBlueprint, PageBlueprint,
- configApiRef, fetchApiRef, createApiFactory,
-} from '@backstage/frontend-plugin-api';
-import { rootRouteRef } from './routes';
-import { myApiRef, MyApiClient } from './api';
-import { RiToolsLine } from '@remixicon/react';
-
-const myApi = ApiBlueprint.make({
- params: defineParams => defineParams({
- api: myApiRef,
- deps: { configApi: configApiRef, fetchApi: fetchApiRef },
- factory: ({ configApi, fetchApi }) => new MyApiClient({ configApi, fetchApi }),
- }),
-});
-
-const myPage = PageBlueprint.make({
- params: {
- path: '/my-plugin',
- title: 'My Plugin',
- icon: ,
- routeRef: rootRouteRef,
- loader: () => import('./components/MyPage').then(m => ),
- },
-});
-
-export default createFrontendPlugin({
- pluginId: 'my-plugin',
- title: 'My Plugin',
- icon: ,
- extensions: [myApi, myPage],
- routes: { root: rootRouteRef },
-});
-```
-
-Key changes: APIs and pages are extensions in the `extensions` array. Nav items are auto-discovered from pages with `title`, `icon`, and `routeRef`. The plugin is the **default export**.
-
-### Pages
-
-**Legacy** -- routable extension provided by the plugin, path set in the app's `FlatRoutes`:
-
-```tsx
-export const MyPage = myPlugin.provide(
- createRoutableExtension({
- name: 'MyPage',
- component: () => import('./components/MyPage').then(m => m.MyPage),
- mountPoint: rootRouteRef,
- }),
-);
-
-// In the app:
-
- } />
-
-```
-
-**NFS** -- the plugin owns its path. No app-side route wiring. The NFS page component must **not** include its own page shell (`PageWithHeader`) — the framework provides the header automatically:
-
-```tsx
-const myPage = PageBlueprint.make({
- params: {
- path: '/my-plugin',
- routeRef: rootRouteRef,
- loader: () => import('./components/MyPage').then(m => ),
- },
-});
-```
-
-Create a separate NFS variant of each page component without the page shell. See `references/migrate-page.md` for the dual header pattern (Pattern A for simple pages, Pattern B for complex pages).
-
-### Nav Items
-
-**Legacy** -- manually added in the app's sidebar:
-
-```tsx
-
-```
-
-**NFS** -- auto-discovered from pages. Set `title` and `icon` on `PageBlueprint` params and the app generates nav entries automatically. No separate blueprint needed — see the Plugin Definition example above.
-
-> Earlier Backstage versions used `NavItemBlueprint`. It has been removed — see `references/api-changes.md`.
-
-### APIs
-
-**Legacy** -- `createApiFactory` in the plugin's `apis` array.
-
-**NFS** -- wrap the existing `createApiFactory` call in `ApiBlueprint.make` using the `defineParams` callback. See the Plugin Definition example above. The `defineParams` callback is required -- it's how the blueprint validates the factory. See `references/migrate-page.md` for the full pattern.
-
-### Entity Content (Catalog Tabs)
-
-Entity content goes in your plugin's `extensions` array. The blueprint declares its own attach point, so the app discovers it automatically:
-
-```tsx
-import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha';
-import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
-
-const myEntityContent = EntityContentBlueprint.make({
- params: {
- path: '/my-tab',
- title: 'My Tab',
- loader: () => import('./components/MyTab').then(m => ),
- },
-});
-
-export default createFrontendPlugin({
- pluginId: 'my-plugin',
- extensions: [myEntityContent],
-});
-```
-
-If you need to provide entity content from a separate package (third-party addon), use `createFrontendModule({ pluginId: 'catalog' })` instead.
-
-### Translations
-
-Translations must be in a separate module targeting `pluginId: 'app'`:
-
-```tsx
-import { createFrontendModule } from '@backstage/frontend-plugin-api';
-import { TranslationBlueprint } from '@backstage/plugin-app-react';
-import { myTranslations } from './translations';
-
-export const myTranslationsModule = createFrontendModule({
- pluginId: 'app',
- extensions: [
- TranslationBlueprint.make({
- name: 'my-plugin-translations',
- params: { resource: myTranslations },
- }),
- ],
-});
-```
-
-### RHDH-Specific Extensions
-
-**Drawer panels** -- `AppDrawerContentBlueprint`:
-
-```tsx
-import { AppDrawerContentBlueprint } from '@red-hat-developer-hub/backstage-plugin-app-react/alpha';
-
-const myDrawer = AppDrawerContentBlueprint.make({
- params: {
- title: 'My Drawer',
- loader: () => import('./components/MyDrawer').then(m => ),
- },
-});
-```
-
-**Global header menu items** -- `GlobalHeaderMenuItemBlueprint`:
-
-```tsx
-import { GlobalHeaderMenuItemBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header/alpha';
-
-const myMenuItem = GlobalHeaderMenuItemBlueprint.make({
- params: {
- title: 'My Action',
- icon: MyIcon,
- routeRef: rootRouteRef,
- },
-});
-```
-
-**Homepage widgets** -- `HomePageWidgetBlueprint`:
-
-```tsx
-import { HomePageWidgetBlueprint } from '@backstage/plugin-home-react/alpha';
-
-const myWidget = HomePageWidgetBlueprint.make({
- params: {
- title: 'My Widget',
- loader: () => import('./components/MyWidget').then(m => ),
- },
-});
-```
-
-### RHDH Mount Point Migration
-
-If your plugin uses RHDH dynamic plugin mount points (`app-config.dynamic.yaml`), these map directly to NFS blueprints. See `references/mount-point-mapping.md` for the complete mapping table with before/after examples for each mount point type.
-
-### Shared Components (Legacy + NFS)
-
-Keep component imports (`useApi`, `useRouteRef`, etc.) on `@backstage/core-plugin-api` — they work in both legacy and NFS contexts. This lets the same components serve both export paths without changes:
-
-```tsx
-// Keep this — works in both legacy and NFS
-import { useApi, useRouteRef } from '@backstage/core-plugin-api';
-```
-
-Don't migrate component imports to `@backstage/frontend-plugin-api` if you need to support legacy consumers — it breaks the legacy code path.
-
-### CompatWrapper (rare)
-
-Only needed when a component depends on legacy context providers that aren't available in NFS (e.g. old `SidebarContext`). Most plugins won't need this. Wrap the JSX element in the loader:
-
-```tsx
-loader: () => import('./components/MyPage').then(m => compatWrapper())
-```
-
-Import `compatWrapper` from `@backstage/core-compat-api`.
-
----
-
-## 6. Choosing Your Approach
-
-### Approach A -- Direct to GA (recommended)
-
-NFS becomes the root export immediately. Legacy code moves to `/legacy` or is removed.
-
-Best when:
-- You control all consumers
-- You can do a clean migration in one pass
-- You want the simplest result
-
-### Approach B -- Phased
-
-Add NFS as `/alpha` exports alongside existing legacy exports. Graduate later by swapping.
-
-Best when:
-- External consumers depend on legacy exports
-- You need time to migrate tests and stories
-- You want to ship incrementally
-
-| | Direct to GA | Phased |
-|---|---|---|
-| Complexity | Lower | Higher (two export sets) |
-| Consumer impact | Breaking change | Non-breaking initially |
-| Maintenance | One code path | Two code paths temporarily |
-| Recommended for | Internal plugins | Shared/published plugins |
-
----
-
-## 7. Package.json Changes
-
-Update your `package.json` exports for the GA structure:
-
-```json
-{
- "exports": {
- ".": "./src/index.ts",
- "./legacy": "./src/legacy.ts",
- "./package.json": "./package.json"
- },
- "typesVersions": {
- "*": {
- "legacy": ["src/legacy.ts"],
- "package.json": ["package.json"]
- }
- }
-}
-```
-
-- `.` -- NFS plugin (default export from `createFrontendPlugin`)
-- `./legacy` -- old `createPlugin`-based exports for consumers who haven't migrated yet
-- Remove the `./legacy` entry when you drop legacy support
-
----
-
-## 8. Verifying Your Migration
-
-Run through this checklist:
-
-- [ ] `yarn tsc` passes with no type errors
-- [ ] `yarn build` succeeds
-- [ ] Plugin default export is the `createFrontendPlugin` result
-- [ ] All extensions (pages, APIs) are in the `extensions` array
-- [ ] NFS page components don't include `PageWithHeader`/`Page` shell (dual header pattern)
-- [ ] Routes are declared in the plugin's `routes` object
-- [ ] Translations are in a separate `createFrontendModule` with `pluginId: 'app'`
-- [ ] Entity content extensions are in the plugin's `extensions` array
-- [ ] `package.json` exports are updated (`.` for NFS, `./legacy` for old)
-- [ ] `src/index.ts` does NOT re-export legacy APIs (legacy only via `./legacy` subpath)
-- [ ] Plugin file uses `.tsx` extension if it contains JSX in blueprint loaders
-- [ ] Component imports stay on `@backstage/core-plugin-api` (shared between legacy and NFS)
-- [ ] `dev/index.tsx` uses NFS dev app pattern; legacy dev app moved to `dev/legacy.tsx`
-- [ ] Workspace app (`packages/app`) is NFS; legacy consumers moved to `./legacy` subpath or a separate `packages/app-legacy`
-
----
-
-## 9. Testing with RHDH
-
-### Local Testing
-
-Use the `rhdh-local` skill to test in a local RHDH instance. If NFS is not yet the default app shell, enable it with environment variables:
-
-```bash
-APP_CONFIG_app_packageName=app-next
-ENABLE_STANDARD_MODULE_FEDERATION=true
-```
-
-Export the plugin as a dynamic plugin and deploy it locally. Verify that:
-- The plugin loads without errors
-- Nav items appear in the sidebar
-- Pages render at the correct paths
-- Entity tabs show up on the right entity kinds
-
-### Cluster Testing
-
-For OpenShift/K8s deployments, add the plugin to your `dynamic-plugins.yaml` configuration and verify it loads in the NFS app shell. Check the browser console for extension registration logs.
-
----
-
-## 10. Common Gotchas
-
-1. **Import paths depend on your approach**: Direct-to-GA → import from root (`.`). Phased → import NFS from `./alpha`. Getting this wrong causes silent failures.
-
-2. **TranslationBlueprint must target `pluginId: 'app'`**: Putting translations in the plugin itself won't work. They must be in a separate `createFrontendModule({ pluginId: 'app' })`.
-
-3. **Nav items require `title` + `icon` + `routeRef` on the page**: Nav entries are auto-discovered from `PageBlueprint` extensions. If your plugin's nav item isn't appearing, ensure all three params are set. `NavItemBlueprint` was removed in recent Backstage versions -- see `references/api-changes.md`.
-
-4. **Entity content not showing on entity pages**: Ensure `path`, `title`, and `loader` are all set on `EntityContentBlueprint`. The blueprint declares its own attach point — it works directly in the plugin's `extensions` array.
-
-5. **ApiBlueprint uses `defineParams` callback**: Don't pass the factory directly -- wrap it: `params: defineParams => defineParams(createApiFactory(...))`.
-
-6. **Keep component imports on `@backstage/core-plugin-api`**: Hooks like `useApi()` and `useRouteRef()` from `core-plugin-api` work in both legacy and NFS. Don't migrate them to `frontend-plugin-api` if you support legacy consumers. Only use `compatWrapper()` when a component depends on legacy context providers (e.g. old `SidebarContext`).
-
-7. **Drawer content only renders when active**: If your drawer needs initialization logic on mount, use `AppRootElementBlueprint` for the persistent part.
-
-8. **Module federation sharing**: Host and remote apps must share the same `@backstage/plugin-app-react` instance. Version mismatches cause runtime errors.
-
-9. **NFS page components must not include a page shell**: The framework provides the header via `PageLayout`. If your NFS component wraps content in `PageWithHeader` or `Page` + `Header`, you'll get double headers. Create an `NfsMyPage` variant without the shell — see `references/migrate-page.md` for the dual header pattern.
-
-10. **`useRouteRef` returns `undefined` in NFS**: The NFS `useRouteRef` from `@backstage/frontend-plugin-api` returns `RouteFunc | undefined` (the route might not be bound). The legacy version from `core-plugin-api` throws instead. When writing NFS-specific components, handle the `undefined` case.
-
----
-
-## 11. Recent API Changes
-
-If you migrated a plugin against an earlier Backstage NFS alpha, some APIs have changed. Key changes include the removal of `NavItemBlueprint`, deprecation of `makeWithOverrides` config pattern, and new params on `PageBlueprint` and `createFrontendPlugin`.
-
-See the full list in [references/api-changes.md](../skills/nfs-migration/references/api-changes.md).
-
----
-
-## 12. Automate It
-
-Instead of migrating manually, use the included Agent Skill:
-
-```bash
-npx skills add redhat-developer/rhdh-skill --skill nfs-migration
-```
-
-Then tell your agent: *"Migrate my plugin to NFS"* -- it will analyze your plugin, apply the right patterns, update exports, and verify the result.
-
-See [skills/nfs-migration/SKILL.md](../skills/nfs-migration/SKILL.md) for details.
-
----
-
-## 13. Reference PRs
-
-Real RHDH plugin migrations to study:
-
-| Plugin | PR | What to learn |
-|--------|-----|---------------|
-| adoption-insights | [#2309](https://github.com/redhat-developer/rhdh-plugins/pull/2309) | Simple page plugin: Page + Nav + API |
-| bulk-import | [#2247](https://github.com/redhat-developer/rhdh-plugins/pull/2247) | Page + Nav + permission patterns |
-| scorecard | [#2487](https://github.com/redhat-developer/rhdh-plugins/pull/2487) | EntityContent + HomePage widgets |
-| orchestrator | [#2526](https://github.com/redhat-developer/rhdh-plugins/pull/2526) | EntityContent + multi-route |
-| lightspeed | [#2721](https://github.com/redhat-developer/rhdh-plugins/pull/2721) | Drawer + FAB (RHDH-specific) |
-| extensions | [#2527](https://github.com/redhat-developer/rhdh-plugins/pull/2527) | compatWrapper usage |
-| homepage | [#2423](https://github.com/redhat-developer/rhdh-plugins/pull/2423) | HomePageWidgets + compatWrapper |
-| quickstart | [#2842](https://github.com/redhat-developer/rhdh-plugins/pull/2842) | Drawer + GlobalHeaderMenuItem |
-
-### Upstream Backstage Docs
-
-- [Plugin migration guide](https://backstage.io/docs/frontend-system/building-plugins/migrating)
-- [Common extension blueprints](https://backstage.io/docs/frontend-system/building-plugins/common-extension-blueprints)
-- [App migration guide](https://backstage.io/docs/frontend-system/building-apps/migrating)
-
----
-
-## 14. Need Help?
-
-- [RHDH Plugins GitHub Issues](https://github.com/redhat-developer/rhdh-plugins/issues)
-- [Backstage Discord](https://discord.gg/backstage-687207715902193673)
-- [Backstage Community](https://backstage.io/community/)
-- [RHDH Documentation](https://docs.redhat.com/en/documentation/red_hat_developer_hub/)
diff --git a/skills/nfs-migration/SKILL.md b/skills/nfs-migration/SKILL.md
index 5428a23..9754295 100644
--- a/skills/nfs-migration/SKILL.md
+++ b/skills/nfs-migration/SKILL.md
@@ -10,16 +10,14 @@ description: >
to the new frontend system for RHDH.
---
-> **Human-readable guide:** `docs/nfs-migration-guide.md` is the authoritative source for migration patterns. These reference files are optimized for agent consumption. When patterns diverge, the guide takes precedence.
-
Always read the plugin's `package.json`, `src/plugin.ts` (or `src/plugin.tsx`), route refs, API factories, and exported components before making any changes. Understand what exists before migrating.
-
-NFS should be the root export (`.`). Legacy goes to `./legacy` with `@deprecated` tags if kept. This is the GA pattern.
+
+NFS is not GA yet. The default approach is to add NFS at `./alpha` while keeping legacy at the root export (`.`). This avoids breaking existing consumers.
@@ -31,11 +29,11 @@ Entity content and cards can go directly in the plugin's `extensions` array —
-Keep component imports (`useApi`, `useRouteRef`, etc.) on `@backstage/core-plugin-api` — they work in both legacy and NFS contexts. This lets the same components serve both the root export (NFS) and `./legacy` export. Only use `compatWrapper()` when a component depends on legacy context providers (e.g. old `SidebarContext`) that aren't available in NFS. Don't migrate component imports to `@backstage/frontend-plugin-api` if you need to support legacy consumers.
+Keep component imports (`useApi`, `useRouteRef`, etc.) on `@backstage/core-plugin-api` — they work in both legacy and NFS contexts. This lets the same components serve both export paths. Only use `compatWrapper()` when a component depends on legacy context providers (e.g. old `SidebarContext`) that aren't available in NFS. Don't migrate component imports to `@backstage/frontend-plugin-api` if you need to support legacy consumers.
-
-Ask the user if they want to keep legacy exports at `./legacy`. If yes, move old `plugin.ts` code there with `@deprecated` JSDoc. If no, remove it.
+
+Legacy exports must remain available since NFS is not GA. With the alpha approach, legacy stays at root unchanged. With the colocated approach, legacy source moves to `legacy.ts` but is re-exported from `index.ts` so existing consumers don't break.
@@ -58,7 +56,7 @@ Ask the user if they want to keep legacy exports at `./legacy`. If yes, move old
|----------|--------|
| 1, "migrate", "convert", "NFS" | Follow the migration workflow below |
| 2, "test", "verify", "deploy" | Read `workflows/test-nfs-plugin.md` |
-| 3, "learn", "guide", "overview" | Read `../../docs/nfs-migration-guide.md` and present key sections to the user |
+| 3, "learn", "guide", "overview" | Read `references/support.md` for upstream Backstage docs and resources |
@@ -83,9 +81,9 @@ If the plugin's `@backstage/*` dependencies are outdated, upgrade them first usi
### Step 2: Choose Approach
-Use **Direct to GA** by default: NFS becomes root export (`.`), legacy at `./legacy`.
+NFS is not GA yet. Use the **Alpha** approach by default: NFS at `./alpha`, legacy stays at root (`.`).
-Only ask about the **Phased** approach (`./alpha`) if the user says they have external consumers that can't migrate yet.
+The **Colocated** approach is the alternative: NFS as default export in `index.ts`, legacy source in `legacy.ts` but re-exported from `index.ts` for backward compatibility. Use this when the user wants NFS and legacy APIs available from the same import path.
### Step 3: Migrate Extensions
@@ -103,14 +101,14 @@ Apply each reference's patterns to the discovered extensions. For page plugins,
### Step 4: Update package.json
-Load `references/package-json.md` and apply the export configuration matching the chosen approach (GA or phased).
+Load `references/package-json.md` and apply the export configuration matching the chosen approach (alpha or colocated).
### Step 5: Update App Wiring
Load `references/app-setup.md` and:
-- Convert `dev/index.tsx` to use the NFS dev app pattern (`createDevApp` from `@backstage/frontend-dev-utils`)
-- Move the old legacy dev app to `dev/legacy.tsx` with a `start:legacy` script
-- If `packages/app` imports legacy APIs from the plugin root, update those imports to use the `./legacy` subpath (or create a separate `packages/app-legacy` for the old frontend system, keeping `packages/app` as NFS)
+- Add an NFS dev app at `dev/index.tsx` (or `dev/nfs.tsx`) using `createApp` from `@backstage/frontend-defaults`
+- Keep the existing legacy dev app working
+- Verify consumer imports still resolve (alpha approach: no changes needed; colocated approach: legacy re-exports from `index.ts` maintain compatibility)
### Step 6: Verify
@@ -135,20 +133,20 @@ Load `references/verification.md` and run all checks. Run `yarn tsc` from the **
| `references/testing-rhdh.md` | Testing with a real RHDH instance |
| `references/gotchas.md` | Troubleshooting migration issues |
| `references/reference-prs.md` | Looking for real migration examples |
+| `references/operator-config.md` | Plugin uses RHDH operator config or needs `app.extensions` / `app.routes.bindings` reference |
| `references/support.md` | User needs help beyond what the skill covers |
-| `../../docs/nfs-migration-guide.md` | User wants to learn about NFS |
-- Plugin default-exports a `createFrontendPlugin` result
+- `./alpha` (or root, for colocated) default-exports a `createFrontendPlugin` result
- All legacy extensions have NFS Blueprint equivalents
- Pages that need nav entries have `title` and `icon` set (on `PageBlueprint` or `createFrontendPlugin`)
-- `package.json` exports NFS at `.` (direct-to-GA) or `./alpha` (phased)
+- `package.json` exports NFS at `./alpha` (alpha approach) or `.` (colocated approach)
- Translations are in a `createFrontendModule` with `pluginId: 'app'`
- Entity content extensions are in the plugin's `extensions` array (or a catalog module if injecting from outside)
- `yarn tsc` and `yarn build` pass
-- Legacy code is at `./legacy` with `@deprecated` tags (if kept)
+- Legacy exports remain available (unchanged at root for alpha; re-exported from `index.ts` for colocated)
diff --git a/skills/nfs-migration/references/api-changes.md b/skills/nfs-migration/references/api-changes.md
index 0f01f98..b2cc2b5 100644
--- a/skills/nfs-migration/references/api-changes.md
+++ b/skills/nfs-migration/references/api-changes.md
@@ -6,7 +6,7 @@ Breaking and notable changes between the early NFS alpha and the current Backsta
## Component imports — keep on `core-plugin-api`
-Hooks like `useApi`, `useRouteRef`, and `useRouteRefParams` from `@backstage/core-plugin-api` work in both legacy and NFS contexts. **Keep component imports on `core-plugin-api`** so the same components serve both the root NFS export and the `./legacy` export.
+Hooks like `useApi`, `useRouteRef`, and `useRouteRefParams` from `@backstage/core-plugin-api` work in both legacy and NFS contexts. **Keep component imports on `core-plugin-api`** so the same components serve both export paths.
Only the plugin definition code (`plugin.tsx`) and blueprint/API factory code use `@backstage/frontend-plugin-api` imports. Don't migrate component-level imports — it breaks legacy consumers.
diff --git a/skills/nfs-migration/references/app-setup.md b/skills/nfs-migration/references/app-setup.md
index 9c3b727..c28646b 100644
--- a/skills/nfs-migration/references/app-setup.md
+++ b/skills/nfs-migration/references/app-setup.md
@@ -42,7 +42,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(App.createRoot());
## Dev app setup
-For direct-to-GA, `dev/index.tsx` should be the NFS dev app (it's the default `yarn start` entry point). Keep the old legacy dev app at `dev/legacy.tsx` and add a `start:legacy` script.
+Add an NFS dev app alongside the existing legacy dev app. Keep the legacy dev app as the default `yarn start` entry point (since NFS is not GA). Add the NFS dev app at `dev/nfs.tsx` with a `start:nfs` script, or at `dev/index.tsx` if you prefer NFS as default during development.
### NFS dev app (`dev/index.tsx`)
@@ -96,12 +96,12 @@ Move the old `createDevApp` from `@backstage/dev-utils` code here. Add to `packa
"start:legacy": "backstage-cli package start --entrypoint dev/legacy"
```
-## Consumer migration (packages/app)
+## Consumer imports
-If the workspace has a `packages/app` that imports legacy APIs from the plugin's root, those imports will break after the GA migration (legacy is no longer at the root export). Two approaches:
+Since NFS is not GA, legacy exports must remain accessible from the package root:
-1. **Update imports** — Change `import { MyPage } from '@scope/my-plugin'` to `import { MyPage } from '@scope/my-plugin/legacy'`
-2. **Create a separate legacy app** — Keep `packages/app` as the NFS app and create `packages/app-legacy` for the old frontend system. This is the pattern used in `rhdh-plugins`.
+- **Alpha approach:** No consumer changes needed — legacy stays at root, NFS is at `./alpha`.
+- **Colocated approach:** Legacy is re-exported from `index.ts` — existing imports continue to work. NFS consumers use the default import.
## Dynamic plugin considerations (RHDH)
diff --git a/skills/nfs-migration/references/gotchas.md b/skills/nfs-migration/references/gotchas.md
index 8bb8fbd..ae19594 100644
--- a/skills/nfs-migration/references/gotchas.md
+++ b/skills/nfs-migration/references/gotchas.md
@@ -103,19 +103,18 @@ export default createFrontendPlugin({ pluginId: 'my-plugin', ... });
**Fix:** Use `plugin.tsx` (not `.ts`) for the NFS plugin file. Imports like `from './plugin'` resolve both extensions automatically.
-## 12. Re-exporting legacy APIs from the root `index.ts`
+## 12. Forgetting to keep legacy exports accessible
-**Why:** For direct-to-GA, the root export (`.`) should be NFS-only. If you re-export legacy named exports from `index.ts`, consumers get both APIs from the same path, which defeats the purpose of the GA migration.
+**Why:** NFS is not GA yet. Existing consumers import legacy APIs from the package root. If you remove or move those exports without re-exporting them, consumers break.
-**Fix:** Legacy exports should only be reachable via the `./legacy` subpath:
+**Fix:** Depends on approach:
+- **Alpha approach:** Legacy stays at root — no changes needed.
+- **Colocated approach:** Legacy source moves to `legacy.ts`, but must be re-exported from `index.ts`:
```tsx
-// src/index.ts — NFS only
+// src/index.ts — NFS default + legacy re-exports
export { default } from './plugin';
export { isMyPluginAvailable } from './utils';
-// Do NOT re-export legacy APIs here
-
-// src/legacy.ts — legacy only, reachable via '@scope/my-plugin/legacy'
-export { myPlugin, MyPage } from './legacyPlugin';
+export { myPlugin, MyPage } from './legacy'; // backward compat
```
## 13. Double headers in NFS pages
@@ -139,3 +138,70 @@ const link = useRouteRef(myRouteRef);
// link might be undefined — check before calling
const href = link?.() ?? '/fallback';
```
+
+## 15. Config file merging replaces arrays, not merges
+
+**Why:** Backstage merges config files by **replacing entire arrays**. If `app.extensions` appears in multiple config files, the higher-priority file's array replaces the lower-priority one — entries are not merged entry-by-entry. Individual extension `config` objects are also replaced wholesale.
+
+**Fix:** Because unlisted extensions are still auto-discovered, a local override file can contain only the extensions you want to change. But verify merge order when overrides seem to have no effect. See `operator-config.md`.
+
+## 16. Dependencies tab removed in NFS
+
+**Why:** The legacy `/dependencies` tab (`entity.page.dependencies`) does not exist in upstream NFS. The dependency cards are now available as individual overview extensions.
+
+**Fix:** Enable the individual cards instead:
+- `entity-card:catalog/depends-on-components`
+- `entity-card:catalog/depends-on-resources`
+- `entity-card:catalog/has-subcomponents`
+- `entity-card:api-docs/provided-apis`
+- `entity-card:api-docs/consumed-apis`
+- `entity-card:catalog-graph/relations`
+
+To restore a dedicated Dependencies tab, a plugin must contribute a custom `entity-content:*` extension.
+
+## 17. System diagram tab replaced
+
+**Why:** The legacy `/system` tab (`entity.page.diagram`) with a full-width catalog graph does not exist in NFS.
+
+**Fix:** Use `entity-card:catalog-graph/relations` on the overview for a compact graph. The "View Graph" action opens `page:catalog-graph` for a standalone full view. Configure the card for richer graphs via `app.extensions` (height, direction, relations list).
+
+## 18. API tab path changed
+
+**Why:** `entity-content:api-docs/apis` is at path `/apis` (with an **s**), changed from the legacy `/api`.
+
+**Fix:** Update bookmarks, documentation links, and any hardcoded paths.
+
+## 19. Overview layout model change
+
+**Why:** NFS uses `DefaultEntityContentLayout` with two card types: `type: info` (sticky sidebar) and `type: content` (main area). This replaces the legacy per-entity-kind MUI grid layout. Warnings (orphan, relation, processing errors) are built into the layout.
+
+**Fix:** Configure cards via `app.extensions`. See `migrate-entity-content.md` for the `type: info` vs `type: content` distinction.
+
+## 20. RHDH-specific features without upstream NFS equivalents
+
+These features have no direct NFS equivalent yet:
+
+| Feature | Status |
+|---------|--------|
+| Nested sidebar menu groups (`menuItems.parent`) | No equivalent — use `NavContentBlueprint` for custom nav |
+| Application drawer mount points | RHDH-specific — pending NFS design |
+| `global.header/help` and similar header slots | Being migrated in `rhdh-plugins` global-header workspace |
+| `mountPoints[].config.layout` (MUI grid SX) | Not configurable via YAML — implement in component CSS |
+| `staticJSXContent` dynamic plugin pattern | Legacy dynamic host — replace with extension inputs / Utility APIs |
+
+## 21. Things you cannot do from configuration alone
+
+- Attach arbitrary exported components to mount points without a matching NFS extension from the plugin.
+- Replicate `mountPoints[].config.layout` grid column positioning — use card `type: info`/`type: content` or adjust the component layout.
+- Add a new entity tab without a plugin that exports `entity-content:*`.
+- Add cards to General settings until upstream exposes extension inputs on `sub-page:user-settings/general`.
+- Use RHDH-only mount points (application drawers, some global header slots) until equivalent NFS extensions exist.
+
+## 22. User settings page limitations in NFS
+
+**Why:** The upstream `user-settings` plugin has limited extension inputs:
+- `page:user-settings` can be overridden via `app.extensions` (interim escape hatch)
+- `sub-page:user-settings/general` has **no card/content input** — you cannot add cards
+- Default settings cards are **not individual extensions** — you cannot hide them selectively
+
+**Fix:** Wait for upstream plugin updates, or replace the full settings page via `page:user-settings` override.
diff --git a/skills/nfs-migration/references/migrate-app-level.md b/skills/nfs-migration/references/migrate-app-level.md
index cf82039..6efeba0 100644
--- a/skills/nfs-migration/references/migrate-app-level.md
+++ b/skills/nfs-migration/references/migrate-app-level.md
@@ -34,6 +34,20 @@ const appElement = AppRootElementBlueprint.make({
});
```
+## PluginWrapperBlueprint — wraps a single plugin's UI
+
+Use for context providers that should only wrap one plugin, not the entire app. Imported from `@backstage/frontend-plugin-api/alpha`.
+
+```tsx
+import { PluginWrapperBlueprint } from '@backstage/frontend-plugin-api/alpha';
+
+const myPluginWrapper = PluginWrapperBlueprint.make({
+ params: { component: MyPluginProvider },
+});
+```
+
+Unlike `AppRootWrapperBlueprint` (app-wide), this scopes the provider to your plugin's extensions only.
+
## Shared components (legacy + NFS)
Hooks like `useApi` and `useRouteRef` from `@backstage/core-plugin-api` work in both legacy and NFS contexts. Keep component imports on `core-plugin-api` so the same components serve both export paths:
@@ -61,6 +75,7 @@ Import `compatWrapper` from `@backstage/core-compat-api`. Most plugins won't nee
| Need invisible element at root (init, snackbars, FABs) | `AppRootElementBlueprint` |
| Components using `useApi`/`useRouteRef` | Keep on `@backstage/core-plugin-api` — works in both systems |
| Component depends on legacy context providers | Wrap with `compatWrapper()` (rare) |
+| Provider scoped to one plugin only | `PluginWrapperBlueprint` |
| Both wrapping and init logic needed | Use both separately — don't combine |
All app-level extensions go in your plugin's `extensions` array (they belong to your plugin, not to another plugin).
diff --git a/skills/nfs-migration/references/migrate-entity-content.md b/skills/nfs-migration/references/migrate-entity-content.md
index ffda6c6..c0df1c4 100644
--- a/skills/nfs-migration/references/migrate-entity-content.md
+++ b/skills/nfs-migration/references/migrate-entity-content.md
@@ -87,3 +87,107 @@ export const myCatalogModule = createFrontendModule({
```
Export the module so consumers can include it in their app's `features` array.
+
+## EntityContextMenuItemBlueprint — entity context menu actions
+
+Replaces `entity.context.menu` mount point. Uses a data-driven approach instead of dialog wrapper components.
+
+```tsx
+import { EntityContextMenuItemBlueprint } from '@backstage/plugin-catalog-react/alpha';
+
+const myMenuItem = EntityContextMenuItemBlueprint.make({
+ name: 'my-action',
+ params: {
+ icon: ,
+ title: 'Run Action',
+ onClick: ({ entity, dialogApi }) => { /* handle action */ },
+ filter: entity => entity.kind === 'Component',
+ },
+});
+```
+
+Legacy `entity.context.menu` items were often dialog wrappers with `open`/`onClose` props — refactor the dialog lifecycle into the `onClick` handler.
+
+## Entity tab groups
+
+Tabs are organized into groups on `page:catalog/entity`. Default groups: `overview`, `documentation`, `development`, `deployment`, `operation`, `observability`.
+
+**Plugin authors** assign content to a group via the `group` param:
+
+```tsx
+const entityContent = EntityContentBlueprint.make({
+ name: 'my-tab',
+ params: {
+ path: '/my-tab',
+ title: 'My Tab',
+ group: 'development',
+ loader: () => import('./MyTab').then(m => ),
+ },
+});
+```
+
+**Operators** configure groups in `app-config.yaml`:
+
+```yaml
+app:
+ extensions:
+ - page:catalog/entity:
+ config:
+ showNavItemIcons: true
+ groups:
+ - overview:
+ title: Overview
+ - documentation:
+ title: Documentation
+ - development:
+ title: Development
+ - custom:
+ title: My Custom Group
+ - deployment: false # hide this group
+```
+
+Set `group: false` on an `entity-content:*` extension to show it as a standalone tab outside any group.
+
+See `operator-config.md` for the full operator configuration reference.
+
+## Card layout: `type: info` vs `type: content`
+
+Entity overview uses `DefaultEntityContentLayout` with two card types:
+
+- **`type: info`** — renders in a sticky sidebar (right side). Use for compact summary cards like About, Links.
+- **`type: content`** — renders in the main area (left side). Default if not specified.
+
+```yaml
+app:
+ extensions:
+ - entity-card:catalog/about:
+ config:
+ type: info
+ - entity-card:catalog/links:
+ config:
+ type: info
+```
+
+Warnings (orphan, relation, processing errors) are built into the layout — no separate mount point configuration needed.
+
+## Built-in entity extension IDs
+
+**Overview cards** (attach to `entity-content:catalog/overview`):
+
+- `entity-card:catalog/about`, `entity-card:catalog/labels`, `entity-card:catalog/links`
+- `entity-card:catalog-graph/relations`
+- `entity-card:catalog/depends-on-components`, `entity-card:catalog/depends-on-resources`
+- `entity-card:catalog/has-subcomponents`, `entity-card:catalog/has-components`
+- `entity-card:catalog/has-resources`, `entity-card:catalog/has-systems`
+- `entity-card:api-docs/has-apis`, `entity-card:api-docs/consumed-apis`, `entity-card:api-docs/provided-apis`
+- `entity-card:api-docs/providing-components`, `entity-card:api-docs/consuming-components`
+- `entity-card:org/group-profile`, `entity-card:org/members-list`, `entity-card:org/ownership`, `entity-card:org/user-profile`
+
+**Content tabs** (attach to `page:catalog/entity`):
+
+- `entity-content:catalog/overview`
+- `entity-content:api-docs/definition`, `entity-content:api-docs/apis`
+- `entity-content:techdocs`
+- `entity-content:kubernetes/kubernetes`
+
+Additional tabs (CI, CD, Argo CD, etc.) appear when the corresponding dynamic plugin exports an `entity-content:*` extension.
diff --git a/skills/nfs-migration/references/migrate-rhdh-extensions.md b/skills/nfs-migration/references/migrate-rhdh-extensions.md
index 54a99cb..2db596d 100644
--- a/skills/nfs-migration/references/migrate-rhdh-extensions.md
+++ b/skills/nfs-migration/references/migrate-rhdh-extensions.md
@@ -110,3 +110,154 @@ const myWrapper = AppRootWrapperBlueprint.make({
```
Register via `createFrontendModule({ pluginId: 'app' })`.
+
+## IconBundleBlueprint — custom icon sets
+
+Replaces `appIcons` config. Registers multiple icons for use across the app (e.g. as string IDs in `config.icon` on page and entity-content extensions).
+
+```tsx
+import { IconBundleBlueprint } from '@backstage/plugin-app-react';
+
+const myIcons = IconBundleBlueprint.make({
+ params: {
+ icons: {
+ fooIcon: ,
+ barIcon: ,
+ },
+ },
+});
+```
+
+Add to `createFrontendPlugin({ extensions: [...] })`. Icons are auto-discovered with the plugin. For a single icon on one page, using the `icon` param on `PageBlueprint` is simpler.
+
+## ThemeBlueprint — custom themes
+
+Replaces legacy `themes` config with `id`, `title`, `variant`, `importName`.
+
+```tsx
+import { ThemeBlueprint } from '@backstage/plugin-app-react';
+import { lightTheme } from './lightTheme';
+
+const customLightTheme = ThemeBlueprint.make({
+ name: 'light',
+ params: {
+ theme: lightTheme,
+ title: 'Light',
+ variant: 'light',
+ icon: ,
+ },
+});
+```
+
+Use `name: 'light'` or `name: 'dark'` to override the built-in themes. Adopters can override the title via `app.extensions`:
+
+```yaml
+app:
+ extensions:
+ - theme:my-plugin/light:
+ config:
+ title: Corporate Light
+```
+
+## SignInPageBlueprint — custom sign-in pages
+
+Replaces legacy `signInPage` config with `importName`.
+
+```tsx
+import { SignInPageBlueprint } from '@backstage/plugin-app-react';
+
+const customSignIn = SignInPageBlueprint.make({
+ params: {
+ loader: async () => {
+ const { CustomSignInPage } = await import('./CustomSignInPage');
+ return CustomSignInPage;
+ },
+ },
+});
+```
+
+Only one sign-in page extension can be active. Installing multiple replaces based on extension override rules.
+
+## FormFieldBlueprint — custom scaffolder fields
+
+Replaces legacy `scaffolderFieldExtensions` config with `importName`.
+
+```tsx
+import { FormFieldBlueprint } from '@backstage/plugin-scaffolder-react/alpha';
+
+export const myField = FormFieldBlueprint.make({
+ name: 'MyCustomField',
+ params: {
+ schema: { /* JSON schema fragment */ },
+ loader: async () => {
+ const { MyCustomField } = await import('./MyCustomField');
+ return MyCustomField;
+ },
+ },
+});
+```
+
+Fields are auto-discovered via `formFieldsApiRef` when the plugin is installed. No YAML registration needed — template authors use the field name in `template.yaml` as before.
+
+## AddonBlueprint — TechDocs addons
+
+Replaces legacy `techdocsAddons` config with `importName`.
+
+```tsx
+import { AddonBlueprint } from '@backstage/plugin-techdocs-react/alpha';
+import { TechDocsAddonLocations } from '@backstage/plugin-techdocs-react';
+
+const exampleAddon = AddonBlueprint.make({
+ name: 'example',
+ params: {
+ location: TechDocsAddonLocations.Content,
+ component: ExampleAddon,
+ },
+});
+```
+
+Addons are collected via `techdocsAddonsApiRef` and merged into TechDocs reader and entity content extensions automatically. The `staticJSXContent` pattern from legacy dynamic plugins is no longer needed.
+
+## NavContentBlueprint — custom sidebar layout
+
+Replaces the entire sidebar navigation component. Use this as an escape hatch when you need custom navigation structure (e.g. RHDH nested `menuItems.parent` groups, which have no direct NFS equivalent).
+
+```tsx
+import { NavContentBlueprint } from '@backstage/plugin-app-react';
+
+const customNav = NavContentBlueprint.make({
+ params: {
+ component: MyCustomSidebar,
+ },
+});
+```
+
+Most plugins don't need this — standard page auto-discovery provides sidebar items. Only use when you need non-standard sidebar structure like nested groups.
+
+## Custom auth provider settings
+
+No dedicated blueprint — use `createExtension` directly, targeting the user-settings auth providers sub-page:
+
+```tsx
+import { createExtension, coreExtensionData } from '@backstage/frontend-plugin-api';
+import { ProviderSettingsItem } from '@backstage/plugin-user-settings';
+
+const myProviderSettings = createExtension({
+ kind: 'auth-provider-settings',
+ name: 'my-custom',
+ attachTo: { id: 'sub-page:user-settings/auth-providers', input: 'providerSettings' },
+ output: [coreExtensionData.reactElement],
+ factory: () => [
+ coreExtensionData.reactElement(
+ ,
+ ),
+ ],
+});
+```
+
+Also provide `ApiBlueprint` for the auth API and `SignInPageBlueprint` if the provider needs a custom sign-in flow.
diff --git a/skills/nfs-migration/references/mount-point-mapping.md b/skills/nfs-migration/references/mount-point-mapping.md
index 8d0e4ca..fc8fa13 100644
--- a/skills/nfs-migration/references/mount-point-mapping.md
+++ b/skills/nfs-migration/references/mount-point-mapping.md
@@ -16,7 +16,12 @@ RHDH's legacy dynamic plugin system used mount points in `app-config.dynamic.yam
| `application/internal/drawer-state` | Init logic in `AppRootElementBlueprint` | `@backstage/frontend-plugin-api` |
| `global.header/*` | `GlobalHeaderMenuItemBlueprint` | `@red-hat-developer-hub/backstage-plugin-global-header/alpha` |
| `header/component`, `header/*` | `GlobalHeaderMenuItemBlueprint` | `@red-hat-developer-hub/backstage-plugin-global-header/alpha` |
-| `appIcons` | `icon` param on `createFrontendPlugin` | — |
+| `appIcons` | `IconBundleBlueprint` (or `icon` param on `createFrontendPlugin` for single icons) | `@backstage/plugin-app-react` |
+| `entity.context.menu` | `EntityContextMenuItemBlueprint` | `@backstage/plugin-catalog-react/alpha` |
+| `search.page.results` | `SearchResultListItemBlueprint` | `@backstage/plugin-search-react/alpha` |
+| `search.page.filters` | `SearchFilterBlueprint` | `@backstage/plugin-search-react/alpha` |
+| `search.page.types` | `SearchFilterResultTypeBlueprint` | `@backstage/plugin-search-react/alpha` |
+| `application/header` | `AppRootElementBlueprint` | `@backstage/plugin-app-react` |
## Dynamic routes → PageBlueprint
@@ -205,6 +210,113 @@ const myMenuItem = GlobalHeaderMenuItemBlueprint.make({
The `target` param maps to the header section: `help`, `profile`, `create`, etc. Use `priority` to control ordering.
+## Entity context menu → EntityContextMenuItemBlueprint
+
+**Before (mount point):**
+```yaml
+mountPoints:
+ - mountPoint: entity.context.menu
+ importName: SimpleDialog
+ config:
+ props:
+ title: Open Dialog
+ icon: dialogIcon
+```
+
+**After (NFS):**
+```tsx
+import { EntityContextMenuItemBlueprint } from '@backstage/plugin-catalog-react/alpha';
+
+const myMenuItem = EntityContextMenuItemBlueprint.make({
+ name: 'my-action',
+ params: {
+ icon: ,
+ title: 'Open Dialog',
+ onClick: ({ entity, dialogApi }) => { /* open dialog */ },
+ filter: entity => entity.kind === 'Component',
+ },
+});
+```
+
+The new system uses data-driven menu items, not dialog wrapper components with `open`/`onClose` props. Refactor dialog lifecycle into the `onClick` handler.
+
+## Search page results → SearchResultListItemBlueprint
+
+**Before (mount point):**
+```yaml
+mountPoints:
+ - mountPoint: search.page.results
+ importName: MySearchResultItem
+```
+
+**After (NFS):**
+```tsx
+import { SearchResultListItemBlueprint } from '@backstage/plugin-search-react/alpha';
+
+const mySearchItem = SearchResultListItemBlueprint.make({
+ params: {
+ predicate: result => result.type === 'my-type',
+ component: async () => {
+ const { MyResultItem } = await import('./MyResultItem');
+ return props => ;
+ },
+ },
+});
+```
+
+## Search page filters → SearchFilterBlueprint
+
+**Before (mount point):**
+```yaml
+mountPoints:
+ - mountPoint: search.page.filters
+ importName: MySearchFilter
+```
+
+**After (NFS):**
+```tsx
+import { SearchFilterBlueprint } from '@backstage/plugin-search-react/alpha';
+
+const myFilter = SearchFilterBlueprint.make({
+ params: {
+ loader: async () => {
+ const { MySearchFilter } = await import('./MySearchFilter');
+ return props => ;
+ },
+ },
+});
+```
+
+`SearchFilterResultTypeBlueprint` (from the same package) follows the same pattern for `search.page.types` mount points.
+
+## Application header → AppRootElementBlueprint
+
+**Before (mount point):**
+```yaml
+mountPoints:
+ - mountPoint: application/header
+ importName: GlobalHeader
+ config:
+ position: above-main-content
+```
+
+**After (NFS):**
+```tsx
+import { AppRootElementBlueprint } from '@backstage/plugin-app-react';
+
+const myHeader = AppRootElementBlueprint.make({
+ name: 'my-header',
+ params: {
+ loader: async () => {
+ const { GlobalHeader } = await import('./GlobalHeader');
+ return ;
+ },
+ },
+});
+```
+
+RHDH's global header plugin is being migrated to extension blueprints in `rhdh-plugins`. The `position: above-main-content` concept is app-layout-specific — verify layout behavior when migrating.
+
## Real migration examples
| Plugin | Mount points used | NFS blueprints | PR |
diff --git a/skills/nfs-migration/references/operator-config.md b/skills/nfs-migration/references/operator-config.md
new file mode 100644
index 0000000..a8e06bb
--- /dev/null
+++ b/skills/nfs-migration/references/operator-config.md
@@ -0,0 +1,123 @@
+# Operator Configuration Reference (New Frontend System)
+
+Operators and platform admins customize NFS apps through `app-config.yaml` keys, not plugin code. This reference covers the configuration surface. For plugin-author migration, see the other reference files.
+
+## `app.extensions`
+
+The primary tool for enabling, disabling, reordering, and configuring extensions.
+
+### Resolution rules
+
+1. All extensions from installed plugins are **auto-discovered** and loaded by default.
+2. Entries in `app.extensions` **override** matching extensions by ID.
+3. Extensions **listed** in `app.extensions` are **reordered** to appear first, in list order. Unlisted extensions keep their default order afterward.
+
+You typically list only extensions you want to customize — not the full inventory.
+
+### Extension ID format
+
+`[:][/]` — for example `page:catalog`, `entity-card:catalog/about`, `entity-content:techdocs`.
+
+### Syntax
+
+```yaml
+app:
+ extensions:
+ # Shorthand: enable with defaults
+ - entity-card:catalog/about
+
+ # Shorthand: disable
+ - page:catalog-unprocessed-entities: false
+
+ # Full form with config
+ - entity-card:catalog/links:
+ config:
+ filter:
+ kind: component
+ type: info
+```
+
+### Config merging caveat
+
+Backstage merges config files by **replacing entire arrays**. If `app.extensions` appears in multiple config files, the higher-priority file's array **replaces** the lower-priority one — entries are not merged entry-by-entry. Individual extension `config` objects are also replaced wholesale when overridden.
+
+Because unlisted extensions are still auto-discovered, a local override file can contain only the extensions you want to change.
+
+## `app.routes.bindings`
+
+Replaces legacy `routeBindings` in `dynamicPlugins.frontend`. Uses `pluginId.routeName` syntax:
+
+```yaml
+app:
+ routes:
+ bindings:
+ catalog.viewTechDoc: techdocs.docRoot
+ catalog.createComponent: scaffolder.index
+ scaffolder.registerComponent: false # disable a binding
+```
+
+See [Frontend Routes](https://backstage.io/docs/frontend-system/architecture/routes/#binding-external-route-references) for details.
+
+## `app.packages`
+
+Controls which frontend plugin packages are auto-discovered:
+
+```yaml
+app:
+ packages: all
+```
+
+Or restrict explicitly:
+
+```yaml
+app:
+ packages:
+ include:
+ - '@backstage/plugin-catalog'
+ - '@backstage/plugin-techdocs'
+ exclude: []
+```
+
+Dynamic plugins loaded at runtime through the frontend feature loader are discovered separately from this setting.
+
+## Scaffolder template grouping
+
+Group templates on the scaffolder page using `sub-page:scaffolder/templates`:
+
+```yaml
+app:
+ extensions:
+ - sub-page:scaffolder/templates:
+ config:
+ groups:
+ - title: Recommended Services
+ filter:
+ spec.type: service
+ - title: Internal Tools
+ filter:
+ spec.type: tool
+```
+
+## Operator cheat sheet
+
+| Task | Legacy RHDH | New frontend system |
+|------|-------------|---------------------|
+| Install a plugin | `dynamic-plugins.yaml` entry | Same — `enabled: true` |
+| Disable a plugin page | Remove route or `menuItem.enabled: false` | `page:my-plugin: false` |
+| Rename sidebar item | `menuItem.text` | `page:my-plugin` → `config.title` |
+| Reorder sidebar | `menuItems.*.priority` | Order in `app.extensions` |
+| Hide entity overview card | Remove mount point entry | `entity-card:*: false` |
+| Change card visibility filter | `mountPoints[].config.if` | `entity-card:*` → `config.filter` |
+| Rename entity tab | `entityTabs[].title` | `entity-content:*` → `config.title` |
+| Reorder / group entity tabs | `entityTabs` + `priority` | `page:catalog/entity` → `config.groups` |
+| Hide entity tab | Negative `entityTabs` priority | `entity-content:*: false` |
+| Bind cross-plugin routes | `routeBindings` | `app.routes.bindings` |
+| Disable route binding | Omit binding | `app.routes.bindings.: false` |
+
+## What you cannot do from configuration alone
+
+- **Attach arbitrary exported components** to mount points without a matching NFS extension from the plugin.
+- **Replicate `mountPoints[].config.layout`** grid column positioning — use card `type: info`/`type: content` or adjust the component layout.
+- **Add a new entity tab** without a plugin that exports `entity-content:*`.
+- **Add cards to General settings** until upstream exposes extension inputs on `sub-page:user-settings/general`.
+- **Use RHDH-only mount points** (application drawers, some global header slots) until equivalent NFS extensions exist.
diff --git a/skills/nfs-migration/references/package-json.md b/skills/nfs-migration/references/package-json.md
index 3570c48..6c7fded 100644
--- a/skills/nfs-migration/references/package-json.md
+++ b/skills/nfs-migration/references/package-json.md
@@ -1,62 +1,57 @@
# Package.json Export Configuration
-## Direct to GA (recommended)
+## Alpha approach (default)
-NFS is the root export. Legacy moves to `./legacy`.
+NFS at `./alpha`, legacy stays at root. No breaking changes for consumers. This is the recommended approach while NFS is not GA.
```json
{
"exports": {
".": "./src/index.ts",
- "./legacy": "./src/legacy.ts",
+ "./alpha": "./src/alpha.tsx",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
- "legacy": ["src/legacy.ts"],
+ "alpha": ["src/alpha.tsx"],
"package.json": ["package.json"]
}
- },
- "publishConfig": {
- "access": "public",
- "legacy": {
- "types": "dist/legacy.d.ts",
- "default": "dist/legacy.esm.js"
- }
}
}
```
### File layout
-- `src/index.ts` — re-exports default from `plugin.tsx`, plus shared utilities (e.g. `isMyPluginAvailable`). **Do not re-export legacy APIs here** — they are only reachable via the `./legacy` subpath
-- `src/plugin.tsx` — NFS plugin definition (`createFrontendPlugin` with blueprints, default export). Use `.tsx` since blueprint loaders return JSX
-- `src/legacy.ts` — old `createPlugin(...)` result with `@deprecated` JSDoc tags
+- `src/index.ts` — existing legacy exports (unchanged)
+- `src/alpha.tsx` — default-exports `createFrontendPlugin(...)`, named-exports modules. Use `.tsx` since blueprint loaders return JSX
-## Phased approach
+## Colocated approach
-NFS at `./alpha`, legacy stays at root.
+NFS as default export in `index.ts`, legacy source in `legacy.ts` but re-exported from `index.ts` for backward compatibility. Use when you want both APIs available from the same import path.
```json
{
"exports": {
".": "./src/index.ts",
- "./alpha": "./src/alpha.tsx",
"./package.json": "./package.json"
- },
- "typesVersions": {
- "*": {
- "alpha": ["src/alpha.tsx"],
- "package.json": ["package.json"]
- }
}
}
```
### File layout
-- `src/index.ts` — existing legacy exports (unchanged)
-- `src/alpha.tsx` — default-exports `createFrontendPlugin(...)`, named-exports modules
+- `src/index.ts` — default-exports `createFrontendPlugin` from `plugin.tsx`, AND re-exports legacy named exports from `legacy.ts` for backward compatibility
+- `src/plugin.tsx` — NFS plugin definition (`createFrontendPlugin` with blueprints, default export). Use `.tsx` since blueprint loaders return JSX
+- `src/legacy.ts` — old `createPlugin(...)` result with `@deprecated` JSDoc tags
+
+```tsx
+// src/index.ts (colocated approach)
+export { default } from './plugin';
+export { isMyPluginAvailable } from './utils';
+
+// Re-export legacy APIs for backward compatibility
+export { myPlugin, MyPage, MyCard } from './legacy';
+```
## Required backstage fields
@@ -78,10 +73,50 @@ Ensure these exist in `package.json`:
- `pluginId`: must match the `pluginId` passed to `createFrontendPlugin`
- `pluginPackages`: array of all packages in this plugin family (frontend, backend, common, etc.)
+## Scalprum configuration (dual-export period)
+
+RHDH dynamic plugins use a `scalprum` section in the derived package's `package.json` for the legacy Webpack module-federation container. Keep this working during migration.
+
+```json
+{
+ "scalprum": {
+ "name": "my-plugin-package",
+ "exposedModules": {
+ "PluginRoot": "./src/index.ts",
+ "FooModule": "./src/foo.ts"
+ }
+ }
+}
+```
+
+- **`scalprum.name`** — Webpack container name. This is also the key under `dynamicPlugins.frontend` in operator YAML — it may differ from the npm package name.
+- **`scalprum.exposedModules`** — maps module names to source entrypoints. Each key becomes a loadable entrypoint in the dynamic plugin bundle.
+
+Legacy wiring resolves modules via `module` (which `exposedModules` key to load, defaults to `PluginRoot`) and `importName` (which named export to render, defaults to default export):
+
+```yaml
+dynamicPlugins:
+ frontend:
+ my-plugin-package:
+ mountPoints:
+ - mountPoint: entity.page.overview/cards
+ module: FooModule
+ importName: MyCard
+```
+
+The RHDH CLI `--scalprum-config` option can override this at export time.
+
+### Dual-export checklist
+
+- [ ] Legacy `scalprum.exposedModules` resolves all `importName`/`module` references in existing operator config
+- [ ] `./alpha` export added for NFS (`createFrontendPlugin` default export)
+- [ ] As each feature moves to NFS extensions, delete the corresponding `dynamicPlugins.frontend` YAML keys
+- [ ] Re-export with a CLI version from the RHDH version matrix
+
## Checklist
-- [ ] `exports` field has `.` pointing to NFS entry
+- [ ] `exports` field has `./alpha` (alpha approach) or `.` (colocated approach) pointing to NFS entry
- [ ] `typesVersions` mirrors any sub-path exports
-- [ ] `publishConfig` has types/default for each sub-path (GA approach)
+- [ ] Legacy exports remain accessible (unchanged root for alpha; re-exported from `index.ts` for colocated)
- [ ] `backstage.role` is `frontend-plugin`
- [ ] `backstage.pluginId` matches `createFrontendPlugin({ pluginId: '...' })`
diff --git a/skills/nfs-migration/references/support.md b/skills/nfs-migration/references/support.md
index cd8dc48..213f2bd 100644
--- a/skills/nfs-migration/references/support.md
+++ b/skills/nfs-migration/references/support.md
@@ -8,6 +8,15 @@
- **[RHDH Documentation](https://docs.redhat.com/en/documentation/red_hat_developer_hub/)** — Official Red Hat Developer Hub documentation
- **[Backstage NFS Docs](https://backstage.io/docs/frontend-system/)** — Upstream New Frontend System documentation, API reference, and migration guides
+## Upstream Backstage docs
+
+- [Frontend System Introduction](https://backstage.io/docs/frontend-system/)
+- [Migrating Plugins](https://backstage.io/docs/frontend-system/building-plugins/migrating/)
+- [Migrating Apps](https://backstage.io/docs/frontend-system/building-apps/migrating/)
+- [Configuring Extensions](https://backstage.io/docs/frontend-system/building-apps/configuring-extensions/)
+- [Common Extension Blueprints](https://backstage.io/docs/frontend-system/building-plugins/common-extension-blueprints/)
+- [Example `app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml)
+
## When to escalate
- **Build failures after migration** — Check `references/gotchas.md` first, then file an issue
diff --git a/skills/nfs-migration/references/verification.md b/skills/nfs-migration/references/verification.md
index 2a43b60..34101e1 100644
--- a/skills/nfs-migration/references/verification.md
+++ b/skills/nfs-migration/references/verification.md
@@ -53,16 +53,34 @@ Adapt selectors to your plugin. These are starting points, not production-ready
| Shared components | Component imports stay on `core-plugin-api` (work in both legacy and NFS) |
| Legacy compat | Components using `compatWrapper` (if any) render without errors |
+## Blueprint-type-specific verification
+
+| Blueprint | What to verify |
+|-----------|---------------|
+| `FormFieldBlueprint` | Template author can use the custom field name in `template.yaml`; field renders in scaffolder form |
+| `AddonBlueprint` | Addon renders in TechDocs reader at the specified location |
+| `ThemeBlueprint` | Theme appears in user settings theme picker; overrides apply |
+| `SignInPageBlueprint` | Custom sign-in page renders on unauthenticated access |
+| `IconBundleBlueprint` | Icon IDs resolve in `config.icon` references on pages and entity content |
+
+## Scalprum / dynamic plugin verification
+
+If dual-exporting (legacy + NFS), also verify:
+
+- [ ] `scalprum.exposedModules` still resolves all `importName`/`module` references in existing operator config
+- [ ] OCI image rebuilt and smoke-tested in RHDH with `APP_CONFIG_app_packageName=app-next`
+
## Consumer import check
-After migrating, verify that any workspace apps (`packages/app`, dev apps) that import from the plugin still compile. Legacy consumers must update their imports to use the `./legacy` subpath:
+After migrating, verify that any workspace apps (`packages/app`, dev apps) that import from the plugin still compile:
```bash
-# Find any imports of legacy named exports from the plugin's root
+# Find all imports from the plugin
grep -r "from '@scope/my-plugin'" packages/ --include='*.ts' --include='*.tsx'
```
-If hits reference legacy exports (e.g. `MyPage`, `myPlugin`), update them to import from `'@scope/my-plugin/legacy'`.
+- **Alpha approach:** No changes needed — legacy is still at the root.
+- **Colocated approach:** Legacy re-exports from `index.ts` maintain compatibility — verify they resolve.
## Quick validation commands