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 <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-30 00:34:56 +02:00
parent 0336f92de8
commit e5baa20a1b
4 changed files with 210 additions and 4 deletions
+5
View File
@@ -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.
+16 -2
View File
@@ -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<JSX_2.Element, 'core.reactElement', {}>;
inputs: {
routes: ExtensionInput<
@@ -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 <div data-testid="location">{location.pathname}</div>;
};
const catalogPage = PageBlueprint.make({
name: 'catalog',
params: {
path: '/catalog',
loader: async () => (
<div>
Catalog Page
<LocationDisplay />
</div>
),
},
});
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 <div data-testid="location">{location.pathname}</div>;
};
const catalogPage = PageBlueprint.make({
name: 'catalog',
params: {
path: '/catalog',
loader: async () => (
<div>
Catalog Page
<LocationDisplay />
</div>
),
},
});
const docsPage = PageBlueprint.make({
name: 'docs',
params: {
path: '/docs',
loader: async () => (
<div>
Docs Page
<LocationDisplay />
</div>
),
},
});
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 () => <div>Home Page</div>,
},
});
const catalogPage = PageBlueprint.make({
name: 'catalog',
params: {
path: '/catalog',
loader: async () => <div>Catalog Page</div>,
},
});
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();
});
});
});
+21 -2
View File
@@ -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: <Navigate to={redirect.to} replace />,
})),
...inputs.routes.map(route => {
const routePath = route.get(coreExtensionData.routePath);