JOURNAL

Angular Routing at Scale — Guards, Resolvers, and Type-Safe Navigation

Post E — Production Angular 21 routing patterns for ecommerce: functional guards with returnUrl, route resolvers for data preloading, type-safe navigation with withComponentInputBinding, and preloading strategies.

angular techlead routing

Read with AI

Choose content to copy and paste into your AI assistant. Nothing is sent automatically. CMS content is converted to Markdown; original Markdown is used when available.

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 Series — Back to main series overview

Discussion

Comments are reviewed before publication. Your email is kept private.

Add a comment

Name and email are required. Do not include confidential information.

Privacy & data

Loading anti-spam verification…

← Back to allĐọc tiếng Việt