Skip to main content
Advertisement

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
),
]
};

// 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
)
Advertisement