Repository: backstage/backstage
Pull request #29760: feat: add techdocs-entity-path annotation for techdocs deep linking
Author: login: csuich2
Labels: name: area:documentation
description: Improvements or additions to documentation
name: area:catalog
description: Related to the Catalog Project Area
name: area:techdocs
description: Related to the TechDocs Project Area
Created: 2025-04-28T15:33:11Z | Merged: 2025-05-27T12:46:39Z

## Hey, I just made a Pull Request!

This annotation works in conjunction with `backstage.io/techdocs-entity` to allow specifying a path within the other entities techdocs to use as the root techdocs page.

For example we have a monorepo that uses a single docs folder. We want all the components in that monorepo to be able to deep link into the monorepo's techdocs to the page for that specific component.

#### :heavy_check_mark: Checklist

<!--- Please include the following in your Pull Request when applicable: -->

- [x] A changeset describing the change and affected packages. ([more info](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#creating-changesets))
- [x] Added or updated documentation
- [x] Tests for new functionality and regression tests for bug fixes
- [n/a] Screenshots attached (for UI changes)
- [x] All your commits have a `Signed-off-by` line in the message. ([more info](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#developer-certificate-of-origin))



Diff:
diff --git a/.changeset/smooth-eels-think.md b/.changeset/smooth-eels-think.md
new file mode 100644
index 00000000000000..c5e2fe0c85e0de
--- /dev/null
+++ b/.changeset/smooth-eels-think.md
@@ -0,0 +1,8 @@
+---
+'@backstage/plugin-techdocs': minor
+'@backstage/plugin-techdocs-react': minor
+'@backstage/plugin-catalog': minor
+'@backstage/plugin-techdocs-common': patch
+---
+
+Introduced `backstage.io/techdocs-entity-path` annotation which allows deep linking into another entities TechDocs in conjunction with `backstage.io/techdocs-entity`.
diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md
index 974fa80993844b..42cefd3adba638 100644
--- a/docs/features/software-catalog/well-known-annotations.md
+++ b/docs/features/software-catalog/well-known-annotations.md
@@ -115,6 +115,20 @@ the TechDocs in the TechDocs page or needing multiple builds of the same docs.
 
 This is for situations where you have complex systems where they share a single repo, and likely a single TechDoc location.
 
+### backstage.io/techdocs-entity-path
+
+```yaml
+# Example:
+metadata:
+  annotations:
+    backstage.io/techdocs-entity: component:default/example
+    backstage.io/techdocs-entity-path: /path/to/this/component
+```
+
+The value of this annotation informs of the path to this component's TechDocs within an external entity that owns the TechDocs.
+In conjunction with [backstage.io/techdocs-entity](#backstageiotechdocs-entity) this allows for deep linking into the TechDocs of
+another entity, not just linking to the root of another entities TechDocs.
+
 ### backstage.io/view-url, backstage.io/edit-url
 
 ```yaml
diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md
index 8a95c865330e45..7d8c99ffda8499 100644
--- a/docs/features/techdocs/how-to-guides.md
+++ b/docs/features/techdocs/how-to-guides.md
@@ -942,6 +942,35 @@ metadata:
     backstage.io/techdocs-entity: system:default/example
 ```
 
+### Deep linking into TechDocs
+
+The `backstage.io/techdocs-entity-path` annotation can be use to deep link into a specific page within the components TechDocs.
+This can be used in conjunction with `backstage.io/techdocs-entity` or standalone.
+
+```yaml
+apiVersion: backstage.io/v1alpha1
+kind: System
+metadata:
+  name: example
+  namespace: default
+  title: Example
+  description: This is the parent entity
+  annotations:
+    backstage.io/techdocs-ref: dir:.
+
+---
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+  name: example-platfrom
+  title: Example Application Platform
+  namespace: default
+  description: This is the child entity
+  annotations:
+    backstage.io/techdocs-entity: system:default/example
+    backstage.io/techdocs-entity-path: /path/to/component/docs
+```
+
 ## How to resolve broken links from moved or renamed pages in your documentation site
 
 TechDocs supports using the [mkdocs-redirects](https://github.com/mkdocs/mkdocs-redirects/tree/master) plugin to create a redirect map for any TechDocs site. This allows broken links from renamed or moved pages in your site to be redirected to their specified replacement.
diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json
index b046eea88d7735..eab74b74e0fa7f 100644
--- a/plugins/catalog/package.json
+++ b/plugins/catalog/package.json
@@ -72,6 +72,8 @@
     "@backstage/plugin-scaffolder-common": "workspace:^",
     "@backstage/plugin-search-common": "workspace:^",
     "@backstage/plugin-search-react": "workspace:^",
+    "@backstage/plugin-techdocs-common": "workspace:^",
+    "@backstage/plugin-techdocs-react": "workspace:^",
     "@backstage/types": "workspace:^",
     "@backstage/version-bridge": "workspace:^",
     "@material-ui/core": "^4.12.2",
diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx
index 02553392385abe..f8bebcf0378ff6 100644
--- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx
+++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx
@@ -589,6 +589,64 @@ describe('<AboutCard />', () => {
     );
   });
 
+  it('renders techdocs link to specific path', async () => {
+    const entity = {
+      apiVersion: 'v1',
+      kind: 'Component',
+      metadata: {
+        name: 'software',
+        annotations: {
+          'backstage.io/techdocs-entity': 'system:default/example',
+          'backstage.io/techdocs-entity-path': '/path/to/component',
+        },
+      },
+      spec: {
+        owner: 'guest',
+        type: 'service',
+        lifecycle: 'production',
+      },
+    };
+
+    await renderInTestApp(
+      <TestApiProvider
+        apis={[
+          [
+            scmIntegrationsApiRef,
+            ScmIntegrationsApi.fromConfig(
+              new ConfigReader({
+                integrations: {
+                  github: [
+                    {
+                      host: 'github.com',
+                      token: '...',
+                    },
+                  ],
+                },
+              }),
+            ),
+          ],
+          [catalogApiRef, catalogApi],
+          [permissionApiRef, {}],
+        ]}
+      >
+        <EntityProvider entity={entity}>
+          <AboutCard />
+        </EntityProvider>
+      </TestApiProvider>,
+      {
+        mountedRoutes: {
+          '/docs/:namespace/:kind/:name': viewTechDocRouteRef,
+          '/catalog/:namespace/:kind/:name': entityRouteRef,
+        },
+      },
+    );
+
+    expect(screen.getByText('View TechDocs').closest('a')).toHaveAttribute(
+      'href',
+      '/docs/default/system/example/path/to/component',
+    );
+  });
+
   it('renders techdocs link', async () => {
     const entity = {
       apiVersion: 'v1',
diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx
index bf5be95a0854d5..88933b2a666501 100644
--- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx
+++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx
@@ -16,10 +16,8 @@
 import {
   ANNOTATION_EDIT_URL,
   ANNOTATION_LOCATION,
-  CompoundEntityRef,
   DEFAULT_NAMESPACE,
   stringifyEntityRef,
-  parseEntityRef,
 } from '@backstage/catalog-model';
 import Card from '@material-ui/core/Card';
 import CardContent from '@material-ui/core/CardContent';
@@ -66,10 +64,11 @@ import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha'
 import { usePermission } from '@backstage/plugin-permission-react';
 import { catalogTranslationRef } from '../../alpha/translation';
 import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
-
-const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
-
-const TECHDOCS_EXTERNAL_ANNOTATION = 'backstage.io/techdocs-entity';
+import { buildTechDocsURL } from '@backstage/plugin-techdocs-react';
+import {
+  TECHDOCS_ANNOTATION,
+  TECHDOCS_EXTERNAL_ANNOTATION,
+} from '@backstage/plugin-techdocs-common';
 
 const useStyles = makeStyles({
   gridItemCard: {
@@ -135,19 +134,6 @@ export function AboutCard(props: AboutCardProps) {
   const entityMetadataEditUrl =
     entity.metadata.annotations?.[ANNOTATION_EDIT_URL];
 
-  let techdocsRef: CompoundEntityRef | undefined;
-
-  if (entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) {
-    try {
-      techdocsRef = parseEntityRef(
-        entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION],
-      );
-      // not a fan of this but we don't care if the parseEntityRef fails
-    } catch {
-      techdocsRef = undefined;
-    }
-  }
-
   const viewInSource: IconLinkVerticalProps = {
     label: t('aboutCard.viewSource'),
     disabled: !entitySourceLocation,
@@ -162,19 +148,7 @@ export function AboutCard(props: AboutCardProps) {
         entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]
       ) || !viewTechdocLink,
     icon: <DocsIcon />,
-    href:
-      viewTechdocLink &&
-      (techdocsRef
-        ? viewTechdocLink({
-            namespace: techdocsRef.namespace || DEFAULT_NAMESPACE,
-            kind: techdocsRef.kind,
-            name: techdocsRef.name,
-          })
-        : viewTechdocLink({
-            namespace: entity.metadata.namespace || DEFAULT_NAMESPACE,
-            kind: entity.kind,
-            name: entity.metadata.name,
-          })),
+    href: buildTechDocsURL(entity, viewTechdocLink),
   };
 
   const subHeaderLinks = [viewInSource, viewInTechDocs];
diff --git a/plugins/techdocs-common/report.api.md b/plugins/techdocs-common/report.api.md
index c7e1829a28f5f2..56a86778a86ff3 100644
--- a/plugins/techdocs-common/report.api.md
+++ b/plugins/techdocs-common/report.api.md
@@ -8,4 +8,8 @@ export const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
 
 // @public (undocumented)
 export const TECHDOCS_EXTERNAL_ANNOTATION = 'backstage.io/techdocs-entity';
+
+// @public (undocumented)
+export const TECHDOCS_EXTERNAL_PATH_ANNOTATION =
+  'backstage.io/techdocs-entity-path';
 ```
diff --git a/plugins/techdocs-common/src/constants.ts b/plugins/techdocs-common/src/constants.ts
index 40187f41ce1e7d..a31951204e66b1 100644
--- a/plugins/techdocs-common/src/constants.ts
+++ b/plugins/techdocs-common/src/constants.ts
@@ -18,3 +18,6 @@
 export const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
 /** @public */
 export const TECHDOCS_EXTERNAL_ANNOTATION = 'backstage.io/techdocs-entity';
+/** @public */
+export const TECHDOCS_EXTERNAL_PATH_ANNOTATION =
+  'backstage.io/techdocs-entity-path';
diff --git a/plugins/techdocs-react/package.json b/plugins/techdocs-react/package.json
index f6dc740ee73587..6c7166f9973c87 100644
--- a/plugins/techdocs-react/package.json
+++ b/plugins/techdocs-react/package.json
@@ -63,6 +63,7 @@
     "@backstage/core-components": "workspace:^",
     "@backstage/core-plugin-api": "workspace:^",
     "@backstage/frontend-plugin-api": "workspace:^",
+    "@backstage/plugin-techdocs-common": "workspace:^",
     "@backstage/version-bridge": "workspace:^",
     "@material-ui/core": "^4.12.2",
     "@material-ui/styles": "^4.11.0",
diff --git a/plugins/techdocs-react/report.api.md b/plugins/techdocs-react/report.api.md
index 23a9c9faaff46e..62ac6375fa67db 100644
--- a/plugins/techdocs-react/report.api.md
+++ b/plugins/techdocs-react/report.api.md
@@ -15,8 +15,21 @@ import { JSX as JSX_2 } from 'react/jsx-runtime';
 import { MemoExoticComponent } from 'react';
 import { PropsWithChildren } from 'react';
 import { ReactNode } from 'react';
+import { RouteFunc } from '@backstage/core-plugin-api';
 import { SetStateAction } from 'react';
 
+// @public
+export const buildTechDocsURL: (
+  entity: Entity,
+  routeFunc:
+    | RouteFunc<{
+        namespace: string;
+        kind: string;
+        name: string;
+      }>
+    | undefined,
+) => string | undefined;
+
 // @public
 export function createTechDocsAddonExtension(
   options: TechDocsAddonOptions,
@@ -27,6 +40,9 @@ export function createTechDocsAddonExtension<TComponentProps>(
   options: TechDocsAddonOptions<TComponentProps>,
 ): Extension<(props: TComponentProps) => JSX.Element | null>;
 
+// @public
+export function getEntityRootTechDocsPath(entity: Entity): string;
+
 // @public
 export const SHADOW_DOM_STYLE_LOAD_EVENT = 'TECH_DOCS_SHADOW_DOM_STYLE_LOAD';
 
diff --git a/plugins/techdocs-react/src/helpers.ts b/plugins/techdocs-react/src/helpers.ts
index 3f10fff2ba3174..e6daf99b26bd6c 100644
--- a/plugins/techdocs-react/src/helpers.ts
+++ b/plugins/techdocs-react/src/helpers.ts
@@ -15,7 +15,17 @@
  */
 
 import { Config } from '@backstage/config';
-import { CompoundEntityRef } from '@backstage/catalog-model';
+import {
+  CompoundEntityRef,
+  Entity,
+  getCompoundEntityRef,
+  parseEntityRef,
+} from '@backstage/catalog-model';
+import {
+  TECHDOCS_EXTERNAL_ANNOTATION,
+  TECHDOCS_EXTERNAL_PATH_ANNOTATION,
+} from '@backstage/plugin-techdocs-common';
+import { RouteFunc } from '@backstage/core-plugin-api';
 
 /**
  * Lower-case entity triplets by default, but allow override.
@@ -35,3 +45,67 @@ export function toLowercaseEntityRefMaybe(
 
   return entityRef;
 }
+
+/**
+ * Get the entity path annotation from the given entity and ensure it starts with a slash.
+ *
+ * @public
+ */
+export function getEntityRootTechDocsPath(entity: Entity): string {
+  let path = entity.metadata.annotations?.[TECHDOCS_EXTERNAL_PATH_ANNOTATION];
+  if (!path) {
+    return '';
+  }
+  if (!path.startsWith('/')) {
+    path = `/${path}`;
+  }
+  return path;
+}
+
+/**
+ * Build the TechDocs URL for the given entity. This helper should be used anywhere there
+ * is a link to an entities TechDocs.
+ *
+ * @public
+ */
+export const buildTechDocsURL = (
+  entity: Entity,
+  routeFunc:
+    | RouteFunc<{
+        namespace: string;
+        kind: string;
+        name: string;
+      }>
+    | undefined,
+) => {
+  if (!routeFunc) {
+    return undefined;
+  }
+
+  let { namespace, kind, name } = getCompoundEntityRef(entity);
+
+  if (entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) {
+    try {
+      const techdocsRef = parseEntityRef(
+        entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION],
+      );
+      namespace = techdocsRef.namespace;
+      kind = techdocsRef.kind;
+      name = techdocsRef.name;
+    } catch {
+      // not a fan of this but we don't care if the parseEntityRef fails
+    }
+  }
+
+  const url = routeFunc({
+    namespace,
+    kind,
+    name,
+  });
+
+  // Add on the external entity path to the url if one exists. This allows deep linking into another
+  // entities TechDocs.
+  const path = getEntityRootTechDocsPath(entity);
+
+  return `${url}${path}`;
+};
diff --git a/plugins/techdocs-react/src/index.ts b/plugins/techdocs-react/src/index.ts
index 5cbab1a326564e..7c4f316c1ebfef 100644
--- a/plugins/techdocs-react/src/index.ts
+++ b/plugins/techdocs-react/src/index.ts
@@ -52,4 +52,8 @@ export {
   useShadowRootElements,
   useShadowRootSelection,
 } from './hooks';
-export { toLowercaseEntityRefMaybe } from './helpers';
+export {
+  toLowercaseEntityRefMaybe,
+  getEntityRootTechDocsPath,
+  buildTechDocsURL,
+} from './helpers';
diff --git a/plugins/techdocs/report.api.md b/plugins/techdocs/report.api.md
index bb7ff9e5a527ae..23694666746784 100644
--- a/plugins/techdocs/report.api.md
+++ b/plugins/techdocs/report.api.md
@@ -431,6 +431,7 @@ export const TechDocsReaderPageContent: (
 // @public
 export type TechDocsReaderPageContentProps = {
   entityRef?: CompoundEntityRef;
+  defaultPath?: string;
   withSearch?: boolean;
   searchResultUrlMapper?: (url: string) => string;
   onReady?: () => void;
diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx
index 68c26cd1c3b778..14dcb00d2989a1 100644
--- a/plugins/techdocs/src/EntityPageDocs.tsx
+++ b/plugins/techdocs/src/EntityPageDocs.tsx
@@ -20,6 +20,7 @@ import {
   parseEntityRef,
 } from '@backstage/catalog-model';
 import { TECHDOCS_EXTERNAL_ANNOTATION } from '@backstage/plugin-techdocs-common';
+import { getEntityRootTechDocsPath } from '@backstage/plugin-techdocs-react';
 
 import { TechDocsReaderPage } from './plugin';
 import { TechDocsReaderPageContent } from './reader/components/TechDocsReaderPageContent';
@@ -52,12 +53,15 @@ export const EntityPageDocs = ({
     }
   }
 
+  const defaultPath = getEntityRootTechDocsPath(entity);
+
   return (
     <TechDocsReaderPage entityRef={entityRef}>
       <TechDocsReaderPageSubheader />
       <TechDocsReaderPageContent
         withSearch={withSearch}
         searchResultUrlMapper={searchResultUrlMapper}
+        defaultPath={defaultPath}
       />
     </TechDocsReaderPage>
   );
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx
index a8e7ae72873d93..77bb3d134f7037 100644
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx
@@ -16,7 +16,10 @@
 import { ReactNode } from 'react';
 import { waitFor } from '@testing-library/react';
 
-import { CompoundEntityRef } from '@backstage/catalog-model';
+import {
+  CompoundEntityRef,
+  getCompoundEntityRef,
+} from '@backstage/catalog-model';
 import {
   techdocsApiRef,
   TechDocsReaderPageProvider,
@@ -121,6 +124,33 @@ describe('<TechDocsReaderPageContent />', () => {
     });
   });
 
+  it('should render techdocs page content with default path', async () => {
+    getEntityMetadata.mockResolvedValue(mockEntityMetadata);
+    getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
+    useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
+    useReaderState.mockReturnValue({ state: 'cached' });
+
+    const defaultPath = '/some/path';
+
+    const rendered = await renderInTestApp(
+      <Wrapper>
+        <TechDocsReaderPageContent
+          withSearch={false}
+          defaultPath={defaultPath}
+        />
+      </Wrapper>,
+    );
+
+    await waitFor(() => {
+      expect(
+        rendered.getByTestId('techdocs-native-shadowroot'),
+      ).toBeInTheDocument();
+    });
+
+    const entityRef = getCompoundEntityRef(mockEntityMetadata);
+    expect(useTechDocsReaderDom).toHaveBeenCalledWith(entityRef, defaultPath);
+  });
+
   it('should not render techdocs content if entity metadata is missing', async () => {
     getEntityMetadata.mockResolvedValue(undefined);
     useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx
index 1db97a82b8b0fb..fe72b090735d21 100644
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx
@@ -61,6 +61,11 @@ export type TechDocsReaderPageContentProps = {
    * @deprecated No need to pass down entityRef as property anymore. Consumes the entityName from `TechDocsReaderPageContext`. Use the {@link @backstage/plugin-techdocs-react#useTechDocsReaderPage} hook for custom reader page content.
    */
   entityRef?: CompoundEntityRef;
+  /**
+   * Path in the docs to render by default. This should be used when rendering docs for an entity that specifies the
+   * "backstage.io/techdocs-entity-path" annotation for deep linking into another entities docs.
+   */
+  defaultPath?: string;
   /**
    * Show or hide the search bar, defaults to true.
    */
@@ -93,7 +98,7 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider(
       setShadowRoot,
     } = useTechDocsReaderPage();
     const { state } = useTechDocsReader();
-    const dom = useTechDocsReaderDom(entityRef);
+    const dom = useTechDocsReaderDom(entityRef, props.defaultPath);
     const path = window.location.pathname;
     const hash = window.location.hash;
     const isStyleLoading = useShadowDomStylesLoading(dom);
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
index c17d5fcac40609..0bd7d0e335b77f 100644
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
@@ -14,7 +14,13 @@
  * limitations under the License.
  */
 
-import { useCallback, useEffect, useState } from 'react';
+import {
+  useCallback,
+  useEffect,
+  useLayoutEffect,
+  // useRef,
+  useState,
+} from 'react';
 
 import useMediaQuery from '@material-ui/core/useMediaQuery';
 import { useTheme } from '@material-ui/core/styles';
@@ -47,10 +53,27 @@ import {
   handleMetaRedirects,
 } from '../../transformers';
 import { useNavigateUrl } from './useNavigateUrl';
-import { useParams } from 'react-router-dom';
+import { useLocation, useNavigate, useParams } from 'react-router-dom';
 
 const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)';
 
+// If a defaultPath is specified then we should navigate to that path replacing the
+// current location in the history. This should only happen on the initial load so
+// navigating to the root of the docs doesn't also redirect.
+const useInitialRedirect = (defaultPath?: string) => {
+  // const hasRun = useRef(false);
+
+  const location = useLocation();
+  const navigate = useNavigate();
+  const { '*': currPath = '' } = useParams();
+
+  useLayoutEffect(() => {
+    if (currPath === '' && defaultPath !== '') {
+      navigate(`${location.pathname}${defaultPath}`, { replace: true });
+    }
+  }, []); // eslint-disable-line react-hooks/exhaustive-deps
+};
+
 /**
  * Hook that encapsulates the behavior of getting raw HTML and applying
  * transforms to it in order to make it function at a basic level in the
@@ -58,6 +81,7 @@ const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)';
  */
 export const useTechDocsReaderDom = (
   entityRef: CompoundEntityRef,
+  defaultPath?: string,
 ): Element | null => {
   const navigate = useNavigateUrl();
   const theme = useTheme();
@@ -76,6 +100,8 @@ export const useTechDocsReaderDom = (
   const [dom, setDom] = useState<HTMLElement | null>(null);
   const isStyleLoading = useShadowDomStylesLoading(dom);
 
+  useInitialRedirect(defaultPath);
+
   const updateSidebarPositionAndHeight = useCallback(() => {
     if (!dom) return;
 
diff --git a/yarn.lock b/yarn.lock
index a8f920772333f1..7decf32970ec76 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6355,6 +6355,8 @@ __metadata:
     "@backstage/plugin-scaffolder-common": "workspace:^"
     "@backstage/plugin-search-common": "workspace:^"
     "@backstage/plugin-search-react": "workspace:^"
+    "@backstage/plugin-techdocs-common": "workspace:^"
+    "@backstage/plugin-techdocs-react": "workspace:^"
     "@backstage/test-utils": "workspace:^"
     "@backstage/types": "workspace:^"
     "@backstage/version-bridge": "workspace:^"
@@ -8503,6 +8505,7 @@ __metadata:
     "@backstage/core-components": "workspace:^"
     "@backstage/core-plugin-api": "workspace:^"
     "@backstage/frontend-plugin-api": "workspace:^"
+    "@backstage/plugin-techdocs-common": "workspace:^"
     "@backstage/test-utils": "workspace:^"
     "@backstage/theme": "workspace:^"
     "@backstage/version-bridge": "workspace:^"


Reviews:
user: login: awanlin
body: The is a nice addition @csuich2, thanks! I reviewed this when it first came in but didn't have time to finish, not sure if all my comments still make sense. There are a few that will. 👍 
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-02T18:58:47Z
user: login: awanlin
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-02T18:59:30Z
user: login: awanlin
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-02T19:00:41Z
user: login: csuich2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T14:54:42Z
user: login: alexlorenzi
body: Hey - Thanks for the contribution.
Looks good overall. Just a couple of small comments, but going to approve so that you're not blocked
⭐ 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T14:26:49Z
user: login: csuich2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T14:34:33Z
user: login: csuich2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T14:34:40Z
user: login: awanlin
body: Thanks again @csuich2 🚀 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-09T18:06:58Z
user: login: freben
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T12:09:07Z
user: login: csuich2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T18:47:25Z
user: login: csuich2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T18:54:12Z
user: login: freben
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-27T12:46:31Z

Review comments:
diff_hunk: @@ -942,6 +942,35 @@ metadata:
     backstage.io/techdocs-entity: system:default/example
 ```
 
+### Deep linking into another components TechDocs
+
+In addition to linking to another component's TechDocs the `backstage.io/techdocs-entity-path` annotation can be used to link to a
+specific page within another component's TechDocs.
+
+```yaml
+apiVersion: backstage.io/v1alpha1
+kind: System
+metadata:
+  name: example
+  namespace: default
+  title: Example
+  description: This is the parent entity
+  annotations:
+    backstage.io/techdocs-ref: dir:.
+
+---
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+  name: example-platfrom
+  title: Example Application Platform
+  namespace: default
+  description: This is the child entity
+  annotations:
+    backstage.io/techdocs-entity: system:default/example
+    backstage.io/techdocs-entity-path: /path/to/component/docs
path: docs/features/techdocs/how-to-guides.md
user: login: awanlin
body: Can this be done in a way where it doesn't matter if they provided the `/` (slash) or not? This is the source of a lot of user frustrations so best to avoid it early if we can.
created_at: 2025-04-28T15:38:03Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,7 @@
+---
+'@backstage/plugin-techdocs': minor
+'@backstage/plugin-catalog': minor
+'@backstage/plugin-techdocs-common': patch
+---
+
+Introduced "backstage.io/techdocs-entity-path" annotation which allows deep linking into another entities TechDocs in conjunction with "backstage.io/techdocs-entity".
path: .changeset/smooth-eels-think.md
user: login: awanlin
body: ```suggestion
Introduced `backstage.io/techdocs-entity-path` annotation which allows deep linking into another entities TechDocs in conjunction with `backstage.io/techdocs-entity`.
```
created_at: 2025-04-28T15:38:29Z
author_association: COLLABORATOR
diff_hunk: @@ -115,6 +115,20 @@ the TechDocs in the TechDocs page or needing multiple builds of the same docs.
 
 This is for situations where you have complex systems where they share a single repo, and likely a single TechDoc location.
 
+### backstage.io/techdocs-entity-path
+
+```yaml
+# Example:
+metadata:
+  annotations:
+    backstage.io/techdocs-entity: component:default/example
+    backstage.io/techdocs-entity-path: /path/to/this/component
+```
+
+The value of this annotation informs of the path to this components TechDocs within an external entity that owns the TechDocs.
path: docs/features/software-catalog/well-known-annotations.md
user: login: awanlin
body: ```suggestion
The value of this annotation informs of the path to this component's TechDocs within an external entity that owns the TechDocs.
```
created_at: 2025-04-28T15:39:00Z
author_association: COLLABORATOR
diff_hunk: @@ -72,6 +72,7 @@
     "@backstage/plugin-scaffolder-common": "workspace:^",
     "@backstage/plugin-search-common": "workspace:^",
     "@backstage/plugin-search-react": "workspace:^",
+    "@backstage/plugin-techdocs": "workspace:^",
path: plugins/catalog/package.json
user: login: awanlin
body: This is not ideal, this means we are brining in the entire frontend TechDocs plugin. We should move anything you are needing to the related `techdocs-react` package and then use that here instead.
created_at: 2025-04-28T15:41:36Z
author_association: COLLABORATOR
diff_hunk: @@ -66,6 +64,7 @@ import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha'
 import { usePermission } from '@backstage/plugin-permission-react';
 import { catalogTranslationRef } from '../../alpha/translation';
 import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
+import { buildTechDocsURL } from '@backstage/plugin-techdocs';
path: plugins/catalog/src/components/AboutCard/AboutCard.tsx
user: login: awanlin
body: If this is some plain node code then we could move this to `techdocs-common` and then pull it from there.
created_at: 2025-04-28T15:42:38Z
author_association: COLLABORATOR
diff_hunk: @@ -942,6 +942,35 @@ metadata:
     backstage.io/techdocs-entity: system:default/example
 ```
 
+### Deep linking into another components TechDocs
+
+In addition to linking to another component's TechDocs the `backstage.io/techdocs-entity-path` annotation can be used to link to a
+specific page within another component's TechDocs.
+
+```yaml
+apiVersion: backstage.io/v1alpha1
+kind: System
+metadata:
+  name: example
+  namespace: default
+  title: Example
+  description: This is the parent entity
+  annotations:
+    backstage.io/techdocs-ref: dir:.
+
+---
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+  name: example-platfrom
+  title: Example Application Platform
+  namespace: default
+  description: This is the child entity
+  annotations:
+    backstage.io/techdocs-entity: system:default/example
+    backstage.io/techdocs-entity-path: /path/to/component/docs
path: docs/features/techdocs/how-to-guides.md
user: login: awanlin
body: Now that I got to the code it looks like you do handle this nicely. Should we just say this works with and without?
created_at: 2025-05-02T18:53:46Z
author_association: COLLABORATOR
diff_hunk: @@ -72,6 +72,7 @@
     "@backstage/plugin-scaffolder-common": "workspace:^",
     "@backstage/plugin-search-common": "workspace:^",
     "@backstage/plugin-search-react": "workspace:^",
+    "@backstage/plugin-techdocs": "workspace:^",
path: plugins/catalog/package.json
user: login: awanlin
body: This has already been addressed 👍 
created_at: 2025-05-02T18:59:30Z
author_association: COLLABORATOR
diff_hunk: @@ -66,6 +64,7 @@ import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha'
 import { usePermission } from '@backstage/plugin-permission-react';
 import { catalogTranslationRef } from '../../alpha/translation';
 import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
+import { buildTechDocsURL } from '@backstage/plugin-techdocs';
path: plugins/catalog/src/components/AboutCard/AboutCard.tsx
user: login: awanlin
body: Same here, already addressed, you chose to move it to `techdocs-react` which is fine IMO.
created_at: 2025-05-02T19:00:41Z
author_association: COLLABORATOR
diff_hunk: @@ -942,6 +942,35 @@ metadata:
     backstage.io/techdocs-entity: system:default/example
 ```
 
+### Deep linking into another components TechDocs
+
+In addition to linking to another component's TechDocs the `backstage.io/techdocs-entity-path` annotation can be used to link to a
+specific page within another component's TechDocs.
+
+```yaml
+apiVersion: backstage.io/v1alpha1
+kind: System
+metadata:
+  name: example
+  namespace: default
+  title: Example
+  description: This is the parent entity
+  annotations:
+    backstage.io/techdocs-ref: dir:.
+
+---
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+  name: example-platfrom
+  title: Example Application Platform
+  namespace: default
+  description: This is the child entity
+  annotations:
+    backstage.io/techdocs-entity: system:default/example
+    backstage.io/techdocs-entity-path: /path/to/component/docs
path: docs/features/techdocs/how-to-guides.md
user: login: csuich2
body: Yeah, that makes sense. Done.
created_at: 2025-05-05T14:54:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,6 +64,7 @@ import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha'
 import { usePermission } from '@backstage/plugin-permission-react';
 import { catalogTranslationRef } from '../../alpha/translation';
 import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
+import { buildTechDocsURL } from '@backstage/plugin-techdocs-react';
 
 const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
path: plugins/catalog/src/components/AboutCard/AboutCard.tsx
user: login: alexlorenzi
body: It's not necessarily specific to this work, but since you're updating the deps already, could you add the techdocs-common dep and replace this with the constant 
created_at: 2025-05-07T13:56:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,3 +57,63 @@ export function toLowercaseEntityRefMaybe(
 
   return entityRef;
 }
+
+/**
+ * Get the entity path annotation from the given entity and ensure it starts with a slash.
+ *
+ * @public
+ */
+export function getEntityRootTechDocsPath(entity: Entity): string {
+  let path = entity.metadata.annotations?.[TECHDOCS_EXTERNAL_PATH_ANNOTATION];
+  if (!path) {
+    return '';
+  }
+  if (!path.startsWith('/')) {
+    path = `/${path}`;
+  }
+  return path;
+}
+
+/**
+ * Build the TechDocs URL for the given entity. This helper should be used anywhere there
+ * is a link to an entities TechDocs.
+ *
+ * @public
+ */
+export const buildTechDocsURL = (
+  entity: Entity,
+  routeFunc: TechDocsRouteFunc | undefined,
+) => {
+  if (!routeFunc) {
+    return undefined;
+  }
+
+  let namespace = entity.metadata.namespace || DEFAULT_NAMESPACE;
+  let kind = entity.kind;
+  let name = entity.metadata.name;
path: plugins/techdocs-react/src/helpers.ts
user: login: alexlorenzi
body: You can use `let {namespace, kind, name} = getCompoundEntityRef(entity);` instead here
created_at: 2025-05-07T14:07:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,6 +64,7 @@ import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha'
 import { usePermission } from '@backstage/plugin-permission-react';
 import { catalogTranslationRef } from '../../alpha/translation';
 import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
+import { buildTechDocsURL } from '@backstage/plugin-techdocs-react';
 
 const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
path: plugins/catalog/src/components/AboutCard/AboutCard.tsx
user: login: csuich2
body: Yeah, sure thing!
created_at: 2025-05-07T14:34:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,3 +57,63 @@ export function toLowercaseEntityRefMaybe(
 
   return entityRef;
 }
+
+/**
+ * Get the entity path annotation from the given entity and ensure it starts with a slash.
+ *
+ * @public
+ */
+export function getEntityRootTechDocsPath(entity: Entity): string {
+  let path = entity.metadata.annotations?.[TECHDOCS_EXTERNAL_PATH_ANNOTATION];
+  if (!path) {
+    return '';
+  }
+  if (!path.startsWith('/')) {
+    path = `/${path}`;
+  }
+  return path;
+}
+
+/**
+ * Build the TechDocs URL for the given entity. This helper should be used anywhere there
+ * is a link to an entities TechDocs.
+ *
+ * @public
+ */
+export const buildTechDocsURL = (
+  entity: Entity,
+  routeFunc: TechDocsRouteFunc | undefined,
+) => {
+  if (!routeFunc) {
+    return undefined;
+  }
+
+  let namespace = entity.metadata.namespace || DEFAULT_NAMESPACE;
+  let kind = entity.kind;
+  let name = entity.metadata.name;
path: plugins/techdocs-react/src/helpers.ts
user: login: csuich2
body: Ah, excellent. Thanks!
created_at: 2025-05-07T14:34:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,17 +47,41 @@ import {
   handleMetaRedirects,
 } from '../../transformers';
 import { useNavigateUrl } from './useNavigateUrl';
-import { useParams } from 'react-router-dom';
+import { useLocation, useNavigate, useParams } from 'react-router-dom';
 
 const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)';
 
+// If a defaultPath is specified then we should navigate to that path replacing the
+// current location in the history. This should only happen on the initial load so
+// navigating to the root of the docs doesn't also redirect.
+const useInitialRedirect = (defaultPath?: string) => {
+  const [hasRun, setHasRun] = useState(false);
path: plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
user: login: freben
body: this should be a `useRef` - otherwise, you always force a superfluous repaint of the whole component when you change the value, even when no redirect happened
created_at: 2025-05-13T11:57:41Z
author_association: MEMBER
diff_hunk: @@ -47,17 +47,41 @@ import {
   handleMetaRedirects,
 } from '../../transformers';
 import { useNavigateUrl } from './useNavigateUrl';
-import { useParams } from 'react-router-dom';
+import { useLocation, useNavigate, useParams } from 'react-router-dom';
 
 const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)';
 
+// If a defaultPath is specified then we should navigate to that path replacing the
+// current location in the history. This should only happen on the initial load so
+// navigating to the root of the docs doesn't also redirect.
+const useInitialRedirect = (defaultPath?: string) => {
+  const [hasRun, setHasRun] = useState(false);
+
+  const location = useLocation();
+  const navigate = useNavigate();
+  const { '*': currPath = '' } = useParams();
+
+  useEffect(() => {
path: plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
user: login: freben
body: This could probably be a `useLayoutEffect` so that it happens even before DOM paint, possibly making it even more unintrusive / fast
created_at: 2025-05-13T11:58:19Z
author_association: MEMBER
diff_hunk: @@ -47,17 +47,41 @@ import {
   handleMetaRedirects,
 } from '../../transformers';
 import { useNavigateUrl } from './useNavigateUrl';
-import { useParams } from 'react-router-dom';
+import { useLocation, useNavigate, useParams } from 'react-router-dom';
 
 const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)';
 
+// If a defaultPath is specified then we should navigate to that path replacing the
+// current location in the history. This should only happen on the initial load so
+// navigating to the root of the docs doesn't also redirect.
+const useInitialRedirect = (defaultPath?: string) => {
+  const [hasRun, setHasRun] = useState(false);
+
+  const location = useLocation();
+  const navigate = useNavigate();
+  const { '*': currPath = '' } = useParams();
+
+  useEffect(() => {
+    // Only run once
+    if (hasRun) {
+      return;
+    }
+    setHasRun(true);
+
+    if (currPath === '' && defaultPath !== '') {
+      navigate(`${location.pathname}${defaultPath}`, { replace: true });
+    }
+  }, [hasRun, currPath, defaultPath, location, navigate]);
path: plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
user: login: freben
body: Another approach could have been to make this an empty array and add a lint disabling comment since we explicitly want it to only run once, no matter if any values change. Then the `hasRun` actually wouldn't strictly be needed. But either approach is fine. 
created_at: 2025-05-13T11:59:32Z
author_association: MEMBER
diff_hunk: @@ -122,6 +132,13 @@ export type TechDocsReaderPageValue = {
   onReady?: () => void;
 };
 
+// @public
+export type TechDocsRouteFunc = RouteFunc<{
path: plugins/techdocs-react/report.api.md
user: login: freben
body: Maybe don't make this type public, and instead inline it in the `buildTechDocsURL` above? Less API pollution
created_at: 2025-05-13T12:05:57Z
author_association: MEMBER
diff_hunk: @@ -431,6 +431,7 @@ export const TechDocsReaderPageContent: (
 // @public
 export type TechDocsReaderPageContentProps = {
   entityRef?: CompoundEntityRef;
+  defaultPath?: string;
path: plugins/techdocs/report.api.md
user: login: freben
body: We don't really need this do we?

The content [already has access](https://github.com/backstage/backstage/blob/674def926408995e0b589badb74f848a3ddef174/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx#L91) to the entire entity being rendered (the `entityMetadata` value). Couldn't you already extract all that you need from inside this component without adding a new prop?
created_at: 2025-05-13T12:08:56Z
author_association: MEMBER
diff_hunk: @@ -431,6 +431,7 @@ export const TechDocsReaderPageContent: (
 // @public
 export type TechDocsReaderPageContentProps = {
   entityRef?: CompoundEntityRef;
+  defaultPath?: string;
path: plugins/techdocs/report.api.md
user: login: csuich2
body: The issue is that `entityMetadata` in the location you referenced will be the entity referenced in the `backstage.io/techdocs-entity` annotation if specified, not the entity we want to extract the path from.
created_at: 2025-05-13T18:47:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,17 +47,41 @@ import {
   handleMetaRedirects,
 } from '../../transformers';
 import { useNavigateUrl } from './useNavigateUrl';
-import { useParams } from 'react-router-dom';
+import { useLocation, useNavigate, useParams } from 'react-router-dom';
 
 const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)';
 
+// If a defaultPath is specified then we should navigate to that path replacing the
+// current location in the history. This should only happen on the initial load so
+// navigating to the root of the docs doesn't also redirect.
+const useInitialRedirect = (defaultPath?: string) => {
+  const [hasRun, setHasRun] = useState(false);
+
+  const location = useLocation();
+  const navigate = useNavigate();
+  const { '*': currPath = '' } = useParams();
+
+  useEffect(() => {
+    // Only run once
+    if (hasRun) {
+      return;
+    }
+    setHasRun(true);
+
+    if (currPath === '' && defaultPath !== '') {
+      navigate(`${location.pathname}${defaultPath}`, { replace: true });
+    }
+  }, [hasRun, currPath, defaultPath, location, navigate]);
path: plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx
user: login: csuich2
body: Yeah, that seems to do the trick!
created_at: 2025-05-21T18:54:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: backstage-goalie[bot]
created_at: 2025-04-28T15:33:35Z
body: > [!IMPORTANT]
> This PR includes changes that affect public-facing API. Please ensure you are adding/updating documentation for new features or behavior.

## Changed Packages

| Package Name | Package Path | Changeset Bump | Current Version |
|:-------------|:-------------|:--------------:|:----------------|
| @backstage/plugin-catalog | plugins/catalog | **minor** | `v1.30.0` |
| @backstage/plugin-techdocs-common | plugins/techdocs-common | **patch** | `v0.1.0` |
| @backstage/plugin-techdocs-react | plugins/techdocs-react | **minor** | `v1.2.17` |
| @backstage/plugin-techdocs | plugins/techdocs | **minor** | `v1.12.6` |


<!-- changeset-feedback -->
author_association: CONTRIBUTOR
user: login: laimis9133
created_at: 2025-05-02T09:45:27Z
body: Great feature, would love to see this in Techdocs as soon as possible!
author_association: CONTRIBUTOR
user: login: csuich2
created_at: 2025-05-08T17:23:32Z
body: @awanlin Thanks for the feedback. Looks like your requested changes are blocking from merging. Does everything look good now?
author_association: CONTRIBUTOR
user: login: awanlin
created_at: 2025-05-09T18:08:33Z
body: Hi @freben or @camilaibs, looks like we'll need your approval as well, think that's due to the lock file change.
author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-05-27T12:49:36Z
body: Thank you for contributing to Backstage! The changes in this pull request will be part of the `1.40.0` release, scheduled for Tue, 17 Jun 2025.
author_association: CONTRIBUTOR