Router Patterns — Breadcrumbs, Scroll Restoration and Route Animations

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 }),
  ]),
]);
Note: 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.
Tip: The 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.
Warning: Route animations with @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.

🧠 Test Yourself

A user scrolls to the bottom of the post list, clicks a post, reads it, then presses the browser’s back button. Without scrollPositionRestoration: 'enabled', where does the list page scroll to?