From e5baa20a1b8d1515a37420181ed0e1d9f37bc0cf Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 30 Mar 2026 00:34:56 +0200 Subject: [PATCH 1/3] plugin-app: add redirect config to app/routes extension Allow users to configure URL redirects on the app/routes extension through app-config. Redirects are specified as an array of {from, to} path pairs in the extension config schema. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/app-routes-redirect-config.md | 5 + plugins/app/report.api.md | 18 +- plugins/app/src/extensions/AppRoutes.test.tsx | 168 ++++++++++++++++++ plugins/app/src/extensions/AppRoutes.tsx | 23 ++- 4 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 .changeset/app-routes-redirect-config.md diff --git a/.changeset/app-routes-redirect-config.md b/.changeset/app-routes-redirect-config.md new file mode 100644 index 0000000000..a04ddec744 --- /dev/null +++ b/.changeset/app-routes-redirect-config.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +Added support for configuring URL redirects on the `app/routes` extension. Redirects can be configured through `app-config` as an array of `{from, to}` path pairs, which will cause navigation to the `from` path to be redirected to the `to` path. diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 4c9b2a8ce4..12f9866aac 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -177,8 +177,22 @@ const appPlugin: OverridableFrontendPlugin< name: 'root'; }>; 'app/routes': OverridableExtensionDefinition<{ - config: {}; - configInput: {}; + config: { + redirects: + | { + from: string; + to: string; + }[] + | undefined; + }; + configInput: { + redirects?: + | { + from: string; + to: string; + }[] + | undefined; + }; output: ExtensionDataRef; inputs: { routes: ExtensionInput< diff --git a/plugins/app/src/extensions/AppRoutes.test.tsx b/plugins/app/src/extensions/AppRoutes.test.tsx index 26ba3df447..62233786bd 100644 --- a/plugins/app/src/extensions/AppRoutes.test.tsx +++ b/plugins/app/src/extensions/AppRoutes.test.tsx @@ -19,6 +19,11 @@ import { renderTestApp } from '@backstage/frontend-test-utils'; import { PageBlueprint } from '@backstage/frontend-plugin-api'; import { Link, useLocation, useParams } from 'react-router-dom'; +const DEFAULT_CONFIG = { + app: { baseUrl: 'http://localhost:3000' }, + backend: { baseUrl: 'http://localhost:7007' }, +}; + describe('AppRoutes', () => { it('should render the first route at root path', async () => { const homePage = PageBlueprint.make({ @@ -243,4 +248,167 @@ describe('AppRoutes', () => { expect(screen.queryByTestId('catalog-page')).not.toBeInTheDocument(); }); }); + + it('should redirect from one path to another using configured redirects', async () => { + const LocationDisplay = () => { + const location = useLocation(); + return
{location.pathname}
; + }; + + const catalogPage = PageBlueprint.make({ + name: 'catalog', + params: { + path: '/catalog', + loader: async () => ( +
+ Catalog Page + +
+ ), + }, + }); + + renderTestApp({ + extensions: [catalogPage], + initialRouteEntries: ['/old-catalog'], + config: { + ...DEFAULT_CONFIG, + app: { + ...DEFAULT_CONFIG.app, + extensions: [ + { + 'app/routes': { + config: { + redirects: [{ from: '/old-catalog', to: '/catalog' }], + }, + }, + }, + ], + }, + }, + }); + + await waitFor(() => { + expect(screen.getByText('Catalog Page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent('/catalog'); + }); + }); + + it('should support multiple redirects', async () => { + const LocationDisplay = () => { + const location = useLocation(); + return
{location.pathname}
; + }; + + const catalogPage = PageBlueprint.make({ + name: 'catalog', + params: { + path: '/catalog', + loader: async () => ( +
+ Catalog Page + +
+ ), + }, + }); + + const docsPage = PageBlueprint.make({ + name: 'docs', + params: { + path: '/docs', + loader: async () => ( +
+ Docs Page + +
+ ), + }, + }); + + const redirectsConfig = { + ...DEFAULT_CONFIG, + app: { + ...DEFAULT_CONFIG.app, + extensions: [ + { + 'app/routes': { + config: { + redirects: [ + { from: '/old-catalog', to: '/catalog' }, + { from: '/old-docs', to: '/docs' }, + ], + }, + }, + }, + ], + }, + }; + + const { unmount } = renderTestApp({ + extensions: [catalogPage, docsPage], + initialRouteEntries: ['/old-catalog'], + config: redirectsConfig, + }); + + await waitFor(() => { + expect(screen.getByText('Catalog Page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent('/catalog'); + }); + + unmount(); + + renderTestApp({ + extensions: [catalogPage, docsPage], + initialRouteEntries: ['/old-docs'], + config: redirectsConfig, + }); + + await waitFor(() => { + expect(screen.getByText('Docs Page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent('/docs'); + }); + }); + + it('should not interfere with normal routes when redirects are configured', async () => { + const homePage = PageBlueprint.make({ + name: 'home', + params: { + path: '/', + loader: async () =>
Home Page
, + }, + }); + + const catalogPage = PageBlueprint.make({ + name: 'catalog', + params: { + path: '/catalog', + loader: async () =>
Catalog Page
, + }, + }); + + renderTestApp({ + extensions: [homePage, catalogPage], + initialRouteEntries: ['/catalog'], + config: { + ...DEFAULT_CONFIG, + app: { + ...DEFAULT_CONFIG.app, + extensions: [ + { + 'app/routes': { + config: { + redirects: [{ from: '/old-catalog', to: '/catalog' }], + }, + }, + }, + ], + }, + }, + }); + + await waitFor(() => { + expect(screen.getByText('Catalog Page')).toBeInTheDocument(); + }); + }); }); diff --git a/plugins/app/src/extensions/AppRoutes.tsx b/plugins/app/src/extensions/AppRoutes.tsx index ddd7962004..b8b894df16 100644 --- a/plugins/app/src/extensions/AppRoutes.tsx +++ b/plugins/app/src/extensions/AppRoutes.tsx @@ -20,7 +20,7 @@ import { createExtensionInput, NotFoundErrorPage, } from '@backstage/frontend-plugin-api'; -import { useRoutes } from 'react-router-dom'; +import { Navigate, useRoutes } from 'react-router-dom'; export const AppRoutes = createExtension({ name: 'routes', @@ -32,10 +32,29 @@ export const AppRoutes = createExtension({ coreExtensionData.reactElement, ]), }, + config: { + schema: { + redirects: z => + z + .array( + z.object({ + from: z.string(), + to: z.string(), + }), + ) + .optional(), + }, + }, output: [coreExtensionData.reactElement], - factory({ inputs }) { + factory({ inputs, config }) { + const redirects = config.redirects ?? []; + const Routes = () => { const element = useRoutes([ + ...redirects.map(redirect => ({ + path: `${redirect.from.replace(/\/$/, '')}/*`, + element: , + })), ...inputs.routes.map(route => { const routePath = route.get(coreExtensionData.routePath); From 4a01a66da3714bf38c280791925dc555dc38287e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 30 Mar 2026 01:22:38 +0200 Subject: [PATCH 2/3] docs: document app/routes redirect configuration Signed-off-by: Patrik Oldsberg Made-with: Cursor Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/app-routes-redirect-config.md | 12 ++++++++++ .../building-apps/03-built-in-extensions.md | 24 +++++++++++++++++++ .../building-apps/08-migrating.md | 16 +++++++++++++ 3 files changed, 52 insertions(+) diff --git a/.changeset/app-routes-redirect-config.md b/.changeset/app-routes-redirect-config.md index a04ddec744..ce43e3c159 100644 --- a/.changeset/app-routes-redirect-config.md +++ b/.changeset/app-routes-redirect-config.md @@ -3,3 +3,15 @@ --- Added support for configuring URL redirects on the `app/routes` extension. Redirects can be configured through `app-config` as an array of `{from, to}` path pairs, which will cause navigation to the `from` path to be redirected to the `to` path. + +For example: + +```yaml +app: + extensions: + - app/routes: + config: + redirects: + - from: /old-path + to: /new-path +``` diff --git a/docs/frontend-system/building-apps/03-built-in-extensions.md b/docs/frontend-system/building-apps/03-built-in-extensions.md index 05e60b54b7..637aded411 100644 --- a/docs/frontend-system/building-apps/03-built-in-extensions.md +++ b/docs/frontend-system/building-apps/03-built-in-extensions.md @@ -183,6 +183,30 @@ Be careful when overriding this extension, as to do so correctly you must consid - Remember to user the route refs for getting paths dynamically, otherwise if an adopter modifies a path through configuration, the route is not going to point to the configured path; - Adopters expect to be able to customize the `NotFoundErrorPage` component via Components API, you should render this component for routes not configured. +#### Configurations + +| Key | Type | Default value | Description | +| ----------- | -------------------------------- | ------------- | -------------------------------------------------------------------- | +| `redirects` | `{ from: string, to: string }[]` | - | A list of URL redirects. Navigation to `from` will redirect to `to`. | + +##### Configuring redirects + +You can configure redirects for the `app/routes` extension to automatically redirect users from one path to another. This is useful when restructuring your app's routes, for example after moving or renaming plugin pages. + +```yaml title="app-config.yaml" +app: + extensions: + - app/routes: + config: + redirects: + - from: /old-path + to: /new-path + - from: /legacy/page + to: /updated/page +``` + +Redirects are matched before any regular routes and use the [`replace`](https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState) strategy, so the old path will not appear in the browser history. + #### Inputs | Name | Description | Type | Optional | Default | Extension creator | diff --git a/docs/frontend-system/building-apps/08-migrating.md b/docs/frontend-system/building-apps/08-migrating.md index ceba8fd868..2784b10b7a 100644 --- a/docs/frontend-system/building-apps/08-migrating.md +++ b/docs/frontend-system/building-apps/08-migrating.md @@ -800,6 +800,22 @@ If you are using [app feature discovery](../architecture/10-app.md#feature-disco Continue this process for each of your legacy routes until you have migrated all of them. For any plugin with additional extensions installed as children of the `Route`, refer to the plugin READMEs for more detailed instructions. For the entity pages, refer to the [separate section](#catalog-entity-page). +##### Migrating `` routes + +If your old routes include `` elements to forward users from one path to another, you can replace them with the built-in redirect configuration on the `app/routes` extension: + +```yaml title="app-config.yaml" +app: + extensions: + - app/routes: + config: + redirects: + - from: /old-path + to: /new-path +``` + +See the [`app/routes` built-in extension documentation](./03-built-in-extensions.md#configuring-redirects) for more details. + ### Migrating core, internal and third-party plugins For certain core plugins — such as the Catalog plugin's entity page — we provide a dedicated step-by-step migration guide, since these plugins often require a more gradual approach due to their complexity. From baa72405ac855e8b9e90d9e7ce3ce42ebd50cb9c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 30 Mar 2026 14:46:18 +0200 Subject: [PATCH 3/3] Fix root path redirect matching all routes Special-case `from: '/'` in redirect config to use an exact path match instead of the `/*` wildcard, which would match the entire app. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/app/src/extensions/AppRoutes.test.tsx | 73 +++++++++++++++++++ plugins/app/src/extensions/AppRoutes.tsx | 5 +- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/plugins/app/src/extensions/AppRoutes.test.tsx b/plugins/app/src/extensions/AppRoutes.test.tsx index 62233786bd..9aa6474189 100644 --- a/plugins/app/src/extensions/AppRoutes.test.tsx +++ b/plugins/app/src/extensions/AppRoutes.test.tsx @@ -370,6 +370,79 @@ describe('AppRoutes', () => { }); }); + it('should only redirect the root path when from is /', async () => { + const LocationDisplay = () => { + const location = useLocation(); + return
{location.pathname}
; + }; + + const catalogPage = PageBlueprint.make({ + name: 'catalog', + params: { + path: '/catalog', + loader: async () => ( +
+ Catalog Page + +
+ ), + }, + }); + + const homePage = PageBlueprint.make({ + name: 'home', + params: { + path: '/home', + loader: async () => ( +
+ Home Page + +
+ ), + }, + }); + + const redirectsConfig = { + ...DEFAULT_CONFIG, + app: { + ...DEFAULT_CONFIG.app, + extensions: [ + { + 'app/routes': { + config: { + redirects: [{ from: '/', to: '/home' }], + }, + }, + }, + ], + }, + }; + + const { unmount } = renderTestApp({ + extensions: [catalogPage, homePage], + initialRouteEntries: ['/'], + config: redirectsConfig, + }); + + await waitFor(() => { + expect(screen.getByText('Home Page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent('/home'); + }); + + unmount(); + + renderTestApp({ + extensions: [catalogPage, homePage], + initialRouteEntries: ['/catalog'], + config: redirectsConfig, + }); + + await waitFor(() => { + expect(screen.getByText('Catalog Page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent('/catalog'); + }); + }); + it('should not interfere with normal routes when redirects are configured', async () => { const homePage = PageBlueprint.make({ name: 'home', diff --git a/plugins/app/src/extensions/AppRoutes.tsx b/plugins/app/src/extensions/AppRoutes.tsx index b8b894df16..f73c47e2cb 100644 --- a/plugins/app/src/extensions/AppRoutes.tsx +++ b/plugins/app/src/extensions/AppRoutes.tsx @@ -52,7 +52,10 @@ export const AppRoutes = createExtension({ const Routes = () => { const element = useRoutes([ ...redirects.map(redirect => ({ - path: `${redirect.from.replace(/\/$/, '')}/*`, + path: + redirect.from === '/' + ? redirect.from + : `${redirect.from.replace(/\/$/, '')}/*`, element: , })), ...inputs.routes.map(route => {