diff --git a/packages/frontend-plugin-api/src/extensions/RouterBlueprint.test.tsx b/packages/frontend-plugin-api/src/extensions/RouterBlueprint.test.tsx
new file mode 100644
index 0000000000..3abc967fd8
--- /dev/null
+++ b/packages/frontend-plugin-api/src/extensions/RouterBlueprint.test.tsx
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { RouterBlueprint } from './RouterBlueprint';
+import { MemoryRouter } from 'react-router-dom';
+import { render, waitFor } from '@testing-library/react';
+import { createSpecializedApp } from '@backstage/frontend-app-api';
+import {
+ coreExtensionData,
+ createExtension,
+ createExtensionInput,
+ createExtensionOverrides,
+} from '../wiring';
+import { MockConfigApi } from '@backstage/test-utils';
+import { PageBlueprint } from './PageBlueprint';
+
+describe('RouterBlueprint', () => {
+ it('should return an extension when calling make with sensible defaults', () => {
+ const extension = RouterBlueprint.make({
+ params: {
+ Component: props =>
{props.children}
,
+ },
+ });
+
+ expect(extension).toMatchInlineSnapshot(`
+ {
+ "$$type": "@backstage/ExtensionDefinition",
+ "attachTo": {
+ "id": "app/root",
+ "input": "router",
+ },
+ "configSchema": undefined,
+ "disabled": false,
+ "factory": [Function],
+ "inputs": {},
+ "kind": "app-router-component",
+ "name": undefined,
+ "namespace": undefined,
+ "output": [
+ [Function],
+ ],
+ "toString": [Function],
+ "version": "v2",
+ }
+ `);
+ });
+
+ it('should work with simple options', async () => {
+ const extension = RouterBlueprint.make({
+ namespace: 'test',
+ params: {
+ Component: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ });
+
+ const app = createSpecializedApp({
+ features: [
+ createExtensionOverrides({
+ extensions: [
+ extension,
+ PageBlueprint.make({
+ namespace: 'test',
+ params: {
+ defaultPath: '/',
+ loader: async () => ,
+ },
+ }),
+ ],
+ }),
+ ],
+ });
+
+ const { getByTestId } = render(app.createRoot());
+
+ await waitFor(() => {
+ expect(getByTestId('test-contents')).toBeInTheDocument();
+ expect(getByTestId('test-router')).toBeInTheDocument();
+ });
+ });
+
+ it('should work with complex options and props', async () => {
+ const extension = RouterBlueprint.make({
+ namespace: 'test',
+ name: 'test',
+ config: {
+ schema: {
+ name: z => z.string(),
+ },
+ },
+ inputs: {
+ children: createExtensionInput([coreExtensionData.reactElement]),
+ },
+ params: {
+ Component: ({ inputs, children, config }) => (
+
+
+ {children}
+
+
+ ),
+ },
+ });
+
+ const app = createSpecializedApp({
+ features: [
+ createExtensionOverrides({
+ extensions: [
+ extension,
+ createExtension({
+ namespace: 'test',
+ attachTo: {
+ id: 'app-router-component:test/test',
+ input: 'children',
+ },
+ output: [coreExtensionData.reactElement],
+ *factory() {
+ yield coreExtensionData.reactElement();
+ },
+ }),
+ PageBlueprint.make({
+ namespace: 'test',
+ params: {
+ defaultPath: '/',
+ loader: async () => ,
+ },
+ }),
+ ],
+ }),
+ ],
+ config: new MockConfigApi({
+ app: {
+ extensions: [
+ {
+ 'app-router-component:test/test': { config: { name: 'Robin' } },
+ },
+ ],
+ },
+ }),
+ });
+
+ const { getByTestId } = render(app.createRoot());
+
+ await waitFor(() => {
+ expect(getByTestId('test-contents')).toBeInTheDocument();
+ expect(getByTestId('test-router-Robin-1')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/frontend-plugin-api/src/extensions/RouterBlueprint.tsx b/packages/frontend-plugin-api/src/extensions/RouterBlueprint.tsx
new file mode 100644
index 0000000000..9e9cddbdd1
--- /dev/null
+++ b/packages/frontend-plugin-api/src/extensions/RouterBlueprint.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React, { ComponentType, PropsWithChildren } from 'react';
+import { createExtensionBlueprint } from '../wiring';
+import { createRouterExtension } from './createRouterExtension';
+
+export const RouterBlueprint = createExtensionBlueprint({
+ kind: 'app-router-component',
+ attachTo: { id: 'app/root', input: 'router' },
+ output: [createRouterExtension.componentDataRef],
+ *factory(
+ {
+ Component,
+ }: {
+ Component: ComponentType<
+ PropsWithChildren<{
+ inputs: typeof inputs;
+ config: typeof config;
+ }>
+ >;
+ },
+ { config, inputs },
+ ) {
+ const Wrapper = (props: PropsWithChildren<{}>) => (
+
+ {props.children}
+
+ );
+
+ yield createRouterExtension.componentDataRef(Wrapper);
+ },
+});