diff --git a/plugins/search-react/src/extensions.test.tsx b/plugins/search-react/src/extensions.test.tsx
new file mode 100644
index 0000000000..24c115e572
--- /dev/null
+++ b/plugins/search-react/src/extensions.test.tsx
@@ -0,0 +1,209 @@
+/*
+ * Copyright 2023 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 { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { ListItem, ListItemText } from '@material-ui/core';
+
+import {
+ wrapInTestApp,
+ renderWithEffects,
+ TestApiProvider,
+ MockAnalyticsApi,
+} from '@backstage/test-utils';
+import {
+ createPlugin,
+ BackstagePlugin,
+ analyticsApiRef,
+} from '@backstage/core-plugin-api';
+import { SearchResult, SearchDocument } from '@backstage/plugin-search-common';
+
+import {
+ SearchResultListItemExtensions,
+ createSearchResultListItemExtension,
+ SearchResultListItemExtensionOptions,
+} from './extensions';
+
+const analyticsApiMock = new MockAnalyticsApi();
+
+const results = [
+ {
+ type: 'explore',
+ document: {
+ location: 'search/search-result1',
+ title: 'Search Result 1',
+ text: 'Some text from the search result 1',
+ },
+ },
+ {
+ type: 'techdocs',
+ document: {
+ location: 'search/search-result2',
+ title: 'Search Result 2',
+ text: 'Some text from the search result 2',
+ },
+ },
+];
+
+const createExtension = (
+ plugin: BackstagePlugin,
+ options: Partial<
+ Omit<
+ SearchResultListItemExtensionOptions<
+ (props: { result?: SearchDocument }) => JSX.Element | null
+ >,
+ 'name'
+ >
+ > = {},
+) => {
+ const {
+ predicate,
+ component = async () => (props: { result?: SearchDocument }) =>
+ (
+
+
+
+ ),
+ } = options;
+ return plugin.provide(
+ createSearchResultListItemExtension({
+ predicate,
+ component,
+ name: 'TestSearchResultItemExtension',
+ }),
+ );
+};
+
+describe('extensions', () => {
+ it('renders without exploding', async () => {
+ await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+
+ expect(screen.getByText('Search Result 1')).toBeInTheDocument();
+ expect(
+ screen.getByText('Some text from the search result 1'),
+ ).toBeInTheDocument();
+
+ expect(screen.getByText('Search Result 2')).toBeInTheDocument();
+ expect(
+ screen.getByText('Some text from the search result 2'),
+ ).toBeInTheDocument();
+ });
+
+ it('capture results discovery events', async () => {
+ await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Search Result 1/ }),
+ );
+
+ expect(analyticsApiMock.getEvents()[0]).toMatchObject({
+ action: 'discover',
+ subject: 'Search Result 1',
+ context: { routeRef: 'unknown', pluginId: 'root', extension: 'App' },
+ attributes: { to: 'search/search-result1' },
+ });
+ });
+
+ it('Could be used as simple components', async () => {
+ const plugin = createPlugin({ id: 'plugin' });
+ const DefaultSearchResultListItemExtension = createExtension(plugin);
+
+ await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+
+ expect(screen.getByText('Default')).toBeInTheDocument();
+ expect(screen.getByText('Search Result 1')).toBeInTheDocument();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Search Result 1/ }),
+ );
+
+ expect(analyticsApiMock.getEvents()[0]).toMatchObject({
+ action: 'discover',
+ subject: 'Search Result 1',
+ context: { routeRef: 'unknown', pluginId: 'root', extension: 'App' },
+ attributes: { to: 'search/search-result1' },
+ });
+ });
+
+ it('use default options for rendering results', async () => {
+ const plugin = createPlugin({ id: 'plugin' });
+ const DefaultSearchResultListItemExtension = createExtension(plugin);
+
+ await renderWithEffects(
+ wrapInTestApp(
+
+
+
+
+ ,
+ ),
+ );
+
+ expect(screen.getAllByText('Default')).toHaveLength(2);
+ expect(screen.getByText('Search Result 1')).toBeInTheDocument();
+ expect(screen.getByText('Search Result 2')).toBeInTheDocument();
+ });
+
+ it('use custom options for rendering results', async () => {
+ const plugin = createPlugin({ id: 'plugin' });
+ const DefaultSearchResultListItemExtension = createExtension(plugin);
+ const ExploreSearchResultListItemExtension = createExtension(plugin, {
+ predicate: (result: SearchResult) => result.type === 'explore',
+ component: async () => (props: { result?: SearchDocument }) =>
+ (
+
+
+
+ ),
+ });
+
+ await renderWithEffects(
+ wrapInTestApp(
+
+
+
+
+
+ ,
+ ),
+ );
+
+ expect(screen.getAllByText('Default')).toHaveLength(1);
+ expect(screen.getAllByText('Explore')).toHaveLength(1);
+ expect(screen.getByText('Search Result 1')).toBeInTheDocument();
+ expect(screen.getByText('Search Result 2')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/search-react/src/extensions.tsx b/plugins/search-react/src/extensions.tsx
new file mode 100644
index 0000000000..1e0619b14c
--- /dev/null
+++ b/plugins/search-react/src/extensions.tsx
@@ -0,0 +1,238 @@
+/*
+ * Copyright 2023 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, {
+ Fragment,
+ ReactNode,
+ PropsWithChildren,
+ isValidElement,
+ createElement,
+ cloneElement,
+ useCallback,
+} from 'react';
+
+import {
+ getComponentData,
+ useElementFilter,
+ Extension,
+ createReactExtension,
+ useAnalytics,
+} from '@backstage/core-plugin-api';
+import { SearchDocument, SearchResult } from '@backstage/plugin-search-common';
+
+import { Box, List, ListProps } from '@material-ui/core';
+
+import { DefaultResultListItem } from './components';
+
+/**
+ * @internal
+ * Key for result extensions.
+ */
+const SEARCH_RESULT_LIST_ITEM_EXTENSION =
+ 'search.results.list.items.extensions.v1';
+
+/**
+ * @internal
+ * Returns the first extension element found for a given result, and null otherwise.
+ * @param elements - All extension elements.
+ * @param result - The search result.
+ */
+const findSearchResultListItemExtensionElement = (
+ elements: ReactNode[],
+ result: SearchResult,
+) => {
+ for (const element of elements) {
+ if (!isValidElement(element)) continue;
+ const predicate = getComponentData<(result: SearchResult) => boolean>(
+ element,
+ SEARCH_RESULT_LIST_ITEM_EXTENSION,
+ );
+ if (!predicate?.(result)) continue;
+ return cloneElement(element, {
+ rank: result.rank,
+ highlight: result.highlight,
+ result: result.document,
+ // Use props in situations where a consumer is manually rendering the extension
+ ...element.props,
+ });
+ }
+ return null;
+};
+
+/**
+ * @internal
+ * Props for {@link SearchResultListItemExtension}.
+ */
+type SearchResultListItemExtensionProps = PropsWithChildren<{
+ rank?: number;
+ result?: SearchDocument;
+ noTrack?: boolean;
+}>;
+
+/**
+ * @internal
+ * Extends children with extension capabilities.
+ * @param props - see {@link SearchResultListItemExtensionProps}.
+ */
+const SearchResultListItemExtension = ({
+ rank,
+ result,
+ noTrack,
+ children,
+}: SearchResultListItemExtensionProps) => {
+ const analytics = useAnalytics();
+
+ const handleClickCapture = useCallback(() => {
+ if (noTrack) return;
+ if (!result) return;
+ analytics.captureEvent('discover', result.title, {
+ attributes: { to: result.location },
+ value: rank,
+ });
+ }, [rank, result, noTrack, analytics]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+/**
+ * @public
+ * Options for {@link createSearchResultListItemExtension}.
+ */
+export type SearchResultListItemExtensionOptions<
+ Component extends (props: any) => JSX.Element | null,
+> = {
+ /**
+ * The extension name.
+ */
+ name: string;
+ /**
+ * The extension component.
+ */
+ component: () => Promise;
+ /**
+ * When an extension defines a predicate, it returns true if the result should be rendered by that extension.
+ * Defaults to a predicate that returns true, which means it renders all sorts of results.
+ */
+ predicate?: (result: SearchResult) => boolean;
+};
+
+/**
+ * @public
+ * Creates a search result item extension.
+ * @param options - The extension options, see {@link SearchResultListItemExtensionOptions} for more details.
+ */
+export const createSearchResultListItemExtension = <
+ Component extends (props: any) => JSX.Element | null,
+>(
+ options: SearchResultListItemExtensionOptions,
+): Extension => {
+ const { name, component, predicate = () => true } = options;
+
+ return createReactExtension({
+ name,
+ component: {
+ lazy: () =>
+ component().then(
+ type =>
+ (props => (
+
+ {createElement(type, props)}
+
+ )) as Component,
+ ),
+ },
+ data: {
+ [SEARCH_RESULT_LIST_ITEM_EXTENSION]: predicate,
+ },
+ });
+};
+
+/**
+ * @public
+ * Returns a function that renders a result using extensions.
+ */
+export const useSearchResultListItemExtensions = (children: ReactNode) => {
+ const elements = useElementFilter(
+ children,
+ collection => {
+ return collection
+ .selectByComponentData({
+ key: SEARCH_RESULT_LIST_ITEM_EXTENSION,
+ })
+ .getElements();
+ },
+ [children],
+ );
+
+ return useCallback(
+ (result: SearchResult, key?: number) => {
+ const element = findSearchResultListItemExtensionElement(
+ elements,
+ result,
+ );
+
+ return (
+
+ {element ?? (
+
+
+
+ )}
+
+ );
+ },
+ [elements],
+ );
+};
+
+/**
+ * @public
+ * Props for {@link SearchResultListItemExtensions}
+ */
+export type SearchResultListItemExtensionsProps = Omit & {
+ /**
+ * Search result list.
+ */
+ results: SearchResult[];
+};
+
+/**
+ * @public
+ * Render results using search extensions.
+ * @param props - see {@link SearchResultListItemExtensionsProps}
+ */
+export const SearchResultListItemExtensions = (
+ props: SearchResultListItemExtensionsProps,
+) => {
+ const { results, children, ...rest } = props;
+ const render = useSearchResultListItemExtensions(children);
+ return {results.map(render)}
;
+};
diff --git a/plugins/search-react/src/index.ts b/plugins/search-react/src/index.ts
index 9813661f3e..8b234e3c05 100644
--- a/plugins/search-react/src/index.ts
+++ b/plugins/search-react/src/index.ts
@@ -22,6 +22,7 @@
export { searchApiRef, MockSearchApi } from './api';
export type { SearchApi } from './api';
+export * from './extensions';
export * from './components';
export {
SearchContextProvider,