Building an Internal API Discovery Portal

A discovery portal is the difference between “we have a catalog” and “an engineer can find the right API in ten seconds.” This how-to assembles the API explorer, ownership and lifecycle facets, and a search backend that scales past the default in-memory index — turning registered API entities into a browsable, filterable directory. It is the consumer-facing payoff of Internal API Portals & Discovery within Developer Experience & Self-Service Platforms, building on entities published per Publishing OpenAPI Specs to the Service Catalog.

Prerequisites

  • Backstage >= 1.20.0 with API entities already ingested.
  • @backstage/plugin-api-docs >= 0.11.0 and @backstage/plugin-search >= 1.4.0 with @backstage/plugin-search-backend-module-pg for a Postgres-backed index.
  • PostgreSQL >= 14 as the catalog and search store at non-trivial scale.
  • App access to App.tsx, apis.ts, and the search backend module wiring.
  • Optional: your RBAC setup configured if API contracts must be gated.

Exact Configuration

1. Mount the API explorer route

// packages/app/src/App.tsx
// Requires @backstage/plugin-api-docs >= 0.11.0
import { ApiExplorerPage } from '@backstage/plugin-api-docs';

// Inside <FlatRoutes>:
// <Route path="/api-docs" element={<ApiExplorerPage />} />

2. Add a sidebar entry for discoverability

// packages/app/src/components/Root/Root.tsx
// Requires @backstage/core-components >= 0.14.0
import ExtensionIcon from '@material-ui/icons/Extension';
import { SidebarItem } from '@backstage/core-components';

// Within <SidebarGroup>:
// <SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />

3. Wire the Postgres search backend

// packages/backend/src/index.ts
// Requires @backstage/plugin-search-backend-module-pg >= 0.5.0
import { createBackend } from '@backstage/backend-defaults';

const backend = createBackend();
backend.add(import('@backstage/plugin-search-backend'));
backend.add(import('@backstage/plugin-search-backend-module-pg'));
backend.add(import('@backstage/plugin-search-backend-module-catalog'));
backend.start();

4. Index API entities and schedule collation

# app-config.yaml
# Requires Backstage >= 1.20.0
search:
  pg:
    highlightOptions:
      useHighlight: true
backend:
  database:
    client: pg
    connection:
      host: ${PG_HOST}
      user: ${PG_USER}
      password: ${PG_PASSWORD}
catalog:
  rules:
    - allow: [Component, API, System, Group]

5. Add a facet filter for type, lifecycle, and owner

The API explorer ships with column filters out of the box. For a richer landing experience, embed a search filter panel scoped to API entities. The card-composition technique mirrors Building a Custom Entity Page Card in Backstage.

// packages/app/src/components/apiSearch/ApiSearchFilters.tsx
// Requires @backstage/plugin-search-react >= 1.7.0
import { SearchFilter } from '@backstage/plugin-search-react';

export const ApiSearchFilters = () => (
  <>
    <SearchFilter.Select name="spec.type" label="Type"
      values={['openapi', 'grpc', 'asyncapi']} />
    <SearchFilter.Select name="spec.lifecycle" label="Lifecycle"
      values={['experimental', 'production', 'deprecated']} />
  </>
);

Validation

# Requires Backstage >= 1.20.0
# 1. API entities are present in the catalog
curl -s "${CATALOG_BACKEND_URL}/api/catalog/entities?filter=kind=api" \
  -H "Authorization: Bearer ${CATALOG_SERVICE_TOKEN}" | jq 'length'
# Expected: matches the number of registered APIs

# 2. Search returns API results with highlighting
curl -s "${SEARCH_BACKEND_URL}/api/search/query?term=payments" \
  -H "Authorization: Bearer ${CATALOG_SERVICE_TOKEN}" \
  | jq '[.results[] | select(.document.kind=="API")] | length'
# Expected: >= 1

# 3. The explorer route responds
curl -s -o /dev/null -w "%{http_code}\n" "${PORTAL_URL}/api-docs"
# Expected: 200

Edge Cases & Troubleshooting

Symptom Root Cause Resolution
Search returns no APIs Catalog collator not indexing the API kind Confirm the catalog search module is registered and re-run collation
Explorer lists APIs but docs are blank OpenAPI spec invalid or $text unresolved Validate the spec and the entity per the publishing how-to
Filters show no facet values Filter name does not match entity field Use the exact spec field path, e.g. spec.lifecycle
Search slow above ~500 entities In-memory Lunr index still active Confirm the Postgres search module loaded and Lunr is not also registered
Deprecated APIs clutter results No lifecycle filter applied Default the explorer to lifecycle != deprecated and let users opt in

Frequently Asked Questions

Do we need Elasticsearch, or is Postgres search enough?

Postgres search comfortably handles thousands of entities with highlighting, and it avoids running a second datastore. Reach for Elasticsearch only when you need advanced relevance tuning, fuzzy matching, or tens of thousands of documents — for most internal portals, the Postgres module is the right default.

How do we keep the portal from exposing sensitive API contracts?

Gate the explorer route and the search results behind the permission framework so only authorized groups see restricted APIs, and tag sensitive entities so they are filtered server-side rather than hidden only in the UI. Pair this with your role-based access control setup to enforce it at the API layer.