Production routing requires additional patterns beyond basic navigation: breadcrumbs for multi-level site navigation, scroll position restoration for consistent UX, route transition animations for a polished feel, and robust 404 handling. These patterns elevate the BlogApp from a functional application to one that feels professionally built. Each pattern builds on Angular’s router APIs covered in earlier lessons, combining them to solve real user experience problems.
Breadcrumbs from Route Data
// ── Route data for breadcrumbs ─────────────────────────────────────────────
export const routes: Routes = [
{
path: 'posts',
data: { breadcrumb: 'Posts' },
children: [
{ path: '', data: { breadcrumb: 'All Posts' },
loadComponent: () => import('./post-list.component').then(m => m.PostListComponent) },
{ path: ':slug', data: { breadcrumb: ':slug' }, // dynamic breadcrumb
loadComponent: () => import('./post-detail.component').then(m => m.PostDetailComponent) },
],
},
];
// ── BreadcrumbService — builds breadcrumb trail from active route ──────────
@Injectable({ providedIn: 'root' })
export class BreadcrumbService {
private router = inject(Router);
private route = inject(ActivatedRoute);
breadcrumbs = toSignal(
this.router.events.pipe(
filter(event => event instanceof NavigationEnd),
startWith(null),
map(() => this.buildBreadcrumbs(this.route.root)),
),
{ initialValue: [] as Breadcrumb[] }
);
private buildBreadcrumbs(route: ActivatedRoute, path = ''): Breadcrumb[] {
const crumbs: Breadcrumb[] = [];
const { data, snapshot } = route;
const label = data['breadcrumb'] as string | undefined;
if (label) {
// Replace :param with actual value from snapshot
const resolvedLabel = label.startsWith(':')
? snapshot.paramMap.get(label.slice(1)) ?? label
: label;
crumbs.push({ label: resolvedLabel, url: path + '/' + snapshot.url.join('/') });
}
route.children.forEach(child =>
crumbs.push(...this.buildBreadcrumbs(child, path + '/' + snapshot.url.join('/')))
);
return crumbs;
}
}
// ── BreadcrumbComponent ────────────────────────────────────────────────────
@Component({
selector: 'app-breadcrumbs',
standalone: true,
imports: [RouterLink],
template: `
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
@for (crumb of crumbService.breadcrumbs(); track crumb.url; let last = $last) {
<li class="breadcrumb-item" [class.active]="last">
@if (!last) {
<a [routerLink]="crumb.url">{{ crumb.label }}</a>
} @else {
{{ crumb.label }}
}
</li>
}
</ol>
</nav>
`,
})
export class BreadcrumbsComponent {
protected crumbService = inject(BreadcrumbService);
}
// ── Route transition animation ────────────────────────────────────────────
// In AppComponent template:
// <div [@routeAnimation]="getRouteState(outlet)">
// <router-outlet #outlet="outlet" />
// </div>
export const routeAnimations = trigger('routeAnimation', [
transition('* <=> *', [
query(':enter', [style({ opacity: 0, transform: 'translateY(8px)' })], { optional: true }),
query(':leave', [animate('150ms ease-out', style({ opacity: 0 }))], { optional: true }),
query(':enter', [animate('200ms ease-in', style({ opacity: 1, transform: 'translateY(0)' }))], { optional: true }),
]),
]);
withInMemoryScrollingOptions({ scrollPositionRestoration: 'enabled' }) in provideRouter() restores the scroll position when the user uses the browser’s back button — navigating back to the post list scrolls to where they were before clicking a post. Without this, the browser scrolls to the top on every navigation, creating a jarring experience when users return from detail pages to long lists. This single configuration significantly improves the perceived quality of the navigation experience.toSignal() function from @angular/core/rxjs-interop converts any Observable to a Signal, combining the best of both worlds: Router.events is an Observable (the right model for a stream of events), but the breadcrumb array it produces is better as a Signal (reactive, works with Angular’s change detection, no subscription management). Use toSignal(observable, { initialValue: ... }) throughout Angular 18 code to bridge the RxJS and Signal worlds.@angular/animations can cause issues with scroll restoration and route transitions when not configured carefully. The :enter and :leave queries in animation triggers must use { optional: true } — if Angular cannot find matching elements (e.g., on the first navigation when no previous component exists to animate out), it throws without optional: true. Always add { optional: true } to all animation queries in route transition animations.Complete Route Configuration
// ── app.config.ts — production router configuration ────────────────────────
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withComponentInputBinding(), // route params as @Input
withPreloading(PreloadAllModules), // preload lazy routes
withInMemoryScrollingOptions({
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
}),
withRouterConfig({
onSameUrlNavigation: 'reload', // reload on same URL (useful for refresh buttons)
canceledNavigationResolution: 'computed',
}),
),
provideAnimationsAsync(), // for route animations
],
};
Common Mistakes
Mistake 1 — No wildcard 404 route (unknown URLs show blank page)
❌ Wrong — no { path: '**', ... }; user sees a blank router outlet with no error indication.
✅ Correct — always include a wildcard route as the last entry that renders a NotFoundComponent.
Mistake 2 — Missing optional: true in route animation queries (throws on first navigation)
❌ Wrong — query(':leave', [...]); first navigation has no :leave element; Angular throws error.
✅ Correct — always use query(':leave', [...], { optional: true }) in route animations.