16.5 Angular Router — Module Routing, Guards (CanActivate), Lazy Loading
Angular Router Overview
Angular Router is Angular's built-in routing system that connects URL paths to components. In a SPA (Single Page Application), it renders different components based on the URL without a full page reload.
URL: /products → ProductListComponent
URL: /products/42 → ProductDetailComponent
URL: /admin/users → AdminUsersComponent (protected by guard)
URL: /404 → NotFoundComponent
Basic Router Setup
app.routes.ts — Route Definitions
// src/app/app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
// Default redirect
{
path: '',
redirectTo: '/home',
pathMatch: 'full'
},
// Basic route
{
path: 'home',
loadComponent: () =>
import('./home/home.component').then(m => m.HomeComponent),
title: 'Home — My App' // Page title (Angular 14+)
},
// Dynamic parameters
{
path: 'products',
loadComponent: () =>
import('./products/product-list.component').then(m => m.ProductListComponent)
},
{
path: 'products/:id',
loadComponent: () =>
import('./products/product-detail.component').then(m => m.ProductDetailComponent)
},
// Wildcard — must be last
{
path: '**',
loadComponent: () =>
import('./not-found/not-found.component').then(m => m.NotFoundComponent)
}
];
app.config.ts — Register Router Provider
// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withComponentInputBinding(), // Auto-bind route params to @Input
withViewTransitions() // Page transition animations
),
]
};
RouterOutlet and RouterLink
// app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<nav>
<!-- RouterLink: navigate to the given path on click -->
<a routerLink="/home" routerLinkActive="active">Home</a>
<a routerLink="/products" routerLinkActive="active">Products</a>
<!-- Exact match -->
<a
routerLink="/"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
>
Root
</a>
<!-- Dynamic path -->
<a [routerLink]="['/products', productId]">Product Detail</a>
<!-- With query parameters -->
<a [routerLink]="['/products']" [queryParams]="{ category: 'electronics', page: 1 }">
Electronics
</a>
</nav>
<!-- The component matching the current route renders here -->
<router-outlet />
`
})
export class AppComponent {
productId = 42;
}
Dynamic Route Parameters
Reading Parameters with ActivatedRoute
// products/product-detail.component.ts
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { switchMap } from 'rxjs/operators';
@Component({
selector: 'app-product-detail',
standalone: true,
template: `
@if (product()) {
<div>
<h1>{{ product()!.title }}</h1>
<p>{{ product()!.price | currency }}</p>
<button (click)="goBack()">Back</button>
<button (click)="goToEdit()">Edit</button>
</div>
} @else {
<p>Product not found.</p>
}
`
})
export class ProductDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
product = signal<any>(null);
ngOnInit() {
// Method 1: paramMap Observable subscription (useful when reusing the same component)
this.route.paramMap.pipe(
switchMap(params => {
const id = Number(params.get('id'));
return this.fetchProduct(id);
})
).subscribe(p => this.product.set(p));
// Method 2: snapshot (read once only)
// const id = this.route.snapshot.paramMap.get('id');
}
private fetchProduct(id: number) {
return Promise.resolve({ id, title: `Product ${id}`, price: 99 });
}
goBack() {
this.router.navigate(['/products']);
}
goToEdit() {
this.router.navigate(['/products', this.product()!.id, 'edit']);
}
}
Using withComponentInputBinding() (Angular 16+)
Adding withComponentInputBinding() to provideRouter lets you receive route parameters directly via @Input() / input().
// app.config.ts
provideRouter(routes, withComponentInputBinding())
// product-detail.component.ts
import { Component, input } from '@angular/core';
@Component({ ... })
export class ProductDetailComponent {
// URL /products/42 → id = '42' automatically bound
id = input<string>('');
}
Query Parameters
// Query parameters: /products?category=electronics&page=2&sort=price
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
@Component({
standalone: true,
imports: [FormsModule],
template: `
<div>
<select [(ngModel)]="category" (change)="updateFilter()">
<option value="">All</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<button (click)="clearFilters()">Clear Filters</button>
</div>
`
})
export class ProductListComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
category = '';
page = 1;
ngOnInit() {
// Read query parameters
this.route.queryParamMap.subscribe(params => {
this.category = params.get('category') || '';
this.page = Number(params.get('page')) || 1;
});
}
updateFilter() {
// Update query parameters (preserve existing)
this.router.navigate([], {
relativeTo: this.route,
queryParams: { category: this.category || null, page: 1 },
queryParamsHandling: 'merge' // Keep existing params
});
}
clearFilters() {
this.router.navigate([], {
relativeTo: this.route,
queryParams: {}
});
}
}
Nested Routing
// app.routes.ts — Nested routes
export const routes: Routes = [
{
path: 'admin',
loadComponent: () =>
import('./admin/admin-layout.component').then(m => m.AdminLayoutComponent),
children: [
{
path: '',
redirectTo: 'dashboard',
pathMatch: 'full'
},
{
path: 'dashboard',
loadComponent: () =>
import('./admin/dashboard/dashboard.component').then(m => m.DashboardComponent)
},
{
path: 'users',
loadComponent: () =>
import('./admin/users/user-list.component').then(m => m.UserListComponent)
},
{
path: 'users/:id',
loadComponent: () =>
import('./admin/users/user-detail.component').then(m => m.UserDetailComponent)
}
]
}
];
// admin/admin-layout.component.ts
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-admin-layout',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<div class="admin-layout">
<aside class="sidebar">
<h2>Admin</h2>
<nav>
<a routerLink="dashboard" routerLinkActive="active">Dashboard</a>
<a routerLink="users" routerLinkActive="active">User Management</a>
</nav>
</aside>
<main class="content">
<!-- Child routes render here -->
<router-outlet />
</main>
</div>
`
})
export class AdminLayoutComponent {}
Route Guards
Guards control route navigation — they prevent unauthenticated users from accessing protected pages.
CanActivate Guard (Functional, Angular 15+)
// guards/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isLoggedIn()) {
return true;
}
// Redirect to login + save original URL
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
};
// guards/admin.guard.ts
export const adminGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.hasRole('admin')) {
return true;
}
return router.createUrlTree(['/403']);
};
CanDeactivate Guard — Prevent Leaving Page
// guards/unsaved-changes.guard.ts
import { CanDeactivateFn } from '@angular/router';
export interface HasUnsavedChanges {
hasUnsavedChanges(): boolean;
}
export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> =
(component) => {
if (component.hasUnsavedChanges()) {
return confirm('You have unsaved changes. Are you sure you want to leave?');
}
return true;
};
// Component that uses it
@Component({ ... })
export class EditProductComponent implements HasUnsavedChanges {
isDirty = signal(false);
hasUnsavedChanges(): boolean {
return this.isDirty();
}
}
resolve Guard — Pre-Load Data
// guards/product.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { ProductService, Product } from '../services/product.service';
export const productResolver: ResolveFn<Product> = (route) => {
const productService = inject(ProductService);
const id = Number(route.paramMap.get('id'));
return productService.getProduct(id);
};
// app.routes.ts
{
path: 'products/:id',
component: ProductDetailComponent,
resolve: { product: productResolver } // Load data before rendering component
}
// product-detail.component.ts
export class ProductDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
ngOnInit() {
this.route.data.subscribe(({ product }) => {
this.product.set(product); // Pre-loaded data from resolver
});
}
}
Applying Guards to Routes
export const routes: Routes = [
{
path: 'admin',
canActivate: [authGuard, adminGuard], // Multiple guards
children: [
{
path: 'edit-product/:id',
component: EditProductComponent,
canDeactivate: [unsavedChangesGuard],
resolve: { product: productResolver }
}
]
}
];
Lazy Loading
Split code to improve initial load performance.
loadComponent — Lazy-Load a Component
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () =>
import('./dashboard/dashboard.component').then(m => m.DashboardComponent)
}
];
loadChildren — Lazy-Load Route Config
export const routes: Routes = [
{
path: 'admin',
canActivate: [authGuard],
loadChildren: () =>
import('./admin/admin.routes').then(m => m.adminRoutes)
// Splits the entire admin feature into one chunk
}
];
// admin/admin.routes.ts
import { Routes } from '@angular/router';
export const adminRoutes: Routes = [
{
path: '',
loadComponent: () =>
import('./layout/admin-layout.component').then(m => m.AdminLayoutComponent),
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () =>
import('./dashboard/admin-dashboard.component').then(m => m.AdminDashboardComponent)
},
{
path: 'users',
loadComponent: () =>
import('./users/admin-users.component').then(m => m.AdminUsersComponent)
}
]
}
];
Real-World Example — Auth-Based Route Protection
// services/auth.service.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { tap } from 'rxjs/operators';
interface AuthUser {
id: number;
name: string;
email: string;
roles: string[];
token: string;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private router = inject(Router);
private http = inject(HttpClient);
private _user = signal<AuthUser | null>(null);
readonly user = this._user.asReadonly();
readonly isLoggedIn = computed(() => !!this._user());
readonly isAdmin = computed(() => this._user()?.roles.includes('admin') ?? false);
constructor() {
// Restore from localStorage on page refresh
const stored = localStorage.getItem('auth_user');
if (stored) {
this._user.set(JSON.parse(stored));
}
}
login(email: string, password: string) {
return this.http.post<AuthUser>('/api/auth/login', { email, password }).pipe(
tap(user => {
this._user.set(user);
localStorage.setItem('auth_user', JSON.stringify(user));
})
);
}
logout() {
this._user.set(null);
localStorage.removeItem('auth_user');
this.router.navigate(['/login']);
}
hasRole(role: string): boolean {
return this._user()?.roles.includes(role) ?? false;
}
getToken(): string | null {
return this._user()?.token ?? null;
}
}
// pages/login.component.ts
import { Component, inject, signal } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../services/auth.service';
@Component({
standalone: true,
imports: [FormsModule],
template: `
<div class="login-page">
<h1>Login</h1>
@if (errorMessage()) {
<div class="error">{{ errorMessage() }}</div>
}
<form (submit)="onSubmit($event)">
<input
[(ngModel)]="email"
name="email"
type="email"
placeholder="Email"
required
>
<input
[(ngModel)]="password"
name="password"
type="password"
placeholder="Password"
required
>
<button type="submit" [disabled]="isLoading()">
{{ isLoading() ? 'Logging in...' : 'Login' }}
</button>
</form>
</div>
`
})
export class LoginComponent {
private authService = inject(AuthService);
private router = inject(Router);
private route = inject(ActivatedRoute);
email = '';
password = '';
isLoading = signal(false);
errorMessage = signal('');
onSubmit(event: Event) {
event.preventDefault();
this.isLoading.set(true);
this.errorMessage.set('');
this.authService.login(this.email, this.password).subscribe({
next: () => {
// Login success — navigate to original destination
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/dashboard';
this.router.navigateByUrl(returnUrl);
},
error: (err) => {
this.errorMessage.set(err.error?.message || 'Login failed.');
this.isLoading.set(false);
}
});
}
}
Programmatic Navigation
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({ ... })
export class NavigationExampleComponent {
private router = inject(Router);
navigateExamples() {
// Basic navigation
this.router.navigate(['/products']);
// With parameters
this.router.navigate(['/products', 42]);
// With query parameters
this.router.navigate(['/products'], {
queryParams: { category: 'electronics' }
});
// Navigate by URL string
this.router.navigateByUrl('/products?category=electronics');
// Relative navigation
this.router.navigate(['../edit'], { relativeTo: this.route });
// Go back (browser history)
history.back();
}
}
Pro Tips
Tip 1: Use Route Data
{
path: 'admin',
component: AdminComponent,
data: {
title: 'Admin Panel',
breadcrumb: 'Admin',
requiredRole: 'admin'
}
}
// Read route data in a guard
export const roleGuard: CanActivateFn = (route) => {
const requiredRole = route.data['requiredRole'];
return inject(AuthService).hasRole(requiredRole);
};
Tip 2: Subscribe to Router Events
import { Router, NavigationStart, NavigationEnd } from '@angular/router';
router.events.pipe(
filter(e => e instanceof NavigationStart)
).subscribe(() => this.isLoading.set(true));
router.events.pipe(
filter(e => e instanceof NavigationEnd)
).subscribe(() => this.isLoading.set(false));
Tip 3: Improve Performance with preloadingStrategy
import { PreloadAllModules } from '@angular/router';
provideRouter(
routes,
withPreloading(PreloadAllModules) // Pre-load all chunks in the background
)