docs: add frontend system migration skills
Add three new skill documents for migrating to the new Backstage frontend system: - App migration (old app-defaults to new frontend-defaults) - Plugin dual-support (adding new system support while keeping old system working) - Plugin full migration (completely moving to the new frontend system) Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,651 @@
|
||||
---
|
||||
name: app-frontend-system-migration
|
||||
description: Migrate a Backstage app from the old frontend system to the new extension-based frontend system. Use this skill when migrating packages/app from app-defaults to frontend-defaults, converting routes, sidebar, plugins, and other app-level configuration.
|
||||
---
|
||||
|
||||
# App Frontend System Migration Skill
|
||||
|
||||
This skill helps migrate a Backstage app package (`packages/app`) from the old frontend system (`@backstage/app-defaults`) to the new extension-based frontend system (`@backstage/frontend-defaults`).
|
||||
|
||||
The migration follows a two-phase approach: first get the app running in hybrid mode with compatibility helpers, then gradually remove legacy code until the app is fully on the new system.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Old system:** `createApp` from `@backstage/app-defaults`, plugins installed via `<Route>` elements in `FlatRoutes`, manual app shell with `AppRouter` + `Root`
|
||||
- **New system:** `createApp` from `@backstage/frontend-defaults`, plugins installed as `features`, extensions wired into an extension tree, no manual app shell
|
||||
- **Feature discovery:** The new system can automatically discover and install plugins from your app's dependencies — no manual imports needed. This is the default for new apps and should be enabled early in migration.
|
||||
- **Hybrid mode:** The new `createApp` with `convertLegacyAppRoot` and `convertLegacyAppOptions` from `@backstage/core-compat-api` to bridge old code
|
||||
|
||||
## Feature Discovery
|
||||
|
||||
Feature discovery is one of the biggest quality-of-life improvements in the new frontend system. Once enabled, any plugin added as a `package.json` dependency that exports a new-system plugin is automatically detected and installed — no code changes in `App.tsx` needed.
|
||||
|
||||
### Enabling Feature Discovery
|
||||
|
||||
Add this to your `app-config.yaml`:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
packages: all
|
||||
```
|
||||
|
||||
This is the **recommended default** for all apps using the new frontend system. Enable it as early as Phase 1.
|
||||
|
||||
### Filtering Discovered Packages
|
||||
|
||||
You can control which packages are discovered using `include` or `exclude` filters:
|
||||
|
||||
```yaml
|
||||
# Only discover specific packages
|
||||
app:
|
||||
packages:
|
||||
include:
|
||||
- '@backstage/plugin-catalog'
|
||||
- '@backstage/plugin-scaffolder'
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Discover all except specific packages
|
||||
app:
|
||||
packages:
|
||||
exclude:
|
||||
- '@backstage/plugin-techdocs'
|
||||
```
|
||||
|
||||
### Disabling Individual Extensions
|
||||
|
||||
Even with feature discovery enabled, you can disable specific extensions via config without removing the package:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
extensions:
|
||||
- page:techdocs: false
|
||||
- nav-item:search: false
|
||||
```
|
||||
|
||||
### How Discovery Works with Manual Imports
|
||||
|
||||
Plugins that are both manually imported in `features` and auto-discovered are deduplicated — no conflicts. This means you can safely enable discovery while still explicitly importing plugins that need customization via `.withOverrides()`.
|
||||
|
||||
### When NOT to Use Discovery
|
||||
|
||||
Omit `app.packages` from config entirely (not `app.packages: none` — just leave it out) to disable discovery. You might do this if:
|
||||
|
||||
- You need full control over which plugins are loaded
|
||||
- You're in early Phase 1 and want to introduce features one at a time
|
||||
- You're running in an environment where the `@backstage/cli` webpack integration isn't available
|
||||
|
||||
Feature discovery requires that the app is built using `@backstage/cli`, which is the default for all Backstage apps.
|
||||
|
||||
## Phase 1: Minimal Hybrid Migration
|
||||
|
||||
### Step 1: Switch `createApp`
|
||||
|
||||
Replace the import source for `createApp`:
|
||||
|
||||
```typescript
|
||||
// OLD
|
||||
import { createApp } from '@backstage/app-defaults';
|
||||
|
||||
// NEW
|
||||
import { createApp } from '@backstage/frontend-defaults';
|
||||
```
|
||||
|
||||
### Step 2: Convert `createApp` options
|
||||
|
||||
Use `convertLegacyAppOptions` to wrap legacy options (`apis`, `icons`, `featureFlags`, `components`, `themes`) as a feature:
|
||||
|
||||
```tsx
|
||||
import { createApp } from '@backstage/frontend-defaults';
|
||||
import { convertLegacyAppOptions } from '@backstage/core-compat-api';
|
||||
|
||||
const convertedOptionsModule = convertLegacyAppOptions({
|
||||
apis,
|
||||
icons: { alert: AlarmIcon },
|
||||
featureFlags: [
|
||||
{
|
||||
name: 'scaffolder-next-preview',
|
||||
description: 'Preview the new Scaffolder Next',
|
||||
pluginId: '',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
SignInPage: props => (
|
||||
<SignInPage
|
||||
{...props}
|
||||
providers={['guest', 'custom', ...providers]}
|
||||
title="Select a sign-in method"
|
||||
align="center"
|
||||
/>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
features: [convertedOptionsModule],
|
||||
});
|
||||
```
|
||||
|
||||
### Step 3: Convert the app root
|
||||
|
||||
Use `convertLegacyAppRoot` to convert the entire app element tree (routes, sidebar, root elements) into features:
|
||||
|
||||
```tsx
|
||||
import { convertLegacyAppRoot } from '@backstage/core-compat-api';
|
||||
|
||||
const convertedRootFeatures = convertLegacyAppRoot(
|
||||
<>
|
||||
<AlertDisplay transientTimeoutMs={2500} />
|
||||
<OAuthRequestDialog />
|
||||
<AppRouter>
|
||||
<VisitListener />
|
||||
<Root>{routes}</Root>
|
||||
</AppRouter>
|
||||
</>,
|
||||
);
|
||||
|
||||
const app = createApp({
|
||||
features: [convertedOptionsModule, ...convertedRootFeatures],
|
||||
});
|
||||
|
||||
export default app.createRoot();
|
||||
```
|
||||
|
||||
Note: `app.createRoot()` now takes **no arguments** and returns a React **element** (not a component).
|
||||
|
||||
### Step 4: Update `index.tsx`
|
||||
|
||||
The default export is now an element, not a component:
|
||||
|
||||
```typescript
|
||||
// OLD
|
||||
import App from './App';
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
|
||||
|
||||
// NEW
|
||||
import app from './App';
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(app);
|
||||
```
|
||||
|
||||
### Step 5: Update `App.test.tsx`
|
||||
|
||||
Same change for the test file:
|
||||
|
||||
```typescript
|
||||
import app from './App';
|
||||
|
||||
const rendered = render(app);
|
||||
```
|
||||
|
||||
## Phase 2: Full Migration
|
||||
|
||||
Once the app works in hybrid mode, gradually remove legacy code and compatibility helpers.
|
||||
|
||||
### Migrating `createApp` Options
|
||||
|
||||
Legacy options become extensions. App-level extensions (themes, icons, sign-in page, translations) must be installed via `createFrontendModule` targeting `pluginId: 'app'`:
|
||||
|
||||
```typescript
|
||||
import { createFrontendModule } from '@backstage/frontend-plugin-api';
|
||||
|
||||
const app = createApp({
|
||||
features: [
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
lightTheme,
|
||||
signInPage,
|
||||
exampleIconBundle,
|
||||
catalogTranslations,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
#### APIs → `ApiBlueprint`
|
||||
|
||||
In the new system, APIs are extensions that follow **ownership rules**. Understanding which `pluginId` to use when wrapping an API in a `createFrontendModule` is critical — using the wrong one will cause conflict errors at runtime.
|
||||
|
||||
**Ownership rules:**
|
||||
|
||||
- Each API has an **owner plugin**. This can be set explicitly via `pluginId` on the `ApiRef`, or inferred from the `ApiRef` ID string:
|
||||
- Explicit `pluginId` on the ref (recommended) → that plugin owns it
|
||||
- `core.*` ID → owned by the `app` plugin
|
||||
- `plugin.<pluginId>.*` ID → owned by that plugin (e.g. `plugin.catalog.starred-entities` is owned by `catalog`)
|
||||
- Other ID prefixes → the prefix itself is the owner
|
||||
- **Only modules for the owning plugin can provide or override an API.** If plugin `A` tries to provide an API owned by plugin `B`, the system reports an `API_FACTORY_CONFLICT` error and rejects the override.
|
||||
- **Modules for the same plugin override the plugin's own factory.** This is how apps replace default implementations.
|
||||
|
||||
The recommended way to create API refs in the new system uses the builder pattern with an explicit `pluginId`:
|
||||
|
||||
```typescript
|
||||
import { createApiRef } from '@backstage/frontend-plugin-api';
|
||||
|
||||
// Recommended: explicit pluginId makes ownership unambiguous
|
||||
const myApiRef = createApiRef<MyApi>().with({
|
||||
id: 'plugin.my-plugin.my-api',
|
||||
pluginId: 'my-plugin',
|
||||
});
|
||||
|
||||
// Legacy form: ownership inferred from the id string pattern
|
||||
const legacyRef = createApiRef<MyApi>({ id: 'plugin.my-plugin.my-api' });
|
||||
```
|
||||
|
||||
The builder form (`createApiRef<T>().with(...)`) is preferred because the `pluginId` is explicit rather than parsed from the ID string. The `id` must still be globally unique across the app — the `pluginId` is ownership metadata, not a namespace prefix.
|
||||
|
||||
**Practical impact for app migration:**
|
||||
|
||||
Most APIs that were in the old `createApp({ apis: [...] })` are either core APIs (owned by `app`) or plugin-specific APIs. You need to group them into the right modules:
|
||||
|
||||
```typescript
|
||||
import { createFrontendModule, ApiBlueprint } from '@backstage/frontend-plugin-api';
|
||||
|
||||
// Core/app-level APIs → module for 'app'
|
||||
const appApisModule = createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
name: 'scm-integrations',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: scmIntegrationsApiRef,
|
||||
deps: { configApi: configApiRef },
|
||||
factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Overriding a plugin's API → module for THAT plugin
|
||||
const catalogApiOverride = createFrontendModule({
|
||||
pluginId: 'catalog',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: catalogApiRef, // id: 'plugin.catalog'
|
||||
deps: { ... },
|
||||
factory: ({ ... }) => new CustomCatalogClient({ ... }),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
features: [appApisModule, catalogApiOverride],
|
||||
});
|
||||
```
|
||||
|
||||
**Common mistake:** Putting all API overrides in a single `createFrontendModule({ pluginId: 'app' })`. This only works for APIs owned by `app` (i.e. `core.*` APIs like `core.config`, `core.discovery`, etc.). Plugin-specific APIs like `plugin.catalog.*` or `plugin.scaffolder.*` must be overridden using a module with the matching `pluginId`.
|
||||
|
||||
The old `createApp({ apis: [...] })` pattern didn't have these restrictions — any API could be overridden from the app. In the new system, the ownership model is stricter to prevent accidental conflicts between plugins.
|
||||
|
||||
#### Sign-in Page → `SignInPageBlueprint`
|
||||
|
||||
```tsx
|
||||
import { SignInPageBlueprint } from '@backstage/plugin-app-react';
|
||||
|
||||
const signInPage = SignInPageBlueprint.make({
|
||||
params: {
|
||||
loader: async () => props =>
|
||||
(
|
||||
<SignInPage
|
||||
{...props}
|
||||
provider={{
|
||||
id: 'github-auth-provider',
|
||||
title: 'GitHub',
|
||||
message: 'Sign in using GitHub',
|
||||
apiRef: githubAuthApiRef,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Themes → `ThemeBlueprint`
|
||||
|
||||
```tsx
|
||||
import { ThemeBlueprint } from '@backstage/plugin-app-react';
|
||||
|
||||
const customLightTheme = ThemeBlueprint.make({
|
||||
name: 'custom-light',
|
||||
params: {
|
||||
theme: {
|
||||
id: 'custom-light',
|
||||
title: 'Light Theme',
|
||||
variant: 'light',
|
||||
icon: <LightIcon />,
|
||||
Provider: ({ children }) => (
|
||||
<UnifiedThemeProvider theme={customLightTheme} children={children} />
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Icons → `IconBundleBlueprint`
|
||||
|
||||
Icon bundles attach to the `app` plugin's icons input, so they must be installed via a module for `app`:
|
||||
|
||||
```typescript
|
||||
import { IconBundleBlueprint } from '@backstage/plugin-app-react';
|
||||
import { createFrontendModule } from '@backstage/frontend-plugin-api';
|
||||
|
||||
const exampleIconBundle = IconBundleBlueprint.make({
|
||||
name: 'example-bundle',
|
||||
params: {
|
||||
icons: { user: MyOwnUserIcon },
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
features: [
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [exampleIconBundle],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
#### Translations → `TranslationBlueprint`
|
||||
|
||||
Translations attach to the `app` plugin's translations input. Note that `createTranslationMessages` takes a `messages` object with key-value pairs:
|
||||
|
||||
```typescript
|
||||
import { TranslationBlueprint } from '@backstage/plugin-app-react';
|
||||
import { createTranslationMessages } from '@backstage/frontend-plugin-api';
|
||||
import { catalogTranslationRef } from '@backstage/plugin-catalog/alpha';
|
||||
|
||||
const catalogTranslations = TranslationBlueprint.make({
|
||||
name: 'catalog-overrides',
|
||||
params: {
|
||||
resource: createTranslationMessages({
|
||||
ref: catalogTranslationRef,
|
||||
messages: {
|
||||
'indexPage.title': 'Service directory',
|
||||
'indexPage.createButtonTitle': 'Register new service',
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
features: [
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [catalogTranslations],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
For adding full language translations, use `createTranslationResource` instead:
|
||||
|
||||
```typescript
|
||||
import { createTranslationResource } from '@backstage/frontend-plugin-api';
|
||||
|
||||
const userSettingsTranslations = TranslationBlueprint.make({
|
||||
name: 'user-settings-zh',
|
||||
params: {
|
||||
resource: createTranslationResource({
|
||||
ref: userSettingsTranslationRef,
|
||||
translations: {
|
||||
zh: () => import('./userSettings-zh'),
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Migrating Root Elements
|
||||
|
||||
Built-in elements like `AlertDisplay`, `OAuthRequestDialog`, and `VisitListener` are provided by the framework automatically. Remove them from `convertLegacyAppRoot`:
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
const convertedRootFeatures = convertLegacyAppRoot(
|
||||
<>
|
||||
<AlertDisplay transientTimeoutMs={2500} />
|
||||
<OAuthRequestDialog />
|
||||
<AppRouter>
|
||||
<VisitListener />
|
||||
<Root>{routes}</Root>
|
||||
</AppRouter>
|
||||
</>,
|
||||
);
|
||||
|
||||
// After
|
||||
const convertedRootFeatures = convertLegacyAppRoot(routes);
|
||||
```
|
||||
|
||||
Custom root elements use `AppRootElementBlueprint`, and custom wrappers use `AppRootWrapperBlueprint` from `@backstage/plugin-app-react`.
|
||||
|
||||
### Migrating the Sidebar
|
||||
|
||||
Create a `NavContentBlueprint` extension to replace the legacy `Root` component:
|
||||
|
||||
```tsx
|
||||
import { NavContentBlueprint } from '@backstage/plugin-app-react';
|
||||
import { createFrontendModule } from '@backstage/frontend-plugin-api';
|
||||
|
||||
const SidebarContent = NavContentBlueprint.make({
|
||||
params: {
|
||||
component: ({ navItems }) => {
|
||||
const nav = navItems.withComponent(item => (
|
||||
<SidebarItem icon={() => item.icon} to={item.href} text={item.title} />
|
||||
));
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarLogo />
|
||||
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
|
||||
<SidebarSearchModal />
|
||||
</SidebarGroup>
|
||||
<SidebarDivider />
|
||||
<SidebarGroup label="Menu" icon={<MenuIcon />}>
|
||||
{nav.take('page:catalog')}
|
||||
{nav.take('page:scaffolder')}
|
||||
<SidebarDivider />
|
||||
<SidebarScrollWrapper>
|
||||
{nav.rest({ sortBy: 'title' })}
|
||||
</SidebarScrollWrapper>
|
||||
</SidebarGroup>
|
||||
</Sidebar>
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const navModule = createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [SidebarContent],
|
||||
});
|
||||
```
|
||||
|
||||
Nav items are auto-discovered from page extensions. Use `nav.take('page:<pluginId>')` to place specific items, and `nav.rest()` for the remainder. Items that are `take`n are excluded from `rest()`.
|
||||
|
||||
### Migrating Routes
|
||||
|
||||
Remove routes from `FlatRoutes` one at a time. With feature discovery enabled (the recommended default), this is the only step needed — the new plugin version is already discovered and waiting; it was simply overridden by the legacy route which had higher priority:
|
||||
|
||||
```tsx
|
||||
// BEFORE: plugin page as a legacy route
|
||||
const routes = (
|
||||
<FlatRoutes>
|
||||
<Route path="/create" element={<ScaffolderPage />} />
|
||||
<Route path="/catalog" element={<CatalogIndexPage />} />
|
||||
</FlatRoutes>
|
||||
);
|
||||
|
||||
// AFTER: just remove the route — discovery handles the rest
|
||||
const routes = (
|
||||
<FlatRoutes>
|
||||
<Route path="/catalog" element={<CatalogIndexPage />} />
|
||||
</FlatRoutes>
|
||||
);
|
||||
```
|
||||
|
||||
If you are **not** using feature discovery, you need to manually import and install the new plugin version:
|
||||
|
||||
```typescript
|
||||
import scaffolderPlugin from '@backstage/plugin-scaffolder/alpha';
|
||||
|
||||
const app = createApp({
|
||||
features: [scaffolderPlugin, ...convertedRootFeatures],
|
||||
});
|
||||
```
|
||||
|
||||
#### All-at-once rule for plugin routes
|
||||
|
||||
Only one version of a plugin can be active in the app at a time. When legacy routes remain in `FlatRoutes`, `convertLegacyAppRoot` creates a plugin from them using the same plugin ID as the real plugin. This shadow plugin overrides the new-system version entirely. Because of this:
|
||||
|
||||
- **All routes from a single plugin must be removed at the same time.** You cannot migrate one route of a multi-route plugin while keeping others in `FlatRoutes`. For example, if a plugin provides both `/foo` and `/foo/settings`, you must remove both routes together.
|
||||
- **Entity page content counts as part of the plugin.** Many plugins contribute both a top-level route (in `FlatRoutes`) _and_ entity page cards/content (in the entity pages). These are all part of the same plugin. If you remove the route from `FlatRoutes` but keep the entity page card as JSX in your entity pages, the old entity card JSX is now orphaned — and the new plugin may auto-provide its own version of that card, leading to duplicates or missing content.
|
||||
|
||||
The practical consequence: when you migrate a plugin, remove _all_ of its legacy touchpoints — routes _and_ entity page extensions — at the same time.
|
||||
|
||||
### Migrating Entity Pages
|
||||
|
||||
Entity pages are typically the most complex part of the migration because they pull in content from many different plugins. The `entityPage` option in `convertLegacyAppRoot` provides a way to migrate them gradually.
|
||||
|
||||
#### Setting up gradual entity page migration
|
||||
|
||||
Pass your entity pages to `convertLegacyAppRoot`:
|
||||
|
||||
```typescript
|
||||
const convertedRootFeatures = convertLegacyAppRoot(routes, { entityPage });
|
||||
```
|
||||
|
||||
This converts your legacy entity page JSX tree into extensions. The structural pieces (`EntityLayout`, `EntitySwitch`) are preserved, while entity cards and content are converted into extensions that live alongside any auto-discovered new-system cards.
|
||||
|
||||
#### Migrating the catalog plugin itself
|
||||
|
||||
The catalog plugin is special because it owns both the `/catalog` route and the entity page route (`/catalog/:namespace/:kind/:name`). You must migrate both together:
|
||||
|
||||
1. Remove the catalog routes from `FlatRoutes`:
|
||||
|
||||
```tsx
|
||||
const routes = (
|
||||
<FlatRoutes>
|
||||
{/* Remove both catalog routes */}
|
||||
{/* <Route path="/catalog" element={<CatalogIndexPage />} /> */}
|
||||
{/* <Route path="/catalog/:namespace/:kind/:name" element={<CatalogEntityPage />}> */}
|
||||
{/* {entityPage} */}
|
||||
{/* </Route> */}
|
||||
<Route path="/create" element={<ScaffolderPage />} />
|
||||
</FlatRoutes>
|
||||
);
|
||||
```
|
||||
|
||||
2. Install the catalog plugin explicitly (before the converted features so it takes priority):
|
||||
|
||||
```typescript
|
||||
import catalogPlugin from '@backstage/plugin-catalog/alpha';
|
||||
|
||||
const app = createApp({
|
||||
features: [catalogPlugin, convertedOptionsModule, ...convertedRootFeatures],
|
||||
});
|
||||
```
|
||||
|
||||
3. Pass `entityPage` to `convertLegacyAppRoot` (if not already done) so your existing entity page layout is preserved.
|
||||
|
||||
#### Migrating individual plugins out of entity pages
|
||||
|
||||
Once the catalog plugin itself is migrated, you can gradually remove legacy entity content from the entity pages. For each plugin that provides entity cards or content:
|
||||
|
||||
1. **Remove the legacy JSX** from your entity page components (e.g. remove `<EntityAboutCard />`, `<EntityTechdocsContent />`, `<EntityKubernetesContent />`)
|
||||
2. The new-system plugin auto-provides these as `EntityCardBlueprint` / `EntityContentBlueprint` extensions that are discovered automatically
|
||||
|
||||
If you see **duplicate cards** after removing routes but before removing entity page JSX, that's expected — the new plugin is auto-providing cards while the legacy JSX still renders them. Remove the legacy JSX to resolve the duplication.
|
||||
|
||||
#### Migrating entity page tabs
|
||||
|
||||
Tabs in entity pages (the `EntityLayout.Route` entries) are provided by `EntityContentBlueprint` extensions in the new system. As you remove legacy entity content JSX, the tabs are automatically sourced from the new-system extensions. The order and grouping of tabs can be configured via `app-config.yaml`:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
extensions:
|
||||
- page:catalog/entity:
|
||||
config:
|
||||
groups:
|
||||
- overview:
|
||||
title: Overview
|
||||
- documentation:
|
||||
title: Docs
|
||||
```
|
||||
|
||||
#### When is it done?
|
||||
|
||||
Once all plugins contributing to entity pages have been migrated, the `entityPage` option can be removed from `convertLegacyAppRoot`, and the entity page component files in `packages/app/src/components/catalog/` can be deleted.
|
||||
|
||||
### Migrating Route Bindings
|
||||
|
||||
In the new system, plugins should define `defaultTarget` on their external route refs (e.g. `createExternalRouteRef({ defaultTarget: 'scaffolder.root' })`). When plugins set sensible defaults, most `bindRoutes` calls in the app become unnecessary — the routes resolve automatically when the target plugin is installed.
|
||||
|
||||
Review your existing `bindRoutes` configuration and remove any bindings that are already covered by default targets in the plugins. For the remaining cases that need custom bindings, you can still use `bindRoutes` or configure them via static config:
|
||||
|
||||
```yaml
|
||||
# app-config.yaml
|
||||
app:
|
||||
routes:
|
||||
bindings:
|
||||
catalog.createComponent: scaffolder.root
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Purpose | Old Package | New Package |
|
||||
| --------------------- | ---------------------------- | -------------------------------- |
|
||||
| App creation | `@backstage/app-defaults` | `@backstage/frontend-defaults` |
|
||||
| Plugin/extension APIs | `@backstage/core-plugin-api` | `@backstage/frontend-plugin-api` |
|
||||
| App components | `@backstage/core-components` | `@backstage/ui` + CSS Modules |
|
||||
| Compatibility bridge | — | `@backstage/core-compat-api` |
|
||||
| App blueprints | — | `@backstage/plugin-app-react` |
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
### Phase 1 (Hybrid)
|
||||
|
||||
1. [ ] Add `@backstage/frontend-defaults` and `@backstage/core-compat-api` dependencies
|
||||
2. [ ] Switch `createApp` import to `@backstage/frontend-defaults`
|
||||
3. [ ] Enable feature discovery: add `app.packages: all` to `app-config.yaml`
|
||||
4. [ ] Wrap legacy options with `convertLegacyAppOptions`
|
||||
5. [ ] Wrap app element tree with `convertLegacyAppRoot`
|
||||
6. [ ] Change `app.createRoot()` to take no arguments
|
||||
7. [ ] Update `index.tsx` to render element instead of component
|
||||
8. [ ] Update `App.test.tsx`
|
||||
9. [ ] Verify app starts and works in hybrid mode
|
||||
|
||||
### Phase 2 (Full Migration)
|
||||
|
||||
1. [ ] Convert APIs to `ApiBlueprint` extensions
|
||||
2. [ ] Convert sign-in page to `SignInPageBlueprint`
|
||||
3. [ ] Convert themes to `ThemeBlueprint`
|
||||
4. [ ] Convert icons to `IconBundleBlueprint`
|
||||
5. [ ] Convert translations to `TranslationBlueprint`
|
||||
6. [ ] Migrate sidebar to `NavContentBlueprint`
|
||||
7. [ ] Remove built-in root elements (`AlertDisplay`, `OAuthRequestDialog`, etc.)
|
||||
8. [ ] Migrate routes from `FlatRoutes` to plugin features (one plugin at a time, removing all routes + entity content for each plugin together)
|
||||
9. [ ] Set up entity page migration with `convertLegacyAppRoot(routes, { entityPage })`
|
||||
10. [ ] Migrate catalog plugin: remove catalog routes from `FlatRoutes`, install `catalogPlugin` as a feature
|
||||
11. [ ] Gradually remove legacy entity card/content JSX as each contributing plugin is migrated
|
||||
12. [ ] Remove `entityPage` option and legacy entity page component files
|
||||
13. [ ] Remove `convertLegacyAppRoot` and `convertLegacyAppOptions` calls
|
||||
14. [ ] Remove `@backstage/app-defaults`, `@backstage/core-app-api` dependencies
|
||||
15. [ ] Run `yarn tsc` and `yarn lint` to verify
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Install `@backstage/plugin-app-visualizer` to inspect the extension tree at `/visualizer`
|
||||
- Duplicate entity cards: remove legacy card JSX from entity pages — plugins auto-provide them
|
||||
- `Invalid element inside FlatRoutes`: push `FeatureFlagged`/`RequirePermissions` wrappers into plugin code instead of the route table
|
||||
|
||||
## Reference
|
||||
|
||||
- [App migration guide](https://backstage.io/docs/frontend-system/building-apps/migrating)
|
||||
- [Architecture overview](https://backstage.io/docs/frontend-system/architecture/index)
|
||||
- [Extension blueprints](https://backstage.io/docs/frontend-system/building-plugins/common-extension-blueprints)
|
||||
- [Installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins)
|
||||
@@ -4,6 +4,21 @@
|
||||
"name": "mui-to-bui-migration",
|
||||
"description": "Migrate Backstage plugins from Material-UI (MUI) to Backstage UI (BUI). Use this skill when migrating components, updating imports, replacing styling patterns, or converting MUI components to their BUI equivalents.",
|
||||
"files": ["SKILL.md"]
|
||||
},
|
||||
{
|
||||
"name": "app-frontend-system-migration",
|
||||
"description": "Migrate a Backstage app from the old frontend system to the new extension-based frontend system. Use this skill when migrating packages/app from createApp (app-defaults) to createApp (frontend-defaults), converting routes, sidebar, plugins, and other app-level configuration.",
|
||||
"files": ["SKILL.md"]
|
||||
},
|
||||
{
|
||||
"name": "plugin-new-frontend-system-support",
|
||||
"description": "Add new frontend system support to an existing Backstage plugin while maintaining backward compatibility with the old system. Use this skill when adding an alpha entry point, creating PageBlueprint/SubPageBlueprint extensions, and implementing the dual-header pattern (old Header vs new HeaderPage/PluginHeader).",
|
||||
"files": ["SKILL.md"]
|
||||
},
|
||||
{
|
||||
"name": "plugin-full-frontend-system-migration",
|
||||
"description": "Fully migrate an existing Backstage plugin to the new frontend system, removing all old system dependencies. Use this skill when converting a plugin to exclusively use @backstage/frontend-plugin-api, replacing internal routing with PageBlueprint/SubPageBlueprint, adopting Backstage UI headers and page layout, and removing @backstage/core-plugin-api usage.",
|
||||
"files": ["SKILL.md"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
---
|
||||
name: plugin-full-frontend-system-migration
|
||||
description: Fully migrate an existing Backstage plugin to the new frontend system, removing all old system dependencies. Use this skill when converting a plugin to exclusively use @backstage/frontend-plugin-api, replacing internal routing with PageBlueprint/SubPageBlueprint, adopting Backstage UI headers and page layout, and removing @backstage/core-plugin-api usage.
|
||||
---
|
||||
|
||||
# Full Plugin Migration to the New Frontend System
|
||||
|
||||
This skill helps fully migrate an existing Backstage plugin from the old frontend system to the new one. Unlike adding dual support (which keeps the old system working), this is a complete migration that removes all `@backstage/core-plugin-api` usage and makes the plugin work exclusively with the new frontend system.
|
||||
|
||||
This is the preferred approach for internal plugins that are only used in a single app, since there is no need to maintain backward compatibility. It can also be used for published plugins when you're ready to drop old system support entirely.
|
||||
|
||||
## Key Differences from Dual Support
|
||||
|
||||
| Aspect | Dual Support | Full Migration |
|
||||
| ---------------- | ------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| Entry point | Old `src/plugin.ts` + new `src/alpha.tsx` | Single `src/plugin.tsx` |
|
||||
| Plugin creation | Both `createPlugin` and `createFrontendPlugin` | Only `createFrontendPlugin` |
|
||||
| Core dependency | Keeps `@backstage/core-plugin-api` | Removes it, uses only `@backstage/frontend-plugin-api` |
|
||||
| Route refs | Reuses `@backstage/core-plugin-api` refs directly | Uses `createRouteRef` from `@backstage/frontend-plugin-api` |
|
||||
| Page shell | Old pages keep `Page`/`Header`, NFS pages skip it | All pages rely on framework's `PageLayout`/`PluginHeader` |
|
||||
| Internal routing | May keep legacy `<Route>` trees in components | Replaced with `SubPageBlueprint` tabbed pages |
|
||||
| Compatibility | Not needed | Not needed |
|
||||
|
||||
## Step 1: Migrate Route Refs
|
||||
|
||||
Replace `createRouteRef` / `createSubRouteRef` / `createExternalRouteRef` imports:
|
||||
|
||||
```typescript
|
||||
// OLD (src/routes.ts)
|
||||
import {
|
||||
createRouteRef,
|
||||
createSubRouteRef,
|
||||
createExternalRouteRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({ id: 'my-plugin' });
|
||||
export const detailsRouteRef = createSubRouteRef({
|
||||
id: 'my-plugin-details',
|
||||
parent: rootRouteRef,
|
||||
path: '/details/:id',
|
||||
});
|
||||
export const externalDocsRouteRef = createExternalRouteRef({ id: 'docs' });
|
||||
|
||||
// NEW (src/routes.ts)
|
||||
import {
|
||||
createRouteRef,
|
||||
createSubRouteRef,
|
||||
createExternalRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
export const rootRouteRef = createRouteRef();
|
||||
export const detailsRouteRef = createSubRouteRef({
|
||||
path: '/details/:id',
|
||||
parent: rootRouteRef,
|
||||
});
|
||||
export const externalDocsRouteRef = createExternalRouteRef({
|
||||
defaultTarget: 'techdocs.docRoot',
|
||||
});
|
||||
```
|
||||
|
||||
Key differences:
|
||||
|
||||
- `createRouteRef()` no longer takes an `id` — the ID is derived from the extension
|
||||
- `createSubRouteRef` path must start with `/` and must not end with `/`
|
||||
- `createExternalRouteRef()` no longer takes an `id` or `optional` flag
|
||||
|
||||
### Set Default Targets for External Route Refs
|
||||
|
||||
When migrating external route refs, always set `defaultTarget` to the most common binding target. This removes the need for apps to explicitly bind routes via `bindRoutes` for standard plugin combinations:
|
||||
|
||||
```typescript
|
||||
export const createComponentRouteRef = createExternalRouteRef({
|
||||
defaultTarget: 'scaffolder.root',
|
||||
});
|
||||
|
||||
export const viewTechDocRouteRef = createExternalRouteRef({
|
||||
params: ['namespace', 'kind', 'name'],
|
||||
defaultTarget: 'techdocs.docRoot',
|
||||
});
|
||||
|
||||
export const catalogEntityRouteRef = createExternalRouteRef({
|
||||
params: ['namespace', 'kind', 'name'],
|
||||
defaultTarget: 'catalog.catalogEntity',
|
||||
});
|
||||
```
|
||||
|
||||
The `defaultTarget` string uses the `<pluginId>.<routeName>` format, where `routeName` matches a key in the target plugin's `routes` map. The default is only activated when the target plugin is installed — otherwise the route stays unbound and `useRouteRef` returns `undefined`.
|
||||
|
||||
This is especially important for a full migration because in the old system, apps typically had explicit `bindRoutes` calls. With default targets, most of those bindings become unnecessary, improving the plug-and-play experience.
|
||||
|
||||
## Step 2: Migrate the Plugin Definition
|
||||
|
||||
Replace `src/plugin.ts` with a `createFrontendPlugin`-based definition:
|
||||
|
||||
```tsx
|
||||
// NEW (src/plugin.tsx)
|
||||
import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
|
||||
import { RiToolsLine } from '@remixicon/react';
|
||||
import { rootRouteRef, externalDocsRouteRef } from './routes';
|
||||
import { myPage } from './extensions';
|
||||
import { myPluginApi } from './apis';
|
||||
|
||||
export default createFrontendPlugin({
|
||||
pluginId: 'my-plugin',
|
||||
title: 'My Plugin',
|
||||
icon: <RiToolsLine />,
|
||||
info: {
|
||||
packageJson: () => import('../package.json'),
|
||||
},
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
},
|
||||
externalRoutes: {
|
||||
docs: externalDocsRouteRef,
|
||||
},
|
||||
extensions: [myPluginApi, myPage],
|
||||
});
|
||||
```
|
||||
|
||||
For the plugin `icon`, prefer using [Remix Icons](https://remixicon.com/) from `@remixicon/react`. If the plugin already has an existing MUI icon, it can be kept with `fontSize="inherit"` (e.g. `<CategoryIcon fontSize="inherit" />`), but for new icons Remix is the recommended choice.
|
||||
|
||||
Since this is the only entry point now, export it as default from `src/index.ts` or update `package.json` exports accordingly. If the plugin was previously consumed via its main entry point, you can make the main entry point export the new plugin:
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./package.json": "./package.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/index.ts
|
||||
export { default } from './plugin';
|
||||
export { rootRouteRef } from './routes';
|
||||
```
|
||||
|
||||
## Step 3: Migrate API Factories to `ApiBlueprint`
|
||||
|
||||
```typescript
|
||||
// OLD
|
||||
import {
|
||||
createApiFactory,
|
||||
discoveryApiRef,
|
||||
fetchApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
export const myApiFactory = createApiFactory({
|
||||
api: myPluginApiRef,
|
||||
deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
|
||||
factory: ({ discoveryApi, fetchApi }) =>
|
||||
new MyPluginClient({ discoveryApi, fetchApi }),
|
||||
});
|
||||
|
||||
// NEW (src/apis.ts)
|
||||
import {
|
||||
ApiBlueprint,
|
||||
discoveryApiRef,
|
||||
fetchApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { myPluginApiRef } from './api';
|
||||
|
||||
export const myPluginApi = ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: myPluginApiRef,
|
||||
deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
|
||||
factory: ({ discoveryApi, fetchApi }) =>
|
||||
new MyPluginClient({ discoveryApi, fetchApi }),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Also update the API ref creation to the new builder pattern with explicit `pluginId`:
|
||||
|
||||
```typescript
|
||||
// OLD
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export const myPluginApiRef = createApiRef<MyPluginApi>({
|
||||
id: 'plugin.my-plugin.client',
|
||||
});
|
||||
|
||||
// NEW (recommended builder pattern with explicit pluginId)
|
||||
import { createApiRef } from '@backstage/frontend-plugin-api';
|
||||
|
||||
export const myPluginApiRef = createApiRef<MyPluginApi>().with({
|
||||
id: 'plugin.my-plugin.client',
|
||||
pluginId: 'my-plugin',
|
||||
});
|
||||
```
|
||||
|
||||
The builder form (`createApiRef<T>().with(...)`) is preferred because ownership is explicit via `pluginId` rather than parsed from the ID string. The `id` must still be globally unique across the app — the `pluginId` is ownership metadata, not a namespace prefix.
|
||||
|
||||
### API Ownership and Override Rules
|
||||
|
||||
The new system enforces **API ownership** — only the owning plugin (or a module targeting it) can provide or override a given API. Ownership is determined by:
|
||||
|
||||
1. The explicit `pluginId` on the `ApiRef` (if set via the builder pattern)
|
||||
2. Falling back to inference from the `ApiRef` ID string:
|
||||
- `plugin.<pluginId>.*` → owned by that plugin
|
||||
- `core.*` → owned by the `app` plugin
|
||||
|
||||
If app adopters want to replace your plugin's default API implementation, they must use a `createFrontendModule` with `pluginId` matching your plugin — they cannot override it from a different plugin or from a generic `app` module. This is a stricter model than the old system where any API could be overridden from the app's `apis` array.
|
||||
|
||||
## Step 4: Migrate Pages to `PageBlueprint`
|
||||
|
||||
### Simple Page (No Sub-Routes)
|
||||
|
||||
```tsx
|
||||
// src/extensions.tsx
|
||||
import { PageBlueprint } from '@backstage/frontend-plugin-api';
|
||||
import { rootRouteRef } from './routes';
|
||||
|
||||
export const myPage = PageBlueprint.make({
|
||||
params: {
|
||||
path: '/my-plugin',
|
||||
routeRef: rootRouteRef,
|
||||
loader: () => import('./components/MyPage').then(m => <m.MyPage />),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The `MyPage` component should **not** include `Page`, `Header`, or `PageWithHeader` from `@backstage/core-components`. The framework's `PageLayout` renders `PluginHeader` automatically.
|
||||
|
||||
The `title` and `icon` params on `PageBlueprint` are only needed if they should differ from the plugin's own `title` and `icon` (set in `createFrontendPlugin`). If omitted, the plugin-level values are used.
|
||||
|
||||
### Page with `HeaderPage` for Custom Actions
|
||||
|
||||
If your page needs a subtitle or action buttons below the framework header, use `HeaderPage` from `@backstage/ui`:
|
||||
|
||||
```tsx
|
||||
// src/components/MyPage/MyPage.tsx
|
||||
import { HeaderPage } from '@backstage/ui';
|
||||
import { Content } from '@backstage/core-components';
|
||||
|
||||
export function MyPage() {
|
||||
return (
|
||||
<>
|
||||
<HeaderPage
|
||||
title="Subtitle or description"
|
||||
customActions={
|
||||
<>
|
||||
<CreateButton title="Create" to="/my-plugin/create" />
|
||||
<SupportButton>Help text</SupportButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Content>
|
||||
<MyPageContent />
|
||||
</Content>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Page Without Header
|
||||
|
||||
For pages that manage their own layout entirely (e.g. home page, dashboards), set `noHeader: true`:
|
||||
|
||||
```tsx
|
||||
export const myPage = PageBlueprint.make({
|
||||
params: {
|
||||
path: '/my-plugin',
|
||||
routeRef: rootRouteRef,
|
||||
noHeader: true,
|
||||
loader: () => import('./components/MyPage').then(m => <m.MyPage />),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Step 5: Replace Internal Routing with Sub-Pages
|
||||
|
||||
This is one of the biggest changes in a full migration. Old plugins often use React Router `<Route>` trees inside a router component to handle internal navigation. The new system replaces this with `SubPageBlueprint` for tabbed sub-pages.
|
||||
|
||||
### Old Pattern: Internal Router
|
||||
|
||||
```tsx
|
||||
// OLD — plugin owns its own routing
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
export function MyPluginRouter() {
|
||||
return (
|
||||
<Page themeId="tool">
|
||||
<Header title="My Plugin" />
|
||||
<HeaderTabs
|
||||
tabs={[
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
]}
|
||||
/>
|
||||
<Content>
|
||||
<Routes>
|
||||
<Route path="/" element={<OverviewPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### New Pattern: `PageBlueprint` + `SubPageBlueprint`
|
||||
|
||||
```tsx
|
||||
// src/extensions.tsx
|
||||
import {
|
||||
PageBlueprint,
|
||||
SubPageBlueprint,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
// Parent page WITHOUT a loader — uses built-in tabbed rendering
|
||||
export const myPluginPage = PageBlueprint.make({
|
||||
params: {
|
||||
path: '/my-plugin',
|
||||
routeRef: rootRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
export const overviewSubPage = SubPageBlueprint.make({
|
||||
name: 'overview',
|
||||
params: {
|
||||
path: 'overview',
|
||||
title: 'Overview',
|
||||
loader: () =>
|
||||
import('./components/OverviewPage').then(m => <m.OverviewPageContent />),
|
||||
},
|
||||
});
|
||||
|
||||
export const settingsSubPage = SubPageBlueprint.make({
|
||||
name: 'settings',
|
||||
params: {
|
||||
path: 'settings',
|
||||
title: 'Settings',
|
||||
loader: () =>
|
||||
import('./components/SettingsPage').then(m => <m.SettingsPageContent />),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
How this works:
|
||||
|
||||
- `PageBlueprint` **without a `loader`** automatically renders its sub-pages as tabs
|
||||
- The first sub-page becomes the default (index redirect)
|
||||
- Each `SubPageBlueprint` gets a tab in the header with its `title`
|
||||
- Sub-page `path` values are **relative** (no leading `/`)
|
||||
- Sub-page components render **content only** — no `Page`, `Header`, or `HeaderTabs`
|
||||
|
||||
If the sub-page content needs padding, use `Container` from `@backstage/ui` as a wrapper inside the component.
|
||||
|
||||
### When NOT to Use Sub-Pages
|
||||
|
||||
Not all internal routing maps to tabs. Use `SubPageBlueprint` when:
|
||||
|
||||
- The sub-routes represent top-level tabs/sections of the plugin
|
||||
- Users navigate between them via the header
|
||||
|
||||
Keep internal routing within a `PageBlueprint` `loader` when:
|
||||
|
||||
- Routes are detail/drill-down pages (e.g. `/my-plugin/items/:id`)
|
||||
- The routing is deeply nested or dynamic
|
||||
|
||||
In those cases, use a `PageBlueprint` **with** a `loader` that handles its own `Routes`:
|
||||
|
||||
```tsx
|
||||
export const myPage = PageBlueprint.make({
|
||||
params: {
|
||||
path: '/my-plugin',
|
||||
routeRef: rootRouteRef,
|
||||
loader: () => import('./components/Router').then(m => <m.MyPluginRouter />),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Step 6: Update Hooks and Imports
|
||||
|
||||
Replace all `@backstage/core-plugin-api` imports with `@backstage/frontend-plugin-api`:
|
||||
|
||||
```typescript
|
||||
// OLD
|
||||
import { useApi, useRouteRef, configApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
// NEW
|
||||
import {
|
||||
useApi,
|
||||
useRouteRef,
|
||||
configApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
```
|
||||
|
||||
### `useRouteRef` Behavior Change
|
||||
|
||||
In the new system, `useRouteRef` may return `undefined` for external route refs that aren't bound. Handle this:
|
||||
|
||||
```typescript
|
||||
// OLD — throws if not bound
|
||||
const docsLink = useRouteRef(externalDocsRouteRef);
|
||||
// Always a function
|
||||
|
||||
// NEW — returns undefined if not bound
|
||||
const docsLink = useRouteRef(externalDocsRouteRef);
|
||||
if (docsLink) {
|
||||
// render link
|
||||
}
|
||||
```
|
||||
|
||||
### Common Import Mappings
|
||||
|
||||
| Old Import (`@backstage/core-plugin-api`) | New Import (`@backstage/frontend-plugin-api`) |
|
||||
| ----------------------------------------- | --------------------------------------------------- |
|
||||
| `createPlugin` | `createFrontendPlugin` |
|
||||
| `createRouteRef` | `createRouteRef` |
|
||||
| `createSubRouteRef` | `createSubRouteRef` |
|
||||
| `createExternalRouteRef` | `createExternalRouteRef` |
|
||||
| `createApiRef` | `createApiRef` |
|
||||
| `createApiFactory` | `ApiBlueprint.make` |
|
||||
| `useApi` | `useApi` |
|
||||
| `useRouteRef` | `useRouteRef` |
|
||||
| `configApiRef` | `configApiRef` |
|
||||
| `discoveryApiRef` | `discoveryApiRef` |
|
||||
| `fetchApiRef` | `fetchApiRef` |
|
||||
| `identityApiRef` | `identityApiRef` |
|
||||
| `storageApiRef` | `storageApiRef` |
|
||||
| `analyticsApiRef` | `analyticsApiRef` |
|
||||
| `createRoutableExtension` | `PageBlueprint.make` |
|
||||
| `createComponentExtension` | Depends on context — blueprint or `createExtension` |
|
||||
|
||||
## Step 7: Remove Old System Code
|
||||
|
||||
1. Delete `src/plugin.ts` (old `createPlugin`)
|
||||
2. Delete any `createRoutableExtension` / `createComponentExtension` usage
|
||||
3. Remove `Page`, `Header`, `PageWithHeader` wrapping from page components
|
||||
4. Remove `HeaderTabs` if replaced by `SubPageBlueprint` tabs
|
||||
5. Remove internal `<Routes>`/`<Route>` trees if replaced by sub-pages
|
||||
6. Remove `@backstage/core-plugin-api` from `package.json` `dependencies`
|
||||
7. Remove `@backstage/core-compat-api` from `package.json` `dependencies` if present
|
||||
|
||||
## Step 8: Update Page Components for BUI
|
||||
|
||||
With the full migration, page components should use `@backstage/ui` components and patterns. See the `mui-to-bui-migration` skill for detailed component migration guidance.
|
||||
|
||||
Key page-level changes:
|
||||
|
||||
- Replace `PageWithHeader` / `Page` + `Header` with framework-provided `PluginHeader` (automatic via `PageLayout`)
|
||||
- Use `HeaderPage` from `@backstage/ui` for optional subtitle/custom actions
|
||||
- Use `Content` from `@backstage/core-components` for page body padding (this is still used even in NFS pages)
|
||||
- Replace `ContentHeader` with `HeaderPage`'s `customActions` prop
|
||||
- Replace `HeaderTabs` with `SubPageBlueprint` (tabs are rendered by the framework)
|
||||
|
||||
## Real Example: Auth Plugin (Fully Migrated)
|
||||
|
||||
The `@backstage/plugin-auth` plugin is a fully migrated example with no `@backstage/core-plugin-api` dependency:
|
||||
|
||||
```tsx
|
||||
// plugins/auth/src/routes.ts
|
||||
import { createRouteRef } from '@backstage/frontend-plugin-api';
|
||||
|
||||
export const rootRouteRef = createRouteRef();
|
||||
|
||||
// plugins/auth/src/plugin.tsx
|
||||
import {
|
||||
createFrontendPlugin,
|
||||
PageBlueprint,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { rootRouteRef } from './routes';
|
||||
|
||||
export const AuthPage = PageBlueprint.make({
|
||||
params: {
|
||||
path: '/oauth2',
|
||||
routeRef: rootRouteRef,
|
||||
loader: () => import('./components/Router').then(m => <m.Router />),
|
||||
},
|
||||
});
|
||||
|
||||
export default createFrontendPlugin({
|
||||
pluginId: 'auth',
|
||||
extensions: [AuthPage],
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Real Example: Scaffolder Sub-Pages
|
||||
|
||||
The scaffolder plugin demonstrates the sub-page pattern (though it still has dual support — the pattern itself is what a full migration targets):
|
||||
|
||||
```tsx
|
||||
// PageBlueprint WITHOUT loader — framework renders tabs
|
||||
export const scaffolderPage = PageBlueprint.make({
|
||||
params: {
|
||||
path: '/create',
|
||||
routeRef: rootRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
// Sub-pages with content only
|
||||
export const templatesSubPage = SubPageBlueprint.make({
|
||||
name: 'templates',
|
||||
params: {
|
||||
path: 'templates',
|
||||
title: 'Templates',
|
||||
loader: () => import('./TemplatesPage').then(m => <m.TemplatesSubPage />),
|
||||
},
|
||||
});
|
||||
|
||||
export const tasksSubPage = SubPageBlueprint.make({
|
||||
name: 'tasks',
|
||||
params: {
|
||||
path: 'tasks',
|
||||
title: 'Tasks',
|
||||
loader: () => import('./TasksPage').then(m => <m.TasksSubPage />),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
1. [ ] Migrate route refs to `@backstage/frontend-plugin-api` (`createRouteRef`, `createSubRouteRef`, `createExternalRouteRef`)
|
||||
2. [ ] Replace `createPlugin` with `createFrontendPlugin`
|
||||
3. [ ] Convert all API factories to `ApiBlueprint` extensions
|
||||
4. [ ] Convert pages to `PageBlueprint`
|
||||
5. [ ] Replace internal tab routing with `SubPageBlueprint` where appropriate
|
||||
6. [ ] Remove `Page`/`Header`/`PageWithHeader` from page components
|
||||
7. [ ] Add `HeaderPage` from `@backstage/ui` where subtitle/custom actions are needed
|
||||
8. [ ] Replace `HeaderTabs` with `SubPageBlueprint` tabs
|
||||
9. [ ] Update all `@backstage/core-plugin-api` imports to `@backstage/frontend-plugin-api`
|
||||
10. [ ] Handle `useRouteRef` possibly returning `undefined`
|
||||
11. [ ] Remove `src/plugin.ts` (old system entry point)
|
||||
12. [ ] Remove `src/alpha.tsx` if it existed (merge into main entry)
|
||||
13. [ ] Remove `@backstage/core-plugin-api` from `package.json` dependencies
|
||||
14. [ ] Remove `@backstage/core-compat-api` from `package.json` dependencies
|
||||
15. [ ] Update `package.json` exports (remove `./alpha` if merged into main)
|
||||
16. [ ] Run `yarn tsc` to check for type errors
|
||||
17. [ ] Run `yarn lint` to check for missing dependencies
|
||||
18. [ ] Run `yarn build:api-reports` to update API reports (if the project uses API reports)
|
||||
19. [ ] Test in a new-system app (`packages/app`)
|
||||
|
||||
## Reference
|
||||
|
||||
- [Plugin migration guide](https://backstage.io/docs/frontend-system/building-plugins/migrating)
|
||||
- [Extension blueprints](https://backstage.io/docs/frontend-system/building-plugins/common-extension-blueprints)
|
||||
- [Utility APIs](https://backstage.io/docs/frontend-system/utility-apis/creating)
|
||||
- MUI to BUI migration: `mui-to-bui-migration` skill
|
||||
@@ -0,0 +1,422 @@
|
||||
---
|
||||
name: plugin-new-frontend-system-support
|
||||
description: Add new frontend system support to an existing Backstage plugin while maintaining backward compatibility with the old system. Use this skill when adding an alpha entry point, creating PageBlueprint/SubPageBlueprint extensions, and implementing the dual-header pattern (old Header vs new HeaderPage/PluginHeader).
|
||||
---
|
||||
|
||||
# Adding New Frontend System Support to an Existing Plugin
|
||||
|
||||
This skill helps add new frontend system (NFS) support to an existing Backstage plugin while keeping the old system fully functional. The result is a plugin that works in both old and new apps via a dual entry point pattern.
|
||||
|
||||
This is the preferred approach for published plugins or plugins that are used by external parties, since it avoids forcing consumers to migrate their app before they are ready.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Dual entry point:** The plugin keeps its existing `src/plugin.ts` (old system) and adds a new `src/alpha.tsx` (new system)
|
||||
- **Old system:** `createPlugin` from `@backstage/core-plugin-api`, pages via `createRoutableExtension`, routes defined in the app
|
||||
- **New system:** `createFrontendPlugin` from `@backstage/frontend-plugin-api`, pages via `PageBlueprint`, routes owned by the plugin
|
||||
- **Dual header pattern:** Old system uses `Page`/`Header`/`PageWithHeader` from `@backstage/core-components`; new system relies on the framework's `PageLayout` which renders `PluginHeader` from `@backstage/ui` — so NFS page components should NOT include their own page shell
|
||||
|
||||
## Step 1: Create the Alpha Entry Point
|
||||
|
||||
Create `src/alpha.tsx` (or `src/alpha/index.ts` for larger plugins) with a `createFrontendPlugin` default export:
|
||||
|
||||
```tsx
|
||||
// src/alpha.tsx
|
||||
import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
|
||||
import { RiToolsLine } from '@remixicon/react';
|
||||
import { rootRouteRef } from './routes';
|
||||
|
||||
const myPage = PageBlueprint.make({
|
||||
params: {
|
||||
path: '/my-plugin',
|
||||
routeRef: rootRouteRef,
|
||||
loader: () => import('./components/MyPage').then(m => <m.NfsMyPage />),
|
||||
},
|
||||
});
|
||||
|
||||
export default createFrontendPlugin({
|
||||
pluginId: 'my-plugin',
|
||||
title: 'My Plugin',
|
||||
icon: <RiToolsLine />,
|
||||
extensions: [myPage],
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
},
|
||||
externalRoutes: {
|
||||
// same external routes as the old plugin
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
For the plugin `icon`, prefer using [Remix Icons](https://remixicon.com/) from `@remixicon/react`. If the plugin already has an existing MUI icon, it can be kept with `fontSize="inherit"` (e.g. `<CategoryIcon fontSize="inherit" />`), but for new icons Remix is the recommended choice.
|
||||
|
||||
The `title` and `icon` params on `PageBlueprint` are only needed if they should differ from the plugin's own `title` and `icon` (set in `createFrontendPlugin`). If omitted, the plugin-level values are used.
|
||||
|
||||
For larger plugins, organize into `src/alpha/plugin.tsx`, `src/alpha/pages.tsx`, `src/alpha/extensions.tsx`, etc., and re-export from `src/alpha/index.ts`.
|
||||
|
||||
## Step 2: Update `package.json` Exports
|
||||
|
||||
Add the `./alpha` subpath export and its `typesVersions` entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./alpha": "./src/alpha.tsx",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"alpha": ["src/alpha.tsx"],
|
||||
"package.json": ["package.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Apps import the new plugin as:
|
||||
|
||||
```typescript
|
||||
import myPlugin from '@backstage/plugin-my-plugin/alpha';
|
||||
```
|
||||
|
||||
## Step 3: Implement the Dual Header Pattern
|
||||
|
||||
The critical difference between old and new system page components is the **page shell**. In the old system, each page renders its own `Page` + `Header` (or `PageWithHeader`) wrapper. In the new system, the framework's `PageLayout` provides the header via `PluginHeader` automatically — so the NFS page component must **not** include its own page shell.
|
||||
|
||||
### Pattern A: Separate Components (Recommended for Simple Pages)
|
||||
|
||||
Create two exported components — one for each system:
|
||||
|
||||
```tsx
|
||||
// src/components/MyPage/MyPage.tsx
|
||||
import {
|
||||
Content,
|
||||
PageWithHeader,
|
||||
ContentHeader,
|
||||
SupportButton,
|
||||
} from '@backstage/core-components';
|
||||
import { HeaderPage } from '@backstage/ui';
|
||||
|
||||
// Used by the OLD system — includes the full page shell
|
||||
export function MyPage() {
|
||||
return (
|
||||
<PageWithHeader title="My Plugin" themeId="tool">
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<SupportButton>Some help text</SupportButton>
|
||||
</ContentHeader>
|
||||
<MyPageContent />
|
||||
</Content>
|
||||
</PageWithHeader>
|
||||
);
|
||||
}
|
||||
|
||||
// Used by the NEW system — no page shell, just content
|
||||
// The framework's PageLayout/PluginHeader provides the title and header
|
||||
export function NfsMyPage() {
|
||||
return (
|
||||
<>
|
||||
<HeaderPage
|
||||
title="My Plugin Subtitle"
|
||||
customActions={<SupportButton>Some help text</SupportButton>}
|
||||
/>
|
||||
<Content>
|
||||
<MyPageContent />
|
||||
</Content>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key differences in the NFS variant:
|
||||
|
||||
- **No `Page`/`PageWithHeader`** — the framework provides the outer page shell
|
||||
- **`HeaderPage` from `@backstage/ui`** is optional — use it only if you need a subtitle or custom actions below the framework header
|
||||
- **No `ContentHeader`** — actions move to `HeaderPage`'s `customActions` prop
|
||||
- The shared `<MyPageContent />` component contains the actual page body
|
||||
|
||||
### Forwarding Customization Props
|
||||
|
||||
If the old system exports a page component with props for customization (e.g. `<CatalogIndexPage actions={...} filters={...} />`), the NFS variant should accept the same props. Export the NFS variant with the same component name from the `./alpha` entry point, so that app adopters can customize it the same way:
|
||||
|
||||
```tsx
|
||||
// src/components/MyPage/MyPage.tsx
|
||||
export interface MyPageProps {
|
||||
actions?: ReactNode;
|
||||
filters?: ReactNode;
|
||||
}
|
||||
|
||||
// Old system — exported from src/index.ts
|
||||
export function MyPage(props: MyPageProps) {
|
||||
return (
|
||||
<PageWithHeader title="My Plugin" themeId="tool">
|
||||
<Content>
|
||||
<MyPageContent {...props} />
|
||||
</Content>
|
||||
</PageWithHeader>
|
||||
);
|
||||
}
|
||||
|
||||
// NFS variant — exported from src/alpha.tsx
|
||||
export function NfsMyPage(props: MyPageProps) {
|
||||
return (
|
||||
<Content>
|
||||
<MyPageContent {...props} />
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The NFS variant is then wired into the `PageBlueprint` loader, and the component itself is re-exported from `./alpha` so adopters can use `.withOverrides()` to pass custom props:
|
||||
|
||||
```typescript
|
||||
// src/alpha.tsx
|
||||
export { NfsMyPage as MyPage } from './components/MyPage';
|
||||
```
|
||||
|
||||
This way, the old `MyPage` is available from the main entry point, and the same name `MyPage` is available from `./alpha` — both accepting the same props for customization.
|
||||
|
||||
### Pattern B: Header Variant Prop (Recommended for Complex Pages)
|
||||
|
||||
For pages with significant shared logic, use a `headerVariant` prop pattern:
|
||||
|
||||
```tsx
|
||||
// src/components/MyPage/MyPage.tsx
|
||||
import { Content, PageWithHeader } from '@backstage/core-components';
|
||||
|
||||
function MyPageContent(
|
||||
props: MyPageProps & { headerVariant: 'legacy' | 'bui' },
|
||||
) {
|
||||
const { headerVariant, ...rest } = props;
|
||||
|
||||
// ... shared page logic, data fetching, etc.
|
||||
|
||||
const pageContent = <Content>{/* shared page body */}</Content>;
|
||||
|
||||
if (headerVariant === 'bui') {
|
||||
return pageContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithHeader title="My Plugin" themeId="tool">
|
||||
{pageContent}
|
||||
</PageWithHeader>
|
||||
);
|
||||
}
|
||||
|
||||
// Old system export
|
||||
export const MyPage = (props: MyPageProps) => (
|
||||
<MyPageContent {...props} headerVariant="legacy" />
|
||||
);
|
||||
|
||||
// New system export
|
||||
export const NfsMyPage = (props: MyPageProps) => (
|
||||
<MyPageContent {...props} headerVariant="bui" />
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern C: Content-Only Sub-Pages (For Tabbed Plugins)
|
||||
|
||||
When using `SubPageBlueprint` for tabbed pages, sub-page loaders should render only the content — the parent `PageBlueprint` provides the header and tabs:
|
||||
|
||||
```tsx
|
||||
// src/alpha/extensions.tsx
|
||||
import {
|
||||
PageBlueprint,
|
||||
SubPageBlueprint,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
export const myPluginPage = PageBlueprint.make({
|
||||
params: {
|
||||
path: '/my-plugin',
|
||||
routeRef: rootRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
export const overviewSubPage = SubPageBlueprint.make({
|
||||
name: 'overview',
|
||||
params: {
|
||||
path: 'overview',
|
||||
title: 'Overview',
|
||||
loader: () =>
|
||||
import('../components/OverviewPage').then(m => <m.OverviewPageContent />),
|
||||
},
|
||||
});
|
||||
|
||||
export const settingsSubPage = SubPageBlueprint.make({
|
||||
name: 'settings',
|
||||
params: {
|
||||
path: 'settings',
|
||||
title: 'Settings',
|
||||
loader: () =>
|
||||
import('../components/SettingsPage').then(m => <m.SettingsPageContent />),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Note: when using `SubPageBlueprint`, omit the `loader` from `PageBlueprint` to use the built-in tabbed sub-page rendering. The `PageBlueprint` without a `loader` creates a parent page that renders sub-pages as tabs automatically. If the sub-page content needs padding, use `Container` from `@backstage/ui` as a wrapper inside the component.
|
||||
|
||||
## Step 4: Migrate APIs to `ApiBlueprint`
|
||||
|
||||
APIs that were part of the old `createPlugin({ apis: [...] })` become `ApiBlueprint` extensions added to the plugin's `extensions` array.
|
||||
|
||||
### API Ownership
|
||||
|
||||
In the new system, each API has an **owner plugin** that controls who can provide or override it. Ownership can be set explicitly via `pluginId` on the `ApiRef` (recommended), or inferred from the `ApiRef` ID string pattern:
|
||||
|
||||
- Explicit `pluginId` on the ref → that plugin owns it
|
||||
- `plugin.<pluginId>.*` ID → owned by that plugin
|
||||
- `core.*` ID → owned by the `app` plugin
|
||||
|
||||
The recommended way to define API refs in the new system uses the builder pattern with an explicit `pluginId`:
|
||||
|
||||
```typescript
|
||||
// In your -react package
|
||||
import { createApiRef } from '@backstage/frontend-plugin-api';
|
||||
|
||||
export const myPluginApiRef = createApiRef<MyPluginApi>().with({
|
||||
id: 'plugin.my-plugin.client',
|
||||
pluginId: 'my-plugin',
|
||||
});
|
||||
```
|
||||
|
||||
When your plugin provides an `ApiBlueprint` in its `extensions` array, the extension is automatically namespaced under your plugin — so the ownership is correct by default:
|
||||
|
||||
```typescript
|
||||
// src/alpha/apis.ts
|
||||
import {
|
||||
ApiBlueprint,
|
||||
discoveryApiRef,
|
||||
fetchApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { myPluginApiRef } from '@internal/plugin-my-plugin-react';
|
||||
import { MyPluginClient } from '../api';
|
||||
|
||||
export const myPluginApi = ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: myPluginApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
fetchApi: fetchApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, fetchApi }) =>
|
||||
new MyPluginClient({ discoveryApi, fetchApi }),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Add the API extension to the plugin's `extensions` array.
|
||||
|
||||
App adopters who want to override your plugin's API must do so using a `createFrontendModule` targeting your plugin's ID — they cannot override it from a module for a different plugin.
|
||||
|
||||
## Step 5: Route Refs
|
||||
|
||||
### Reusing Existing Route Refs
|
||||
|
||||
Route refs defined using `createRouteRef` from `@backstage/core-plugin-api` can be used directly in the new system — no conversion needed. They work when passed to `createFrontendPlugin`'s `routes`/`externalRoutes` and to `PageBlueprint`'s `routeRef` param:
|
||||
|
||||
```tsx
|
||||
// routes.ts — keep using your existing route refs from @backstage/core-plugin-api
|
||||
import { createRouteRef } from '@backstage/core-plugin-api';
|
||||
export const rootRouteRef = createRouteRef({ id: 'my-plugin' });
|
||||
|
||||
// alpha.tsx — pass them directly, no conversion needed
|
||||
const myPage = PageBlueprint.make({
|
||||
params: {
|
||||
path: '/my-plugin',
|
||||
routeRef: rootRouteRef,
|
||||
loader: () => import('./MyPage').then(m => <m.NfsMyPage />),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
There is no need for `convertLegacyRouteRef` or `compatWrapper` from `@backstage/core-compat-api` — these are no longer required for plugin migration.
|
||||
|
||||
### Default Targets for External Route Refs
|
||||
|
||||
When adding new-system support, set `defaultTarget` on your external route refs so that apps don't need explicit route bindings for common cases. The target string uses the `<pluginId>.<routeName>` format, matching the `routes` map of the target plugin. The default is only used when the target plugin is actually installed — otherwise the route remains unbound.
|
||||
|
||||
```typescript
|
||||
// routes.ts
|
||||
import { createExternalRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export const viewTechDocRouteRef = createExternalRouteRef({
|
||||
id: 'view-techdoc',
|
||||
optional: true,
|
||||
params: ['namespace', 'kind', 'name'],
|
||||
defaultTarget: 'techdocs.docRoot',
|
||||
});
|
||||
|
||||
export const createComponentRouteRef = createExternalRouteRef({
|
||||
id: 'create-component',
|
||||
optional: true,
|
||||
defaultTarget: 'scaffolder.root',
|
||||
});
|
||||
```
|
||||
|
||||
This significantly improves the out-of-the-box experience — plugins with sensible defaults "just work" when installed without requiring the app to configure `bindRoutes`.
|
||||
|
||||
### `useRouteRef` Behavior Difference
|
||||
|
||||
In the new system, `useRouteRef` from `@backstage/frontend-plugin-api` may return `undefined` for unbound external routes. Legacy `useRouteRef` from `@backstage/core-plugin-api` throws an error instead. When writing NFS components, handle the `undefined` case.
|
||||
|
||||
## Step 6: Translations
|
||||
|
||||
If the plugin uses translations, the translation ref should be exported from the main entry point (`src/index.ts`). There is no need to re-export it from `./alpha` — consumers import translation refs from the main entry point regardless of which frontend system they use.
|
||||
|
||||
The same applies to other refs like API refs and route refs: keep them exported from the main entry point (or the `-react` package) and avoid duplicating exports in `./alpha`.
|
||||
|
||||
## Real Examples from the Backstage Repo
|
||||
|
||||
### Catalog Plugin (Dual Entry Point)
|
||||
|
||||
- Old: `plugins/catalog/src/plugin.ts` — `createPlugin` with `createRoutableExtension`
|
||||
- New: `plugins/catalog/src/alpha/plugin.tsx` — `createFrontendPlugin` with `PageBlueprint`
|
||||
- Header split: `plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx`
|
||||
- `BaseCatalogPage` (old) uses `PageWithHeader` + `ContentHeader`
|
||||
- `NfsBaseCatalogPage` (new) uses `HeaderPage` from `@backstage/ui` + `Content`
|
||||
|
||||
### Scaffolder Plugin (Sub-Pages)
|
||||
|
||||
- Old: `plugins/scaffolder/src/plugin.tsx` — single `ScaffolderPage` with internal routing
|
||||
- New: `plugins/scaffolder/src/alpha/extensions.tsx` — `PageBlueprint` (no loader) + multiple `SubPageBlueprint` entries for templates, tasks, actions, editor
|
||||
- Sub-page loaders wrap content in `<Content>` only — no page shell
|
||||
|
||||
### Notifications Plugin (Header Variant Pattern)
|
||||
|
||||
- `plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx`
|
||||
- Uses `headerVariant: 'legacy' | 'bui'` prop
|
||||
- `NfsNotificationsPage` returns content only (no `PageWithHeader`)
|
||||
- `NotificationsPage` wraps in `PageWithHeader`
|
||||
|
||||
### API Docs Plugin (Simple Dual Page)
|
||||
|
||||
- `plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.tsx`
|
||||
- `DefaultApiExplorerPage` (old) uses `PageWithHeader` + `ContentHeader`
|
||||
- `NfsApiExplorerPage` (new) uses `HeaderPage` + `Content`
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
1. [ ] Create `src/alpha.tsx` (or `src/alpha/` directory) with `createFrontendPlugin`
|
||||
2. [ ] Add `./alpha` to `package.json` `exports` and `typesVersions`
|
||||
3. [ ] Create `PageBlueprint` for each top-level page
|
||||
4. [ ] Create `SubPageBlueprint` for tabbed sub-pages (if applicable)
|
||||
5. [ ] Convert API factories to `ApiBlueprint` extensions
|
||||
6. [ ] Implement NFS page variants without page shell (`Page`/`Header`/`PageWithHeader`)
|
||||
7. [ ] Use `HeaderPage` from `@backstage/ui` for subtitle/custom actions in NFS pages
|
||||
8. [ ] Wire route refs (existing `@backstage/core-plugin-api` refs work directly, no conversion needed)
|
||||
9. [ ] Ensure translation refs and API refs are exported from the main entry point (not duplicated in `./alpha`)
|
||||
10. [ ] Add `@backstage/frontend-plugin-api` to `package.json` dependencies
|
||||
11. [ ] Add `@backstage/ui` to dependencies (if using `HeaderPage`)
|
||||
12. [ ] Run `yarn tsc` to check for type errors
|
||||
13. [ ] Run `yarn lint` to check for missing dependencies
|
||||
14. [ ] Test in both old app (`packages/app-legacy`) and new app (`packages/app`)
|
||||
15. [ ] Run `yarn build:api-reports` to update API reports (if the project uses API reports)
|
||||
|
||||
## Reference
|
||||
|
||||
- [Plugin migration guide](https://backstage.io/docs/frontend-system/building-plugins/migrating)
|
||||
- [Extension blueprints](https://backstage.io/docs/frontend-system/building-plugins/common-extension-blueprints)
|
||||
- [Utility APIs](https://backstage.io/docs/frontend-system/utility-apis/creating)
|
||||
Reference in New Issue
Block a user