diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index e139e26..14efd96 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -5,14 +5,14 @@
},
"metadata": {
"description": "Orchestrator skill for RHDH plugin development - onboard, update, and maintain plugins in the Extensions Catalog",
- "version": "0.6.0"
+ "version": "0.6.1"
},
"plugins": [
{
"name": "rhdh",
"source": "./",
"description": "Skills for RHDH plugin lifecycle management",
- "version": "0.6.0",
+ "version": "0.6.1",
"strict": true
}
]
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index eadcf3d..ec8a1cd 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "rhdh",
"description": "All-in-one toolkit for Red Hat Developer Hub (RHDH). Covers plugin development, overlay management, environment setup, version compatibility, CI/CD, and RHDH ecosystem navigation.",
- "version": "0.6.0",
+ "version": "0.6.1",
"author": {
"name": "RHDH Store Manager"
},
diff --git a/README.md b/README.md
index 755de14..13766c3 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: alpha (default, NFS at `./alpha`) or colocated (NFS + legacy both from root). 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/pyproject.toml b/pyproject.toml
index 3d5653d..f76fce1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "rhdh-skill"
-version = "0.6.0"
+version = "0.6.1"
description = "Claude Code skill for RHDH plugin development"
readme = "README.md"
license = "Apache-2.0"
diff --git a/skills/nfs-migration/SKILL.md b/skills/nfs-migration/SKILL.md
index 5428a23..0b14dd3 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/overview.md` and present key sections to the user |
@@ -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,21 @@ 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/overview.md` | User wants to learn about NFS before migrating |
| `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/examples/before-after-drawer.md b/skills/nfs-migration/examples/before-after-drawer.md
index 9d3ccad..9970e17 100644
--- a/skills/nfs-migration/examples/before-after-drawer.md
+++ b/skills/nfs-migration/examples/before-after-drawer.md
@@ -28,7 +28,7 @@ const myDrawer = AppDrawerContentBlueprint.make({
name: 'my-drawer',
params: {
id: MY_DRAWER_ID,
- loader: () => import('./components/DrawerContent').then(m => ),
+ element: ,
resizable: true,
defaultWidth: 400,
},
diff --git a/skills/nfs-migration/references/api-changes.md b/skills/nfs-migration/references/api-changes.md
index 0f01f98..472206e 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.
@@ -191,6 +191,21 @@ export const viewTechDocRouteRef = createExternalRouteRef({
});
```
+## `AppDrawerContentBlueprint` uses `element`, not `loader`
+
+The RHDH `AppDrawerContentBlueprint` from `@red-hat-developer-hub/backstage-plugin-app-react/alpha` accepts an `element` param (a React element), not `loader` (a lazy import function):
+
+```tsx
+// Correct
+AppDrawerContentBlueprint.make({
+ name: 'my-drawer',
+ params: {
+ id: MY_DRAWER_ID,
+ element: ,
+ },
+});
+```
+
## New catalog-react blueprints
The `@backstage/plugin-catalog-react/alpha` package now exports additional blueprints:
diff --git a/skills/nfs-migration/references/app-setup.md b/skills/nfs-migration/references/app-setup.md
index 9c3b727..dda2840 100644
--- a/skills/nfs-migration/references/app-setup.md
+++ b/skills/nfs-migration/references/app-setup.md
@@ -33,8 +33,8 @@ ReactDOM.createRoot(document.getElementById('root')!).render(App.createRoot());
| Approach | Plugin import | Module imports |
|----------|--------------|----------------|
-| Direct to GA | `import myPlugin from '@scope/my-plugin'` | `import { myTranslationsModule } from '@scope/my-plugin'` |
-| Phased | `import myPlugin from '@scope/my-plugin/alpha'` | `import { myTranslationsModule } from '@scope/my-plugin/alpha'` |
+| Alpha (default) | `import myPlugin from '@scope/my-plugin/alpha'` | `import { myTranslationsModule } from '@scope/my-plugin/alpha'` |
+| Colocated | `import myPlugin from '@scope/my-plugin'` | `import { myTranslationsModule } from '@scope/my-plugin'` |
- The default export is always the plugin (`createFrontendPlugin` result)
- Named exports are modules (`createFrontendModule` results)
@@ -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..a7f4d35 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,4 @@ const link = useRouteRef(myRouteRef);
// link might be undefined — check before calling
const href = link?.() ?? '/fallback';
```
+
diff --git a/skills/nfs-migration/references/migrate-app-level.md b/skills/nfs-migration/references/migrate-app-level.md
index cf82039..aa13667 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. Note: this is an `@alpha` API — the import path may change in future Backstage releases.
+
## 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..3bcc5dc 100644
--- a/skills/nfs-migration/references/migrate-entity-content.md
+++ b/skills/nfs-migration/references/migrate-entity-content.md
@@ -87,3 +87,70 @@ export const myCatalogModule = createFrontendModule({
```
Export the module so consumers can include it in their app's `features` array.
+
+## EntityContextMenuItemBlueprint
+
+See `mount-point-mapping.md` for the migration pattern (replaces `entity.context.menu` mount point).
+
+## 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.
+
diff --git a/skills/nfs-migration/references/migrate-rhdh-extensions.md b/skills/nfs-migration/references/migrate-rhdh-extensions.md
index 54a99cb..8f9a7d3 100644
--- a/skills/nfs-migration/references/migrate-rhdh-extensions.md
+++ b/skills/nfs-migration/references/migrate-rhdh-extensions.md
@@ -110,3 +110,108 @@ 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
+```
+
+## 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.
+
diff --git a/skills/nfs-migration/references/migrate-translations.md b/skills/nfs-migration/references/migrate-translations.md
index 8f10f55..bd89935 100644
--- a/skills/nfs-migration/references/migrate-translations.md
+++ b/skills/nfs-migration/references/migrate-translations.md
@@ -35,21 +35,40 @@ export { default as default } from './plugin';
export { myTranslationsModule } from './modules';
```
-## App integration
+## Auto-discovery via separate entry point (RHDH dynamic plugins)
-The consuming app must include the module in its `features` array:
+Modules targeting `pluginId: 'app'` are not auto-discovered by `app.packages: all` because they are not part of `createFrontendPlugin`. To make them auto-discoverable without explicit code changes in the consuming app, re-export the module as a **default export** from a separate file and add it as its own entry point in `package.json`:
```tsx
-import myPlugin, { myTranslationsModule } from '@scope/my-plugin';
+// src/myTranslationsModuleExport.ts
+export { myTranslationsModule as default } from './index';
+```
-createApp({
- features: [myPlugin, myTranslationsModule],
-});
+```json
+{
+ "exports": {
+ ".": "./src/index.ts",
+ "./alpha": "./src/alpha.tsx",
+ "./my-translations-module": "./src/myTranslationsModuleExport.ts",
+ "./package.json": "./package.json"
+ },
+ "typesVersions": {
+ "*": {
+ "alpha": ["src/alpha.tsx"],
+ "my-translations-module": ["src/myTranslationsModuleExport.ts"],
+ "package.json": ["package.json"]
+ }
+ }
+}
```
+Module federation treats each entry point as a separate remote. This lets the Backstage app load the module automatically without adding it to the `features` array.
+
+This pattern works for any `createFrontendModule` that targets a different plugin (init logic modules, translation modules, etc.). See the [quickstart plugin](https://github.com/redhat-developer/rhdh-plugins/tree/main/workspaces/quickstart/plugins/quickstart) for a real example.
+
## Key rules
- **Always** `pluginId: 'app'` — translations are app-level, not plugin-level
- Each language gets its own `createTranslationResource` call
-- Export the module as a named export alongside the default plugin export
-- The app must explicitly opt in by adding the module to `features`
+- Export the module as a named export from `index.ts` for direct consumers
+- For auto-discovery in RHDH, add a separate entry point with a default export
diff --git a/skills/nfs-migration/references/mount-point-mapping.md b/skills/nfs-migration/references/mount-point-mapping.md
index 8d0e4ca..dba7408 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/frontend-plugin-api` |
## Dynamic routes → PageBlueprint
@@ -205,6 +210,136 @@ 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: ,
+ useProps() {
+ return {
+ title: 'Open Dialog',
+ onClick: () => { /* handle action */ },
+ };
+ },
+ },
+});
+```
+
+The `useProps` hook can call other React hooks and returns `{ title, onClick }` or `{ title, href }` plus optional `disabled`.
+
+## 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 => ;
+ },
+ },
+});
+```
+
+## Search page types → SearchFilterResultTypeBlueprint
+
+**Before (mount point):**
+```yaml
+mountPoints:
+ - mountPoint: search.page.types
+ importName: MySearchType
+```
+
+**After (NFS):**
+```tsx
+import { SearchFilterResultTypeBlueprint } from '@backstage/plugin-search-react/alpha';
+
+const myType = SearchFilterResultTypeBlueprint.make({
+ params: {
+ value: 'my-type',
+ name: 'My Type',
+ icon: ,
+ },
+});
+```
+
+## 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/frontend-plugin-api';
+
+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..a3691dc
--- /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
+
+`[kind:]namespace[/name]` — 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** (some global header slots) until equivalent NFS extensions exist. Application drawers have `AppDrawerContentBlueprint` — see `migrate-rhdh-extensions.md`.
diff --git a/skills/nfs-migration/references/overview.md b/skills/nfs-migration/references/overview.md
new file mode 100644
index 0000000..7e784e4
--- /dev/null
+++ b/skills/nfs-migration/references/overview.md
@@ -0,0 +1,48 @@
+# NFS Overview
+
+## 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`.
+
+Plugins declare what they provide. The app assembles itself from features:
+
+```ts
+import { createApp } from '@backstage/frontend-defaults';
+const app = createApp({ features: [myPlugin, catalogPlugin, ...] });
+```
+
+## Why migrate
+
+- **Declarative**: plugins describe their own routes, nav items, and APIs — no manual wiring in the app
+- **Configurable**: extensions can be enabled, disabled, or reordered via `app-config.yaml`
+- **Auto-discoverable**: apps detect installed plugins automatically via `app.packages: all`
+- **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
+
+## Deprecation timeline
+
+| Phase | Status | What happens |
+|-------|--------|-------------|
+| Current (RHDH 1.10) | NFS available as `./alpha` | Add NFS exports alongside legacy. Both work side-by-side. |
+| Next | NFS becomes default | NFS moves to root (`.`), legacy moves to `./legacy`. |
+| GA + 2 releases | Legacy removed | `./legacy` subpath is removed. Only NFS remains. |
+
+## Key concepts
+
+| Legacy | NFS equivalent |
+|--------|---------------|
+| `createPlugin` | `createFrontendPlugin` (default export) |
+| `createRoutableExtension` | `PageBlueprint` |
+| `createComponentExtension` | `EntityContentBlueprint`, `EntityCardBlueprint`, etc. |
+| `createApiFactory` in plugin `apis` array | `ApiBlueprint` with `defineParams` wrapper |
+| Manual route wiring in `App.tsx` | Auto-discovered from `PageBlueprint` `routeRef` |
+| `menuItem` in dynamic routes config | Auto-discovered from `PageBlueprint` `title` + `icon` |
+| `mountPoints` in `app-config.yaml` | Extension blueprints with built-in attach points |
+| `routeBindings` in plugin config | `externalRoutes` on plugin + `app.routes.bindings` |
+
+## Two migration approaches
+
+- **Alpha (default)** — NFS at `./alpha`, legacy unchanged at root. No breaking changes for consumers.
+- **Colocated** — NFS as default export in `index.ts`, legacy re-exported from `index.ts` for backward compat.
+
+See `package-json.md` for the export configuration for each approach.
diff --git a/skills/nfs-migration/references/package-json.md b/skills/nfs-migration/references/package-json.md
index 3570c48..0ee4cc1 100644
--- a/skills/nfs-migration/references/package-json.md
+++ b/skills/nfs-migration/references/package-json.md
@@ -1,62 +1,79 @@
# 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/index.ts` — existing legacy exports (unchanged)
+- `src/alpha.tsx` — default-exports `createFrontendPlugin(...)`, named-exports modules. Use `.tsx` since blueprint loaders return JSX
+
+## Colocated approach
+
+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",
+ "./package.json": "./package.json"
+ }
+}
+```
+
+### File layout
+
+- `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
-## Phased approach
+```tsx
+// src/index.ts (colocated approach)
+export { default } from './plugin';
+export { isMyPluginAvailable } from './utils';
-NFS at `./alpha`, legacy stays at root.
+// Re-export legacy APIs for backward compatibility
+export { myPlugin, MyPage, MyCard } from './legacy';
+```
+
+## Module entry points for auto-discovery
+
+Modules targeting `pluginId: 'app'` (translations, init logic) are not auto-discovered by `app.packages: all`. To enable auto-discovery in RHDH dynamic plugins, add each module as a separate entry point with a default export:
+
+```tsx
+// src/myTranslationsModuleExport.ts
+export { myTranslationsModule as default } from './index';
+```
```json
{
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.tsx",
+ "./my-translations-module": "./src/myTranslationsModuleExport.ts",
"./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
+Module federation treats each entry point as a separate remote, so it gets loaded without explicit `features` array changes. See `migrate-translations.md` for the full pattern.
## Required backstage fields
@@ -78,10 +95,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..1907735 100644
--- a/skills/nfs-migration/references/verification.md
+++ b/skills/nfs-migration/references/verification.md
@@ -14,29 +14,6 @@ Run these in order. Stop and fix any failures before continuing.
8. **Entity tabs** (if applicable) — Navigate to an entity, verify tab appears
9. **Translations** (if applicable) — Switch language, verify strings update
-## Playwright smoke test (optional)
-
-```ts
-import { test, expect } from '@playwright/test';
-
-test('plugin page renders', async ({ page }) => {
- await page.goto('/my-plugin');
- await expect(page.locator('h1')).toContainText('My Plugin');
-});
-
-test('nav item visible', async ({ page }) => {
- await page.goto('/');
- await expect(page.locator('nav')).toContainText('My Plugin');
-});
-
-test('entity tab visible', async ({ page }) => {
- await page.goto('/catalog/default/component/my-component');
- await expect(page.locator('[role="tab"]')).toContainText('My Plugin');
-});
-```
-
-Adapt selectors to your plugin. These are starting points, not production-ready tests.
-
## Testing principles (any framework)
| What to verify | How |
@@ -53,16 +30,24 @@ 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 |
+## 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
diff --git a/skills/rhdh-coding/references/nfs.md b/skills/rhdh-coding/references/nfs.md
new file mode 100644
index 0000000..6e9c15b
--- /dev/null
+++ b/skills/rhdh-coding/references/nfs.md
@@ -0,0 +1,141 @@
+# NFS (New Frontend System) Coding Patterns
+
+## Alpha File Structure (`src/alpha.tsx`)
+
+```tsx
+import {
+ ApiBlueprint, createApiFactory, createFrontendModule, createFrontendPlugin,
+ createRouteRef, createSubRouteRef, PageBlueprint,
+} from '@backstage/frontend-plugin-api';
+import { TranslationBlueprint } from '@backstage/plugin-app-react';
+import { EntityCardBlueprint, EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha';
+```
+
+## Route Refs
+
+```tsx
+const rootRouteRef = createRouteRef(); // bulk-import alpha.tsx
+const detailRouteRef = createSubRouteRef({ parent: rootRouteRef, path: '/detail/:id' });
+```
+
+Legacy `createRouteRef({ id: 'xxx' })` from `@backstage/core-plugin-api` also works (orchestrator) but prefer NFS-native.
+
+## PageBlueprint
+
+```tsx
+// bulk-import alpha.tsx
+PageBlueprint.make({
+ params: {
+ path: '/bulk-import', routeRef: rootRouteRef, noHeader: true,
+ loader: () => import('./components').then(({ Router }) => ),
+ },
+});
+```
+
+**noHeader**: RHDH plugins commonly use `noHeader: true` when the plugin renders its own header with breadcrumbs, tabs, or permission controls. Omit it if you want the framework-provided `PluginHeader`.
+
+## ApiBlueprint
+
+```tsx
+// orchestrator alpha.tsx
+ApiBlueprint.make({
+ params: defineParams => defineParams(
+ createApiFactory({
+ api: orchestratorApiRef,
+ deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
+ factory: ({ discoveryApi, identityApi }) =>
+ new OrchestratorClient({ discoveryApi, identityApi }),
+ }),
+ ),
+});
+```
+
+Multiple APIs in one plugin require a `name`: `ApiBlueprint.make({ name: 'npmBackendApi', params: ... })`.
+
+## EntityContentBlueprint and EntityCardBlueprint
+
+```tsx
+// orchestrator alpha.tsx — entity tab
+EntityContentBlueprint.make({
+ name: 'workflows',
+ params: {
+ path: '/workflows', title: 'Workflows',
+ filter: (entity) => Boolean(entity.metadata?.annotations?.['orchestrator.io/workflows']),
+ loader: () => import('./components/CatalogTab').then(m => ),
+ },
+});
+// npm alpha.tsx (community-plugins) — entity card
+EntityCardBlueprint.make({
+ name: 'EntityNpmInfoCard',
+ params: {
+ filter: isNpmAvailable,
+ loader: () => import('./components/EntityNpmInfoCard').then(m => ),
+ },
+});
+```
+
+## Nav items
+
+`NavItemBlueprint` was removed. Nav items are now auto-discovered from `PageBlueprint` extensions with `title`, `icon`, and `routeRef`. No separate blueprint needed.
+
+## Plugin and Module Exports
+
+Default export = plugin. TranslationBlueprint must go in a separate module with `pluginId: 'app'`:
+
+```tsx
+export default createFrontendPlugin({
+ pluginId: 'bulk-import',
+ extensions: [bulkImportApi, bulkImportPage],
+ routes: { root: rootRouteRef, tasks: importHistoryRouteRef },
+});
+export const translationsModule = createFrontendModule({
+ pluginId: 'app',
+ extensions: [TranslationBlueprint.make({
+ name: 'bulk-import-translations', params: { resource: bulkImportTranslations },
+ })],
+});
+```
+
+## Package Exports
+
+### New NFS-only plugins
+
+The NFS plugin is the root export (`.`). No `./alpha`, no `./legacy`, no
+scalprum config, no `dist-scalprum/`. The app discovers and loads it via
+`app.packages`.
+
+```json
+{ "main": "src/index.ts", "types": "src/index.ts",
+ "exports": { ".": "./src/index.ts", "./package.json": "./package.json" },
+ "backstage": { "role": "frontend-plugin", "pluginId": "my-plugin" } }
+```
+
+No scalprum section needed. No `export-dynamic-plugin` step. Package as a
+standard npm package in an OCI image for deployment.
+
+### Migrated plugins (alpha approach — default)
+
+NFS at `./alpha`, legacy unchanged at root. No breaking changes for consumers:
+
+```json
+{ "exports": {
+ ".": "./src/index.ts",
+ "./alpha": "./src/alpha.tsx",
+ "./package.json": "./package.json"
+ },
+ "typesVersions": { "*": { "alpha": ["src/alpha.tsx"] } } }
+```
+
+- `src/index.ts` — existing legacy exports (unchanged)
+- `src/alpha.tsx` — NFS plugin (`createFrontendPlugin`, Blueprints)
+
+The `scalprum.exposedModules` entries some plugins still have are transition
+baggage — NFS apps don't load through scalprum. Remove when legacy is dropped.
+
+## compatWrapper
+
+`compatWrapper` from `@backstage/core-compat-api` bridges legacy components into NFS.
+
+- **Needed**: plugin uses `@material-ui/*` (MUI v4) or legacy route refs (tech-radar uses `compatWrapper` + `convertLegacyRouteRef`).
+- **Not needed**: plugin uses `@mui/*` (MUI v5) and NFS-native route refs (orchestrator, bulk-import skip it).
+- **Not needed for new NFS-only plugins**: no legacy code to bridge.