In this post, we’ll build a production-grade Angular documentation portal that aggregates multiple OpenAPI specifications into a single, searchable interface.
Prerequisites
This is the list of all the prerequisites:
- Node.js 20+
- Angular CLI 19+
- Familiarity with TypeScript
- Basic understanding of Angular routing and dependency injection
Overview
Most microservice architectures have the same documentation problem: each service runs its own Swagger UI on a different port, developers have to remember which URL belongs to which service, and there is no unified place to search across all APIs. This article walks through building a production-grade Angular documentation portal that aggregates multiple OpenAPI specifications into a single, searchable interface.
What you’ll build: a standalone Angular 19 application with a sidebar navigation, signal-based search, lazy-loaded Swagger UI instances, and clean URL routing between API specs — all without a backend.
Architecture Overview
The portal follows a simple layout: a persistent sidebar lists all registered APIs, and the main content area renders a Swagger UI instance for whichever API the user selects.
┌─────────────────────────────────────────────────┐
│ API Portal │
│ │
│ ┌──────────────┐ ┌───────────────────────────┐│
│ │ Sidebar │ │ Router Outlet ││
│ │ │ │ ││
│ │ [search] │ │ /apis → Home ││
│ │ │ │ /apis/:apiId → Detail ││
│ │ Users API │ │ ││
│ │ Orders API │ │ <app-swagger-viewer> ││
│ │ Payments API│ │ dynamic import() ││
│ │ │ │ SwaggerUI({ domNode }) ││
│ └──────────────┘ └───────────────────────────┘│
└─────────────────────────────────────────────────┘
Key decisions:
- Swagger UI (~2.5MB minified) is loaded via a dynamic
import()— it only downloads when the user opens an API detail page, not on app startup. - State is managed entirely with Angular signals — no RxJS
BehaviorSubjectpatterns, no NgRx. - All components use
ChangeDetectionStrategy.OnPushand standalone APIs. - API specs are served as static JSON files from
public/api-specs/— no backend required.
Let’s code
Project Setup
ng new api-docs-portal --style=scss --routing=true
cd api-docs-portal
npm install swagger-ui
Note:
swagger-ui5.x does not ship TypeScript declarations. You’ll need to add them manually — covered below.
Step 1: Define the API Registry Model
Create src/app/models/api-spec.model.ts:
export type ApiStatus = 'stable' | 'beta' | 'deprecated';
export interface ApiSpec {
id: string;
name: string;
description: string;
version: string;
specUrl: string;
tags: string[];
status: ApiStatus;
baseUrl?: string;
}
The id field doubles as the route parameter (/apis/:apiId). The specUrl is a path served by Angular’s dev server from the public/ directory — no backend needed.
Step 2: ApiRegistryService — Signals for Reactive State
Create src/app/services/api-registry.service.ts:
import { Injectable, computed, signal } from '@angular/core';
import { ApiSpec } from '../models/api-spec.model';
@Injectable({ providedIn: 'root' })
export class ApiRegistryService {
private readonly _apis = signal<ApiSpec[]>([
{
id: 'users-api',
name: 'Users API',
description: 'User management, authentication, and profile endpoints',
version: '2.1.0',
specUrl: '/api-specs/users-api.json',
tags: ['users', 'auth', 'profiles'],
status: 'stable',
baseUrl: 'https://api.example.com/v2',
},
{
id: 'orders-api',
name: 'Orders API',
description: 'Order processing, fulfillment, and order item management',
version: '1.4.0',
specUrl: '/api-specs/orders-api.json',
tags: ['orders', 'fulfillment', 'inventory'],
status: 'stable',
baseUrl: 'https://api.example.com/orders',
},
{
id: 'payments-api',
name: 'Payments API',
description: 'Payment processing, billing, and refund operations',
version: '3.0.0-beta',
specUrl: '/api-specs/payments-api.json',
tags: ['payments', 'billing', 'refunds'],
status: 'beta',
baseUrl: 'https://api.example.com/payments',
},
]);
private readonly _searchQuery = signal('');
readonly allApis = this._apis.asReadonly();
readonly filteredApis = computed(() => {
const q = this._searchQuery().toLowerCase().trim();
if (!q) return this._apis();
return this._apis().filter(api =>
api.name.toLowerCase().includes(q) ||
api.description.toLowerCase().includes(q) ||
api.tags.some(t => t.toLowerCase().includes(q))
);
});
readonly totalCount = computed(() => this._apis().length);
getById(id: string): ApiSpec | undefined {
return this._apis().find(a => a.id === id);
}
setSearchQuery(query: string): void {
this._searchQuery.set(query);
}
}
filteredApis is a computed() signal — it re-evaluates automatically whenever _searchQuery or _apis changes. Angular’s change detection picks up the new value and re-renders only the components that read it. No Subject, no manual subscriptions.
allApis is exposed as a read-only signal via asReadonly() — callers can read the value but cannot call .set() on it.
Step 3: App Configuration and Routing
Configure the providers in src/app/app.config.ts:
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(),
],
};
withComponentInputBinding() tells the router to bind route parameters directly to component @Input() or input() signals. This eliminates the need to inject ActivatedRoute to read the :apiId parameter in most cases — though in ApiDetailComponent we use toSignal(route.params) for a computed derivation.
Define the routes in src/app/app.routes.ts:
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', redirectTo: 'apis', pathMatch: 'full' },
{
path: 'apis',
loadComponent: () =>
import('./pages/home/home.component').then(m => m.HomeComponent),
},
{
path: 'apis/:apiId',
loadComponent: () =>
import('./pages/api-detail/api-detail.component').then(m => m.ApiDetailComponent),
},
{ path: '**', redirectTo: 'apis' },
];
Both page components are lazy-loaded via loadComponent. They become separate JavaScript chunks — the router downloads each chunk only when the route is first activated.
Step 4: App Shell and Sidebar
AppComponent is the persistent shell. It renders the fixed sidebar alongside a <router-outlet> for the page content.
// src/app/app.component.ts
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, SidebarComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="portal-layout">
<aside class="portal-sidebar">
<app-sidebar />
</aside>
<main class="portal-content">
<router-outlet />
</main>
</div>
`,
styles: [`
.portal-layout { display: flex; height: 100vh; overflow: hidden; }
.portal-sidebar { width: var(--sidebar-width); flex-shrink: 0; overflow-y: auto; }
.portal-content { flex: 1; height: 100%; overflow-y: auto; min-width: 0; }
`],
})
export class AppComponent {}
min-width: 0 on .portal-content is important. Without it, flex children can overflow their container when content is wide — a common issue with Swagger UI’s wide tables.
SidebarComponent reads filteredApis directly from the registry service and delegates search input back to setSearchQuery:
// src/app/components/sidebar/sidebar.component.ts
@Component({
selector: 'app-sidebar',
standalone: true,
imports: [RouterLink, RouterLinkActive],
templateUrl: './sidebar.component.html',
styleUrl: './sidebar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SidebarComponent {
private readonly registry = inject(ApiRegistryService);
readonly filteredApis = this.registry.filteredApis;
readonly totalCount = this.registry.totalCount;
onSearch(event: Event): void {
const query = (event.target as HTMLInputElement).value;
this.registry.setSearchQuery(query);
}
}
The sidebar template uses @for with track api.id for efficient DOM reconciliation and routerLinkActive="active" to highlight the currently selected API:
@for (api of filteredApis(); track api.id) {
<a
class="nav-item"
[routerLink]="['/apis', api.id]"
routerLinkActive="active"
>
<div class="nav-item-main">
<span class="nav-item-name">{{ api.name }}</span>
<span class="status-badge status-{{ api.status }}">{{ api.status }}</span>
</div>
<div class="nav-item-meta">
<span class="nav-item-version">v{{ api.version }}</span>
</div>
</a>
} @empty {
<p class="nav-empty">No APIs match your search.</p>
}
Step 5: Home Page
The home page renders a grid of API cards. Each card links to /apis/:apiId:
// src/app/pages/home/home.component.ts
@Component({
selector: 'app-home',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="home">
<header class="home-header">
<h1>API Documentation Portal</h1>
<p>Browse and explore {{ registry.totalCount() }} available API specifications.</p>
</header>
<div class="api-grid">
@for (api of registry.allApis(); track api.id) {
<a class="api-card" [routerLink]="['/apis', api.id]">
<div class="api-card-header">
<h3>{{ api.name }}</h3>
<span class="status-badge status-{{ api.status }}">{{ api.status }}</span>
</div>
<p class="api-card-description">{{ api.description }}</p>
<div class="api-card-footer">
<span class="api-version">v{{ api.version }}</span>
<div class="api-tags">
@for (tag of api.tags; track tag) {
<span class="api-tag">{{ tag }}</span>
}
</div>
</div>
</a>
}
</div>
</div>
`,
})
export class HomeComponent {
readonly registry = inject(ApiRegistryService);
}
registry.allApis() returns all APIs regardless of search — the home page shows the complete catalog.
Step 6: SwaggerViewerComponent — The Core
This is the most complex piece. It manages the Swagger UI lifecycle: lazy-loading the library, initializing it against a DOM node, and reacting when the parent passes a different specUrl.
Declaring Types for swagger-ui 5.x
swagger-ui 5.x does not ship TypeScript declarations. Without them, import('swagger-ui') fails the type check. Create src/swagger-ui.d.ts:
// swagger-ui 5.x does not ship TypeScript declarations.
declare module 'swagger-ui' {
export interface SwaggerUIOptions {
url?: string;
spec?: Record<string, unknown>;
domNode?: HTMLElement;
dom_id?: string;
layout?: string;
deepLinking?: boolean;
filter?: boolean | string;
tryItOutEnabled?: boolean;
displayRequestDuration?: boolean;
defaultModelsExpandDepth?: number;
defaultModelExpandDepth?: number;
docExpansion?: 'list' | 'full' | 'none';
requestInterceptor?: (req: unknown) => unknown;
responseInterceptor?: (res: unknown) => unknown;
onComplete?: () => void;
onFailure?: (err: unknown) => void;
presets?: unknown[];
plugins?: unknown[];
}
function SwaggerUI(options: SwaggerUIOptions): unknown;
export default SwaggerUI;
An ambient module declaration teaches the TypeScript compiler about the module’s shape without requiring @types packages.
The Component
// src/app/components/swagger-viewer/swagger-viewer.component.ts
import {
AfterViewInit, ChangeDetectionStrategy, Component,
DestroyRef, ElementRef, ViewChild, inject, input, signal,
} from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { skip } from 'rxjs';
@Component({
selector: 'app-swagger-viewer',
standalone: true,
templateUrl: './swagger-viewer.component.html',
styleUrl: './swagger-viewer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SwaggerViewerComponent implements AfterViewInit {
readonly specUrl = input.required<string>();
readonly apiName = input('');
@ViewChild('swaggerContainer') private container!: ElementRef<HTMLDivElement>;
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
private readonly destroyRef = inject(DestroyRef);
// Must be called in the injection context (field initializer or constructor),
// NOT inside ngAfterViewInit — the injection context is not active there.
private readonly specUrl$ = toObservable(this.specUrl);
constructor() {
// Clean up the DOM when this component is destroyed.
// SwaggerUI 5.x has no public destroy() method.
this.destroyRef.onDestroy(() => {
if (this.container?.nativeElement) {
this.container.nativeElement.innerHTML = '';
}
});
}
ngAfterViewInit(): void {
// Load the initial URL.
this.loadSwaggerUI(this.specUrl());
// React to subsequent URL changes (user navigates to a different API).
// skip(1) discards the immediate replay emission so we don't double-initialize.
this.specUrl$
.pipe(skip(1), takeUntilDestroyed(this.destroyRef))
.subscribe(url => this.loadSwaggerUI(url));
}
private async loadSwaggerUI(url: string): Promise<void> {
if (!url || !this.container?.nativeElement) return;
this.loading.set(true);
this.loadError.set(null);
this.container.nativeElement.innerHTML = '';
try {
const { default: SwaggerUI } = await import('swagger-ui');
SwaggerUI({
url,
domNode: this.container.nativeElement,
layout: 'BaseLayout',
filter: true,
tryItOutEnabled: true,
displayRequestDuration: true,
defaultModelsExpandDepth: 1,
docExpansion: 'list',
onComplete: () => this.loading.set(false),
onFailure: () => {
this.loading.set(false);
this.loadError.set(`Could not load API specification.\nURL: ${url}`);
},
});
} catch {
this.loading.set(false);
this.loadError.set('Failed to initialize Swagger UI.');
}
}
}
Two patterns here deserve explanation.
toObservable(this.specUrl) in a field initializer, not in ngAfterViewInit. toObservable() internally calls inject(ChangeDetectorRef) — it must run while Angular’s injection context is active. Field initializers and constructors run in that context. Lifecycle hooks do not. Calling it inside ngAfterViewInit throws NG0203: toObservable() can only be used within an injection context.
skip(1) on the subscription. toObservable() behaves like a BehaviorSubject — it emits the current signal value immediately upon subscription. But we already call this.loadSwaggerUI(this.specUrl()) synchronously at the top of ngAfterViewInit. Without skip(1), the first emission would trigger a second initialization for the same URL.
domNode vs dom_id. SwaggerUI’s dom_id option expects a CSS selector string like "#swagger-ui" and queries the document for it. When you render multiple SwaggerUI instances (or re-use the same selector), IDs collide and the second instance overwrites the first. The domNode option accepts an HTMLElement directly — use it whenever you control the container reference via @ViewChild.
Template
<!-- swagger-viewer.component.html -->
<div class="viewer-wrapper">
@if (loading()) {
<div class="viewer-loading" aria-live="polite">
<div class="spinner"></div>
<p>Loading <strong>{{ apiName() }}</strong> documentation…</p>
</div>
}
@if (loadError(); as err) {
<div class="viewer-error" role="alert">
<strong>Failed to load specification</strong>
<pre>{{ err }}</pre>
</div>
}
<div
#swaggerContainer
class="swagger-container"
[class.hidden]="loading() || !!loadError()"
></div>
</div>
The #swaggerContainer div is never destroyed — it’s only hidden while loading. SwaggerUI writes directly to nativeElement; keeping the node alive avoids re-initialization on every change-detection pass.
Step 7: ApiDetailComponent
// src/app/pages/api-detail/api-detail.component.ts
@Component({
selector: 'app-api-detail',
standalone: true,
imports: [SwaggerViewerComponent, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (api(); as api) {
<div class="detail-header">
<div class="detail-breadcrumb">
<a routerLink="/apis">APIs</a>
<span>/</span>
<span>{{ api.name }}</span>
</div>
<div class="detail-title-row">
<div>
<h1>{{ api.name }}</h1>
<p>{{ api.description }}</p>
</div>
<div class="detail-meta">
<span class="status-badge status-{{ api.status }}">{{ api.status }}</span>
<span class="detail-version">v{{ api.version }}</span>
</div>
</div>
</div>
<div class="detail-body">
<app-swagger-viewer [specUrl]="api.specUrl" [apiName]="api.name" />
</div>
} @else {
<div class="not-found">
<h2>API not found</h2>
<a routerLink="/apis">← Back to all APIs</a>
</div>
}
`,
})
export class ApiDetailComponent {
private readonly route = inject(ActivatedRoute);
private readonly registry = inject(ApiRegistryService);
private readonly apiId = toSignal(
this.route.params.pipe(map(p => p['apiId'] as string)),
{ initialValue: '' }
);
readonly api = computed(() => this.registry.getById(this.apiId()));
}
apiId is a signal derived from the route params observable via toSignal(). When the user clicks a different API in the sidebar, the router updates the params, apiId emits the new value, api recomputes, and the template re-renders — all without any manual subscription management.
If api() returns undefined (unknown route param), Angular renders the @else block instead of throwing.
Step 8: Configure angular.json
Two changes are required.
1. Add the swagger-ui stylesheet globally (it can’t be scoped to a component — it styles elements SwaggerUI writes to the DOM at runtime):
"styles": [
"node_modules/swagger-ui/dist/swagger-ui.css",
"src/styles.scss"
]
2. Allow swagger-ui as a CommonJS dependency and raise the bundle budget:
"allowedCommonJsDependencies": ["swagger-ui"],
"budgets": [
{ "type": "initial", "maximumWarning": "3mb", "maximumError": "5mb" },
{ "type": "anyComponentStyle", "maximumWarning": "8kb", "maximumError": "16kb" }
]
The default Angular budget warns at 500kb and errors at 1mb. swagger-ui is ~2.5MB minified — it ships as a CommonJS module with bundled React and its own CSS. Raising the budget is unavoidable.
The allowedCommonJsDependencies entry suppresses the Angular CLI build warning that fires for CommonJS packages (Angular’s build pipeline prefers ESM for better tree-shaking, but swagger-ui doesn’t offer an ESM build).
3. Suppress the SwaggerUI top bar globally in src/styles.scss:
.swagger-ui {
.topbar { display: none !important; }
}
SwaggerUI renders its own navigation bar by default. Since the portal provides its own navigation, the topbar is redundant. This override must live in the global stylesheet — component-scoped styles can’t pierce SwaggerUI’s shadow DOM boundary (though SwaggerUI doesn’t actually use shadow DOM, Angular’s view encapsulation adds _nghost/_ngcontent attributes that break class matching for externally rendered content).
Step 9: OpenAPI Spec Files
Place your spec files in public/api-specs/. Angular’s build pipeline copies the entire public/ directory to dist/ — files there are served at the root path:
public/
└── api-specs/
├── users-api.json
├── orders-api.json
└── payments-api.json
Available at:
http://localhost:4200/api-specs/users-api.jsonhttp://localhost:4200/api-specs/orders-api.jsonhttp://localhost:4200/api-specs/payments-api.json
In production, you’d serve the spec files from a real API gateway or CDN, updating specUrl values in ApiRegistryService to point to the live endpoints.
Running the Portal
npm start
Navigate to http://localhost:4200.
The sidebar lists all three APIs. Click any card to open the detail page — the first navigation downloads the swagger-ui chunk. Subsequent navigations use the cached module.
To verify lazy loading, open DevTools → Network, filter by JS, and watch for a swagger-ui-*.js chunk appearing on the first click to /apis/:apiId.
Performance Considerations
Bundle size. swagger-ui dominates the bundle. Even with the raised budget, load the library lazily (the import() approach above) to keep the initial bundle lean. The initial download serves only your app shell — about 150-200KB. The swagger-ui chunk downloads on first navigation to an API detail page.
Spec file size. Large OpenAPI specs (> 500KB) can visibly slow SwaggerUI initialization. Options: split monolithic specs into tagged sub-specs, enable gzip on your CDN, or pre-validate specs to remove redundant $ref chains.
Re-initialization cost. Every specUrl change clears innerHTML and re-runs SwaggerUI(). SwaggerUI has no incremental update API — it always does a full re-render. Avoid rapid navigation between APIs (e.g., via keyboard shortcuts) without debouncing the URL signal.
OnPush throughout. All components use ChangeDetectionStrategy.OnPush. Angular’s change detection skips OnPush subtrees unless an @Input reference changes, an event fires inside the component, or a signal the template reads emits a new value. The signal-based state model integrates naturally — computed signals mark their consumers dirty automatically.
Common Pitfalls
Calling toObservable() in a lifecycle hook. toObservable() requires an active injection context. The symptom is a runtime error: NG0203: toObservable() can only be used within an injection context. Fix: move the call to a field initializer or constructor.
Using dom_id with multiple instances. If you use dom_id: '#swagger-ui' and the component is destroyed and re-created, the old ID may still be in the DOM or reference a stale element. Use domNode: this.container.nativeElement to pass the reference directly.
Missing allowedCommonJsDependencies. Without it, ng build emits a warning for every CommonJS module in swagger-ui’s dependency tree. It doesn’t break the build but pollutes CI output. Add "allowedCommonJsDependencies": ["swagger-ui"] to quiet it.
Not clearing innerHTML on spec change. SwaggerUI 5.x has no destroy() method. If you call SwaggerUI() into an already-populated container, it appends a second UI instance below the first. Always clear container.nativeElement.innerHTML = '' before re-initializing.
Scoped styles don’t reach SwaggerUI elements. Component styles with ViewEncapsulation.Emulated (the default) add attribute selectors like [_ngcontent-abc] to all rules. SwaggerUI’s elements don’t carry those attributes, so scoped rules don’t apply. Global styles in styles.scss work. If you need component-level isolation, use ViewEncapsulation.None on the viewer component — but understand that this lets its styles bleed into the global scope.
Alternative Approaches

Redoc is worth considering when the portal is consumer-facing (external developers, partners). It renders OpenAPI specs with better typography and a document-style layout, though it doesn’t support interactive requests. Swap SwaggerViewerComponent for a Redoc web component and the rest of the architecture stays identical.
For internal developer portals where “Try it out” is critical, SwaggerUI remains the default choice despite its bundle weight.
Key Takeaways
- Signal-based state (
signal,computed,input.required) eliminates subscription boilerplate and integrates cleanly withChangeDetectionStrategy.OnPush. - Dynamic
import('swagger-ui')defers the largest dependency until it’s actually needed — keep the initial bundle lean and let the browser cache the chunk after the first load. toObservable()must run in the injection context;skip(1)prevents double-initialization;domNodeavoids ID collisions between SwaggerUI instances.
The complete source code is available on GitHub.
Support me through GitHub Sponsors.
Thank you for Reading !! See you in the next post.


