Larger Angular applications often need panels or tabs interfaces where each panel is driven by the URL. Most existing implementations use a single route and manage panel state on top of it — this works for simple cases but loses all the advanced features the Angular Router offers (lazy loading, guards, nested routes, etc.).
route-with-dynamic-outlets solves this by dynamically registering named <router-outlet> children at match time, so every panel is a first-class Angular route. The library is a focused proof of concept that shows a clean, router-native direction for advanced panel/tab UIs.
npm install @skitionek/route-with-dynamic-outletsLocal development and CI target Node.js 22.14 or newer.
This repository includes a dev container configuration in .devcontainer/devcontainer.json.
In VS Code, open the command palette and run:
Dev Containers: Reopen in Container
The container uses Node.js 22, installs dependencies with npm ci, and forwards port 4173 for the local docs demo.
npm run demoThen open http://localhost:4173. This serves the interactive demo from the docs/ directory.
The workspace includes launch configurations for debugging Jest and individual TypeScript files:
Debug Current TS File
Debug Jest Tests
Debug Jest Current File
Open the Run and Debug panel in VS Code and pick the configuration that matches the file or test suite you want to inspect.
import { Component } from '@angular/core';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { map, Observable, switchMap } from 'rxjs';
import { CommonModule } from '@angular/common';
import { OutletsMap } from '@skitionek/route-with-dynamic-outlets';
@Component({
standalone: true,
selector: 'app-dynamic-outlets',
template: `
<a [routerLink]="[{ outlets: { A: [''], B: [''] } }]">Open A and B</a>
<router-outlet
*ngFor="let outlet of outlets$ | async"
[name]="outlet"
></router-outlet>
`,
imports: [RouterModule, CommonModule],
})
export class DynamicOutletsComponent {
constructor(protected activatedRoute: ActivatedRoute) {}
outlets$ = this.activatedRoute.data.pipe(
switchMap(({ outlets$ }) => outlets$ as Observable<OutletsMap>),
map(outlets => Object.keys(outlets ?? {}))
);
}import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { createRouteWithDynamicOutlets } from '@skitionek/route-with-dynamic-outlets';
import { DynamicOutletsComponent } from './dynamic-outlets.component';
import { PlaceholderComponent } from './placeholder/placeholder.component';
const routes: Routes = [
createRouteWithDynamicOutlets({
path: 'workspace',
component: DynamicOutletsComponent,
dynamicOutletFactory: () => ({
path: '',
component: PlaceholderComponent,
}),
}),
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AppWorkspaceRoutingModule {}Routes with dynamic outlets can be nested arbitrarily. Pass the result of an inner createRouteWithDynamicOutlets call as the return value of dynamicOutletFactory:
createRouteWithDynamicOutlets({
path: 'a',
component: DynamicOutletsComponent,
dynamicOutletFactory: () =>
createRouteWithDynamicOutlets({
path: 'b',
component: DynamicOutletsComponent,
dynamicOutletFactory: () => ({
path: 'c',
component: PlaceholderComponent,
}),
}),
})Supply a matcher function instead of (or in addition to) a path to match non-standard URL patterns:
createRouteWithDynamicOutlets({
component: DynamicOutletsComponent,
matcher: url => {
if (url.length === 1 && url[0].path.match(/^@[\w]+$/)) {
return {
consumed: url,
posParams: { username: new UrlSegment(url[0].path.slice(1), {}) },
};
}
return null;
},
dynamicOutletFactory: () => ({
path: '',
component: PlaceholderComponent,
}),
})createRouteWithDynamicOutlets wraps the Angular Route object with a custom matcher. Each time the router evaluates the route, the matcher:
- Runs the underlying path/matcher check (using Angular's built-in
defaultUrlMatcheralgorithm by default). - Reads the current named-outlet children from the
UrlSegmentGroup. - Calls
dynamicOutletFactoryfor any new outlets and removes routes for outlets that are no longer present. - Pushes the updated
OutletsMapinto aBehaviorSubjectstored onroute.data.outlets$.
Your component subscribes to activatedRoute.data.outlets$ and renders a <router-outlet> for each key, giving every outlet a full, independent router lifecycle.
Creates an Angular Route with a custom matcher that dynamically manages child outlet routes.
| Parameter | Type | Description |
|---|---|---|
route |
RouteWithDynamicOutlets |
Angular Route extended with dynamicOutletFactory. path and matcher are both optional and behave the same as in a standard Route. |
Extends Angular's Route with one additional field:
| Field | Type | Description |
|---|---|---|
dynamicOutletFactory |
DynamicOutletRouteFactory |
Called once per new outlet name to build the child route definition for that outlet. |
type DynamicOutletRouteFactory = (
segments: UrlSegment[],
group: UrlSegmentGroup,
route: Route,
outlet: string
) => Omit<Route, 'outlet'>;Receives the current URL segments, segment group, parent route, and the outlet name. Returns a partial Route (the outlet field is set automatically).
type OutletsMap = UrlSegmentGroup['children'];A map of outlet name → UrlSegmentGroup reflecting the currently active named outlets. Emitted by the outlets$ observable on activatedRoute.data.