diff --git a/.changeset/app-routes-param-redirect-substitution.md b/.changeset/app-routes-param-redirect-substitution.md
new file mode 100644
index 0000000000..66fc77a4ea
--- /dev/null
+++ b/.changeset/app-routes-param-redirect-substitution.md
@@ -0,0 +1,17 @@
+---
+'@backstage/plugin-app': patch
+---
+
+The `app/routes` redirect config now supports path parameter substitution in the `to` target. Named params (`:userId`) and splat params (`*`) captured by the `from` path are replaced in the `to` string before navigating, making it possible to express redirects like:
+
+```yaml
+app:
+ extensions:
+ - app/routes:
+ config:
+ redirects:
+ - from: /users/:userId
+ to: /profile/:userId
+ - from: /old-docs
+ to: /docs/*
+```
diff --git a/plugins/app/src/extensions/AppRoutes.test.tsx b/plugins/app/src/extensions/AppRoutes.test.tsx
index 9aa6474189..83b0c596b4 100644
--- a/plugins/app/src/extensions/AppRoutes.test.tsx
+++ b/plugins/app/src/extensions/AppRoutes.test.tsx
@@ -443,6 +443,149 @@ describe('AppRoutes', () => {
});
});
+ it('should substitute named path params in redirect target', async () => {
+ const LocationDisplay = () => {
+ const location = useLocation();
+ return
{location.pathname}
;
+ };
+
+ const profilePage = PageBlueprint.make({
+ name: 'profile',
+ params: {
+ path: '/profile/:userId',
+ loader: async () => (
+
+ Profile Page
+
+
+ ),
+ },
+ });
+
+ renderTestApp({
+ extensions: [profilePage],
+ initialRouteEntries: ['/users/alice'],
+ config: {
+ ...DEFAULT_CONFIG,
+ app: {
+ ...DEFAULT_CONFIG.app,
+ extensions: [
+ {
+ 'app/routes': {
+ config: {
+ redirects: [
+ { from: '/users/:userId', to: '/profile/:userId' },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Profile Page')).toBeInTheDocument();
+ expect(screen.getByTestId('location')).toHaveTextContent(
+ '/profile/alice',
+ );
+ });
+ });
+
+ it('should substitute splat param in redirect target', async () => {
+ const LocationDisplay = () => {
+ const location = useLocation();
+ return {location.pathname}
;
+ };
+
+ const docsPage = PageBlueprint.make({
+ name: 'docs',
+ params: {
+ path: '/docs',
+ loader: async () => (
+
+ Docs Page
+
+
+ ),
+ },
+ });
+
+ renderTestApp({
+ extensions: [docsPage],
+ initialRouteEntries: ['/d/default/component/my-entity'],
+ config: {
+ ...DEFAULT_CONFIG,
+ app: {
+ ...DEFAULT_CONFIG.app,
+ extensions: [
+ {
+ 'app/routes': {
+ config: {
+ redirects: [{ from: '/d', to: '/docs/*' }],
+ },
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Docs Page')).toBeInTheDocument();
+ expect(screen.getByTestId('location')).toHaveTextContent(
+ '/docs/default/component/my-entity',
+ );
+ });
+ });
+
+ it('should not corrupt a longer param when a shorter param is a prefix of it', async () => {
+ const LocationDisplay = () => {
+ const location = useLocation();
+ return {location.pathname}
;
+ };
+
+ const targetPage = PageBlueprint.make({
+ name: 'target',
+ params: {
+ path: '/target/:ab/:a',
+ loader: async () => (
+
+ Target Page
+
+
+ ),
+ },
+ });
+
+ renderTestApp({
+ extensions: [targetPage],
+ initialRouteEntries: ['/source/bar/foo'],
+ config: {
+ ...DEFAULT_CONFIG,
+ app: {
+ ...DEFAULT_CONFIG.app,
+ extensions: [
+ {
+ 'app/routes': {
+ config: {
+ redirects: [{ from: '/source/:ab/:a', to: '/target/:ab/:a' }],
+ },
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Target Page')).toBeInTheDocument();
+ expect(screen.getByTestId('location')).toHaveTextContent(
+ '/target/bar/foo',
+ );
+ });
+ });
+
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 75d590283b..04d94ebc35 100644
--- a/plugins/app/src/extensions/AppRoutes.tsx
+++ b/plugins/app/src/extensions/AppRoutes.tsx
@@ -21,7 +21,21 @@ import {
createExtensionInput,
NotFoundErrorPage,
} from '@backstage/frontend-plugin-api';
-import { Navigate, useRoutes } from 'react-router-dom';
+import { Navigate, useParams, useRoutes } from 'react-router-dom';
+
+function RedirectWithParams({ to }: { to: string }) {
+ const params = useParams() as Record;
+ let target = to;
+ for (const [name, value] of Object.entries(params)) {
+ // Use \b (word boundary) for named params so that `:a` doesn't
+ // accidentally match inside `:ab` when both are present.
+ target = target.replace(
+ name === '*' ? /\*/g : new RegExp(`:${name}\\b`, 'g'),
+ value ?? '',
+ );
+ }
+ return ;
+}
export const AppRoutes = createExtension({
name: 'routes',
@@ -54,7 +68,7 @@ export const AppRoutes = createExtension({
redirect.from === '/'
? redirect.from
: `${redirect.from.replace(/\/$/, '')}/*`,
- element: ,
+ element: ,
})),
...inputs.routes.map(route => {
const routePath = route.get(coreExtensionData.routePath);