Routing is where Angular’s architecture becomes visible to the user. A badly structured router makes the user wait, see flashes of empty content, or land on a 404 instead of being redirected correctly. A well-structured router is invisible — it just works.

This post covers the routing patterns needed for a production ecommerce application: functional guards, resolvers, type-safe navigation, and preloading strategies.

Functional Guards — The Modern Pattern

Angular’s route guards are now functional — no class, no implements CanActivate, just a typed function.

Auth Guard with returnUrl redirect

// libs/auth/util/src/lib/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '@techshop/auth/data-access';

export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  if (auth.isAuthenticated()) {
    return true;
  }

  // Preserve the intended URL so login can redirect back
  return router.createUrlTree(['/login'], {
    queryParams: { returnUrl: state.url }
  });
};

Use in routes:

// checkout.routes.ts
export const checkoutRoutes: Routes = [
  {
    path: '',
    canActivate: [authGuard],
    children: [
      { path: 'shipping', component: ShippingStepComponent },
      { path: 'payment', component: PaymentStepComponent },
      { path: 'review', component: OrderReviewComponent },
      { path: '', redirectTo: 'shipping', pathMatch: 'full' },
    ]
  }
];

Login component handling returnUrl:

@Component({...})
export class LoginComponent {
  private router = inject(Router);
  private route = inject(ActivatedRoute);

  async onLogin() {
    await inject(AuthService).login(this.form.value);

    // Navigate back to the originally intended page
    const returnUrl = this.route.snapshot.queryParams['returnUrl'] ?? '/account';
    this.router.navigateByUrl(returnUrl);
  }
}

Role Guard (Tech Lead, Admin)

export const roleGuard = (requiredRole: string): CanActivateFn => {
  return () => {
    const auth = inject(AuthService);
    const router = inject(Router);

    if (auth.hasRole(requiredRole)) return true;
    return router.createUrlTree(['/forbidden']);
  };
};

// Usage
{ path: 'admin', canActivate: [roleGuard('admin')], component: AdminComponent }

Route Resolvers — Preload Data Before Component Mounts

Without resolvers, components must handle their own loading states. With resolvers, data is available as soon as the component mounts — much cleaner for SSR.

// libs/catalog/data-access/src/lib/product.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn, Router } from '@angular/router';
import { TechShopApiClient } from '@techshop/shared/api-client';

export const productResolver: ResolveFn<Product> = async (route) => {
  const api = inject(TechShopApiClient);
  const router = inject(Router);
  const slug = route.paramMap.get('slug')!;

  try {
    return await api.products.bySlug(slug).get();
  } catch {
    // Product not found → redirect to 404
    await router.navigate(['/not-found']);
    return null!;
  }
};

Route configuration:

{
  path: 'products/:slug',
  component: ProductDetailComponent,
  resolve: { product: productResolver },
  data: { renderMode: RenderMode.Server } // SSR this route
}

Component reads from resolved data:

@Component({...})
export class ProductDetailComponent {
  private route = inject(ActivatedRoute);

  // Resolved data is available immediately on init — no loading state needed
  product = toSignal(
    this.route.data.pipe(map(data => data['product'] as Product)),
    { requireSync: true }
  );
}

Type-Safe Navigation with withComponentInputBinding()

Angular’s withComponentInputBinding() binds route params, query params, and resolved data directly to component @Input() (or Signal input()).

// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(appRoutes, withComponentInputBinding()), // Enable binding
  ]
};

Component now receives route params as inputs automatically:

@Component({...})
export class ProductDetailComponent {
  // :slug param bound automatically from route URL
  slug = input.required<string>();

  // ?ref= query param bound automatically
  ref = input<string>('');

  // Resolved data bound by key name
  product = input.required<Product>();
}

No ActivatedRoute.snapshot.params['slug'] anywhere. The component is testable with just its inputs.

Lazy-Loaded Routes at Scale

// app.routes.ts
export const appRoutes: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('@techshop/home/feature-homepage').then(m => m.HomepageComponent),
  },
  {
    path: 'products',
    loadChildren: () =>
      import('@techshop/catalog/feature-product-list').then(m => m.catalogRoutes),
  },
  {
    path: 'products/:slug',
    loadComponent: () =>
      import('@techshop/catalog/feature-product-detail').then(m => m.ProductDetailComponent),
    resolve: { product: productResolver },
    data: { renderMode: RenderMode.Server }
  },
  {
    path: 'cart',
    loadComponent: () =>
      import('@techshop/cart/feature-cart').then(m => m.CartComponent),
    data: { renderMode: RenderMode.Client }  // cart is session-specific, no SSR
  },
  {
    path: 'checkout',
    canActivate: [authGuard],
    loadChildren: () =>
      import('@techshop/checkout/feature-checkout').then(m => m.checkoutRoutes),
    data: { renderMode: RenderMode.Client }
  },
  {
    path: 'account',
    canActivate: [authGuard],
    loadChildren: () =>
      import('@techshop/account/feature-account').then(m => m.accountRoutes),
    data: { renderMode: RenderMode.Client }
  },
  {
    path: '**',
    loadComponent: () =>
      import('@techshop/shared/ui').then(m => m.NotFoundComponent),
    data: { renderMode: RenderMode.Prerender }
  }
];

Preloading Strategies

By default, lazy routes are loaded only when the user navigates to them. Preloading strategies load them in the background while the user is on other pages.

// Custom preloading — preload routes tagged for preloading
import { PreloadingStrategy, Route } from '@angular/router';

export class SelectivePreloadingStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
    // Only preload routes explicitly marked
    return route.data?.['preload'] ? load() : EMPTY;
  }
}

// Usage in provider
provideRouter(
  appRoutes,
  withPreloading(SelectivePreloadingStrategy),
  withComponentInputBinding()
)

Mark high-traffic routes for preloading:

{
  path: 'products',
  loadChildren: () => import('./catalog.routes'),
  data: { preload: true }  // preloaded immediately after shell loads
}

TechShop preloading decisions:

  • /products → preload immediately (most users go here)
  • /products/:slug → preload on hover (IntersectionObserver on product cards)
  • /checkout → load on demand (auth-required)
  • /account → load on demand (auth-required)

References


Part of the Angular Tech Lead SeriesBack to main series overview

Export for reading

Comments