Angular Routing v19+: Complete Guide from Basics to Advanced
Master Angular Routing v19+ with functional guards, lazy loading, and modern patterns. From basic navigation to advanced route strategies.
What's New in Angular v19 Routing?
Angular v19 brings significant improvements to routing, building on the momentum of functional guards and standalone components introduced in previous versions. The routing system is now more intuitive, type-safe, and performant.
Key Features in v19+ 🚀
- Functional Guards: Tree-shakeable, composable route guards
- Input Binding: Route parameters directly as component inputs
- View Transitions API: Smooth page transitions
- Enhanced Type Safety: Better TypeScript inference
- Improved Preloading: Smarter lazy loading strategies
- Standalone-First: No NgModules required
Beginner: Getting Started with Angular Routing
Setting Up Routes
The modern way to configure routes uses provideRouter in your application config.
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes)
]
};
// app.routes.ts
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { AboutComponent } from './about/about.component';
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'about', component: AboutComponent },
{ path: '**', redirectTo: '' } // Wildcard route
];
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig);
Basic Router Outlet
// app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink],
template: `
<nav>
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">Home</a>
<a routerLink="/about" routerLinkActive="active">About</a>
</nav>
<router-outlet />
`,
styles: [`
nav a.active { font-weight: bold; color: #14b8a6; }
`]
})
export class AppComponent {}
Route Parameters
// app.routes.ts
export const routes: Routes = [
{ path: 'user/:id', component: UserComponent },
{ path: 'product/:id/:slug', component: ProductComponent }
];
// user.component.ts - Traditional way
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-user',
template: `<h2>User ID: {{ userId }}</h2>`
})
export class UserComponent implements OnInit {
userId: string | null = null;
constructor(private route: ActivatedRoute) {}
ngOnInit() {
// Snapshot - for one-time read
this.userId = this.route.snapshot.paramMap.get('id');
// Observable - for reactive updates
this.route.paramMap.subscribe(params => {
this.userId = params.get('id');
});
}
}
Modern Approach: Input Binding (v16+)
// app.routes.ts
export const routes: Routes = [
{
path: 'user/:id',
component: UserComponent,
// ✅ Enable input binding
}
];
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withComponentInputBinding() // Enable route params as inputs
)
]
};
// user.component.ts - Modern way with signals
import { Component, input } from '@angular/core';
@Component({
selector: 'app-user',
standalone: true,
template: `
<h2>User ID: {{ id() }}</h2>
<p>This updates automatically when route changes!</p>
`
})
export class UserComponent {
// ✅ Route parameter directly as input signal
id = input.required<string>();
// Works with optional params too
tab = input<string>(); // from query params
}
Programmatic Navigation
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-navigation-demo',
template: `
<button (click)="goToUser(123)">Go to User 123</button>
<button (click)="goBack()">Go Back</button>
<button (click)="goToSettings()">Settings with Query</button>
`
})
export class NavigationDemoComponent {
private router = inject(Router);
goToUser(id: number) {
// Simple navigation
this.router.navigate(['/user', id]);
}
goBack() {
// Navigate back
this.router.navigate(['..'], { relativeTo: this.route });
}
goToSettings() {
// Navigation with query params and fragment
this.router.navigate(['/settings'], {
queryParams: { tab: 'profile', edit: true },
fragment: 'personal-info'
});
// Results in: /settings?tab=profile&edit=true#personal-info
}
}
Query Parameters and Fragments
// Reading query params with input binding
@Component({
selector: 'app-search',
template: `
<h2>Search Results</h2>
<p>Query: {{ query() }}</p>
<p>Page: {{ page() }}</p>
`
})
export class SearchComponent {
// ✅ Query params as inputs (v16+)
query = input<string>(); // from ?query=...
page = input<number>(); // from ?page=...
}
// Traditional way
import { ActivatedRoute } from '@angular/router';
@Component({ /*...*/ })
export class TraditionalSearchComponent {
constructor(private route: ActivatedRoute) {
this.route.queryParams.subscribe(params => {
console.log(params['query'], params['page']);
});
}
}
Intermediate: Advanced Routing Patterns
Child Routes and Nested Routing
// app.routes.ts
export const routes: Routes = [
{
path: 'dashboard',
component: DashboardComponent,
children: [
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
{ path: 'overview', component: OverviewComponent },
{ path: 'stats', component: StatsComponent },
{ path: 'settings', component: SettingsComponent }
]
}
];
// dashboard.component.ts
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [RouterOutlet, RouterLink],
template: `
<div class="dashboard">
<aside>
<nav>
<a routerLink="overview" routerLinkActive="active">Overview</a>
<a routerLink="stats" routerLinkActive="active">Stats</a>
<a routerLink="settings" routerLinkActive="active">Settings</a>
</nav>
</aside>
<main>
<!-- Child routes render here -->
<router-outlet />
</main>
</div>
`
})
export class DashboardComponent {}
Lazy Loading Routes
Lazy loading loads feature modules only when needed, reducing initial bundle size.
// app.routes.ts
export const routes: Routes = [
{
path: 'admin',
// ✅ Lazy load entire route configuration
loadChildren: () => import('./admin/admin.routes')
.then(m => m.ADMIN_ROUTES)
},
{
path: 'blog/:id',
// ✅ Lazy load single component
loadComponent: () => import('./blog/blog-post.component')
.then(m => m.BlogPostComponent)
}
];
// admin/admin.routes.ts
import { Routes } from '@angular/router';
import { AdminComponent } from './admin.component';
import { UsersComponent } from './users/users.component';
import { RolesComponent } from './roles/roles.component';
export const ADMIN_ROUTES: Routes = [
{
path: '',
component: AdminComponent,
children: [
{ path: 'users', component: UsersComponent },
{ path: 'roles', component: RolesComponent }
]
}
];
Functional Route Guards (Modern Approach)
Functional guards are the modern, recommended way to protect routes.
// guards/auth.guard.ts
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isLoggedIn()) {
return true;
}
// Redirect to login
return router.createUrlTree(['/login']);
};
// Role-based guard with parameters
export const roleGuard = (allowedRoles: string[]) => {
return () => {
const authService = inject(AuthService);
const router = inject(Router);
const userRole = authService.getUserRole();
if (allowedRoles.includes(userRole)) {
return true;
}
return router.createUrlTree(['/unauthorized']);
};
};
// app.routes.ts
import { authGuard, roleGuard } from './guards/auth.guard';
export const routes: Routes = [
{
path: 'profile',
component: ProfileComponent,
canActivate: [authGuard] // ✅ Functional guard
},
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes'),
canActivate: [authGuard, roleGuard(['admin', 'superadmin'])]
},
{
path: 'edit/:id',
component: EditComponent,
canDeactivate: [unsavedChangesGuard] // Prevent leaving with unsaved changes
}
];
CanDeactivate Guard - Unsaved Changes
// guards/unsaved-changes.guard.ts
import { CanDeactivateFn } from '@angular/router';
export interface CanComponentDeactivate {
canDeactivate: () => boolean | Promise<boolean>;
}
export const unsavedChangesGuard: CanDeactivateFn<CanComponentDeactivate> =
(component) => {
return component.canDeactivate ?
component.canDeactivate() :
true;
};
// edit.component.ts
import { Component } from '@angular/core';
import { CanComponentDeactivate } from './guards/unsaved-changes.guard';
@Component({
selector: 'app-edit',
template: `
<form>
<input [(ngModel)]="formData" />
<button (click)="save()">Save</button>
</form>
`
})
export class EditComponent implements CanComponentDeactivate {
formData = '';
private saved = false;
save() {
this.saved = true;
// Save logic
}
canDeactivate(): boolean {
if (!this.saved && this.formData) {
return confirm('You have unsaved changes. Do you want to leave?');
}
return true;
}
}
Route Resolvers - Pre-fetching Data
// resolvers/user.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { UserService } from '../services/user.service';
import { User } from '../models/user.model';
export const userResolver: ResolveFn<User> = (route, state) => {
const userService = inject(UserService);
const userId = route.paramMap.get('id')!;
return userService.getUser(userId);
};
// app.routes.ts
export const routes: Routes = [
{
path: 'user/:id',
component: UserComponent,
resolve: {
user: userResolver // Data available before component loads
}
}
];
// user.component.ts
import { Component, input } from '@angular/core';
import { User } from '../models/user.model';
@Component({
selector: 'app-user',
template: `
<div *ngIf="user() as userData">
<h2>{{ userData.name }}</h2>
<p>{{ userData.email }}</p>
</div>
`
})
export class UserComponent {
// ✅ Resolved data as input
user = input.required<User>();
}
Route Data and Metadata
// app.routes.ts
export const routes: Routes = [
{
path: 'home',
component: HomeComponent,
data: {
title: 'Home Page',
breadcrumb: 'Home',
requiresAuth: false
}
},
{
path: 'admin',
component: AdminComponent,
data: {
title: 'Admin Panel',
roles: ['admin'],
animation: 'AdminPage'
}
}
];
// Reading route data
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({ /*...*/ })
export class PageComponent implements OnInit {
pageTitle = '';
constructor(private route: ActivatedRoute) {}
ngOnInit() {
this.route.data.subscribe(data => {
this.pageTitle = data['title'];
document.title = this.pageTitle;
});
}
}
Advanced: Expert-Level Routing
Custom Route Reuse Strategy
Control when components are reused or recreated during navigation.
// strategies/custom-reuse-strategy.ts
import { RouteReuseStrategy, ActivatedRouteSnapshot, DetachedRouteHandle } from '@angular/router';
export class CustomReuseStrategy implements RouteReuseStrategy {
private handlers: Map<string, DetachedRouteHandle> = new Map();
shouldDetach(route: ActivatedRouteSnapshot): boolean {
// Store component if route data says so
return route.data['shouldReuse'] === true;
}
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
if (route.data['shouldReuse']) {
this.handlers.set(this.getRouteKey(route), handle);
}
}
shouldAttach(route: ActivatedRouteSnapshot): boolean {
return !!route.data['shouldReuse'] && !!this.handlers.get(this.getRouteKey(route));
}
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
if (!route.data['shouldReuse']) return null;
return this.handlers.get(this.getRouteKey(route)) || null;
}
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
return future.routeConfig === curr.routeConfig;
}
private getRouteKey(route: ActivatedRouteSnapshot): string {
return route.pathFromRoot
.map(r => r.url.map(segment => segment.toString()).join('/'))
.join('/');
}
}
// app.config.ts
import { RouteReuseStrategy } from '@angular/router';
import { CustomReuseStrategy } from './strategies/custom-reuse-strategy';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
{ provide: RouteReuseStrategy, useClass: CustomReuseStrategy }
]
};
// Usage in routes
export const routes: Routes = [
{
path: 'search',
component: SearchComponent,
data: { shouldReuse: true } // Component will be reused
}
];
Preloading Strategies
// strategies/custom-preload-strategy.ts
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of, timer } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class CustomPreloadStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
// Don't preload if data says so
if (route.data?.['preload'] === false) {
return of(null);
}
// Immediate preload for high priority
if (route.data?.['preload'] === 'instant') {
console.log('Preloading instantly:', route.path);
return load();
}
// Delayed preload for normal priority
if (route.data?.['preload'] === true) {
const delay = route.data?.['preloadDelay'] || 2000;
console.log(`Preloading after ${delay}ms:`, route.path);
return timer(delay).pipe(mergeMap(() => load()));
}
return of(null);
}
}
// app.config.ts
import { provideRouter, withPreloading } from '@angular/router';
import { CustomPreloadStrategy } from './strategies/custom-preload-strategy';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withPreloading(CustomPreloadStrategy)
)
]
};
// Usage in routes
export const routes: Routes = [
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes'),
data: { preload: true, preloadDelay: 5000 }
},
{
path: 'dashboard',
loadChildren: () => import('./dashboard/dashboard.routes'),
data: { preload: 'instant' } // Load immediately
},
{
path: 'reports',
loadChildren: () => import('./reports/reports.routes'),
data: { preload: false } // Never preload
}
];
View Transitions API (v19+)
Smooth animations between route changes using the native View Transitions API.
// app.config.ts
import { provideRouter, withViewTransitions } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withViewTransitions() // ✅ Enable view transitions
)
]
};
/* styles.css - Define transitions */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 300ms;
}
/* Slide animation */
@keyframes slide-from-right {
from { transform: translateX(100%); }
}
@keyframes slide-to-left {
to { transform: translateX(-100%); }
}
::view-transition-old(root) {
animation: slide-to-left 300ms ease-out;
}
::view-transition-new(root) {
animation: slide-from-right 300ms ease-out;
}
// Custom transition per route
import { provideRouter, withViewTransitions } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withViewTransitions({
skipInitialTransition: true,
onViewTransitionCreated: ({ transition, from, to }) => {
console.log('Transitioning from', from, 'to', to);
}
})
)
]
};
Router Events and Monitoring
// services/router-monitor.service.ts
import { Injectable, inject } from '@angular/core';
import { Router, NavigationStart, NavigationEnd, NavigationError } from '@angular/router';
import { filter } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class RouterMonitorService {
private router = inject(Router);
constructor() {
this.monitorNavigation();
}
private monitorNavigation() {
// Track navigation start
this.router.events.pipe(
filter(event => event instanceof NavigationStart)
).subscribe((event: NavigationStart) => {
console.log('Navigation started:', event.url);
// Show loading indicator
});
// Track navigation end
this.router.events.pipe(
filter(event => event instanceof NavigationEnd)
).subscribe((event: NavigationEnd) => {
console.log('Navigation ended:', event.url);
// Hide loading indicator
// Track analytics
this.trackPageView(event.urlAfterRedirects);
});
// Track navigation errors
this.router.events.pipe(
filter(event => event instanceof NavigationError)
).subscribe((event: NavigationError) => {
console.error('Navigation error:', event.error);
// Handle error
});
}
private trackPageView(url: string) {
// Send to analytics
console.log('Page view:', url);
}
}
Dynamic Route Configuration
// services/dynamic-routes.service.ts
import { Injectable, inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class DynamicRoutesService {
private router = inject(Router);
private http = inject(HttpClient);
async loadDynamicRoutes() {
// Fetch route configuration from API
const dynamicRoutes = await this.http.get<any[]>('/api/routes').toPromise();
const routes: Routes = dynamicRoutes.map(route => ({
path: route.path,
loadComponent: () => this.loadComponent(route.component),
data: route.data
}));
// Add routes dynamically
this.router.resetConfig([
...this.router.config,
...routes
]);
}
private loadComponent(componentName: string) {
// Dynamic component loading
return import(`./features/${componentName}.component`)
.then(m => m[`${componentName}Component`]);
}
}
Auxiliary Routes (Named Outlets)
// app.routes.ts
export const routes: Routes = [
{
path: '',
component: HomeComponent,
children: [
{
path: 'popup',
component: PopupComponent,
outlet: 'popup' // Named outlet
}
]
}
];
// app.component.ts
@Component({
selector: 'app-root',
template: `
<div class="main-content">
<router-outlet />
</div>
<!-- Named outlet for popup -->
<div class="popup-container">
<router-outlet name="popup" />
</div>
`
})
export class AppComponent {}
// Navigation to named outlet
import { Router } from '@angular/router';
export class SomeComponent {
private router = inject(Router);
openPopup() {
this.router.navigate([
{ outlets: { popup: ['popup'] } }
]);
}
closePopup() {
this.router.navigate([
{ outlets: { popup: null } }
]);
}
}
Common Pitfalls and Solutions
❌ Pitfall 1: Memory Leaks from Route Subscriptions
// ❌ BAD: Subscription not cleaned up
export class BadComponent implements OnInit {
constructor(private route: ActivatedRoute) {}
ngOnInit() {
this.route.params.subscribe(params => {
// Memory leak if component is reused!
});
}
}
// ✅ GOOD: Use takeUntilDestroyed
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class GoodComponent {
private destroyRef = inject(DestroyRef);
constructor(private route: ActivatedRoute) {
this.route.params.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(params => {
// Automatically cleaned up
});
}
}
// ✅ BEST: Use input binding (no subscription needed)
export class BestComponent {
id = input.required<string>(); // From route params
}
❌ Pitfall 2: Incorrect Relative Navigation
// ❌ BAD: Navigating without considering current route
export class BadComponent {
constructor(private router: Router) {}
goToDetails(id: number) {
this.router.navigate(['details', id]); // Might not work as expected
}
}
// ✅ GOOD: Use relative navigation with ActivatedRoute
export class GoodComponent {
constructor(
private router: Router,
private route: ActivatedRoute
) {}
goToDetails(id: number) {
this.router.navigate(['details', id], {
relativeTo: this.route
});
}
goToParent() {
this.router.navigate(['..'], {
relativeTo: this.route
});
}
}
❌ Pitfall 3: Not Handling Navigation Failures
// ❌ BAD: Ignoring navigation result
export class BadComponent {
async navigate() {
this.router.navigate(['/somewhere']);
// What if navigation fails?
}
}
// ✅ GOOD: Handle navigation result
export class GoodComponent {
async navigate() {
const success = await this.router.navigate(['/somewhere']);
if (!success) {
console.error('Navigation failed');
// Handle failure (show message, retry, etc.)
}
}
}
❌ Pitfall 4: Overusing Resolvers
// ❌ BAD: Blocking navigation for non-critical data
export const heavyResolver: ResolveFn<any> = () => {
return inject(HeavyService).loadEverything();
// User waits for ALL data before seeing page
};
// ✅ GOOD: Load critical data in resolver, rest in component
export const criticalResolver: ResolveFn<User> = () => {
return inject(UserService).getCurrentUser();
// Only essential data
};
@Component({ /*...*/ })
export class PageComponent implements OnInit {
user = input.required<User>(); // From resolver
additionalData = signal(null);
ngOnInit() {
// Load non-critical data after page loads
this.loadAdditionalData();
}
private async loadAdditionalData() {
const data = await this.service.getAdditionalData();
this.additionalData.set(data);
}
}
❌ Pitfall 5: Query Param Handling Issues
// ❌ BAD: Losing query params on navigation
export class BadComponent {
navigate() {
this.router.navigate(['/next']);
// Query params are lost!
}
}
// ✅ GOOD: Preserve query params
export class GoodComponent {
navigate() {
this.router.navigate(['/next'], {
queryParamsHandling: 'preserve' // Keep existing params
});
}
navigateAndMerge() {
this.router.navigate(['/next'], {
queryParams: { newParam: 'value' },
queryParamsHandling: 'merge' // Merge with existing
});
}
}
Decision Tree: Choosing the Right Routing Pattern
Guard Selection Decision Tree
Lazy Loading Strategy Decision
Performance Optimization
Bundle Size Comparison
| Strategy | Initial Bundle | Load Time | Best For |
|---|---|---|---|
| Eager Loading | Large | Slow | Small apps |
| Lazy Loading | Small | Fast | Large apps |
| Lazy + Preload | Small | Fast + Smooth | Production apps |
| Lazy + Custom Preload | Optimized | Balanced | Complex apps |
Route Configuration Best Practices
// ✅ GOOD: Organized route structure
export const routes: Routes = [
// Public routes
{ path: '', component: HomeComponent },
{ path: 'about', component: AboutComponent },
// Auth routes
{ path: 'login', loadComponent: () => import('./auth/login.component') },
{ path: 'register', loadComponent: () => import('./auth/register.component') },
// Protected routes
{
path: 'app',
canActivate: [authGuard],
children: [
{ path: 'dashboard', loadComponent: () => import('./dashboard/dashboard.component') },
{ path: 'profile', loadComponent: () => import('./profile/profile.component') }
]
},
// Admin routes (lazy loaded module)
{
path: 'admin',
canActivate: [authGuard, roleGuard(['admin'])],
loadChildren: () => import('./admin/admin.routes'),
data: { preload: true, preloadDelay: 3000 }
},
// Error routes
{ path: '404', component: NotFoundComponent },
{ path: '**', redirectTo: '404' }
];
Real-World Example: E-Commerce App Routing
// app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';
import { cartGuard } from './guards/cart.guard';
export const routes: Routes = [
// Landing page
{
path: '',
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent),
title: 'Shop - Home'
},
// Product catalog
{
path: 'products',
loadChildren: () => import('./features/products/products.routes').then(m => m.PRODUCT_ROUTES),
data: { preload: 'instant' }
},
// Single product with SEO-friendly URL
{
path: 'product/:id/:slug',
loadComponent: () => import('./pages/product-detail/product-detail.component'),
resolve: {
product: productResolver
},
title: productTitleResolver
},
// Shopping cart
{
path: 'cart',
loadComponent: () => import('./pages/cart/cart.component'),
canDeactivate: [unsavedChangesGuard]
},
// Checkout flow
{
path: 'checkout',
canActivate: [authGuard, cartGuard],
loadChildren: () => import('./features/checkout/checkout.routes'),
data: { animation: 'CheckoutPage' }
},
// User account
{
path: 'account',
canActivate: [authGuard],
loadChildren: () => import('./features/account/account.routes'),
data: { preload: true, preloadDelay: 2000 }
},
// Admin panel
{
path: 'admin',
canActivate: [authGuard, roleGuard(['admin', 'manager'])],
loadChildren: () => import('./features/admin/admin.routes'),
data: { preload: false }
},
// Error pages
{ path: 'unauthorized', loadComponent: () => import('./pages/unauthorized/unauthorized.component') },
{ path: '404', loadComponent: () => import('./pages/not-found/not-found.component') },
{ path: '**', redirectTo: '404' }
];
// features/products/products.routes.ts
import { Routes } from '@angular/router';
import { ProductListComponent } from './product-list/product-list.component';
import { CategoryComponent } from './category/category.component';
export const PRODUCT_ROUTES: Routes = [
{
path: '',
component: ProductListComponent
},
{
path: 'category/:categoryId',
component: CategoryComponent,
children: [
{
path: 'subcategory/:subId',
component: CategoryComponent
}
]
},
{
path: 'search',
component: ProductListComponent,
// Query params: ?q=search&sort=price&filter=instock
}
];
// guards/cart.guard.ts
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { CartService } from '../services/cart.service';
export const cartGuard = () => {
const cartService = inject(CartService);
const router = inject(Router);
if (cartService.isEmpty()) {
alert('Your cart is empty!');
return router.createUrlTree(['/products']);
}
return true;
};
// resolvers/product.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn, Router } from '@angular/router';
import { ProductService } from '../services/product.service';
import { Product } from '../models/product.model';
import { catchError, of } from 'rxjs';
export const productResolver: ResolveFn<Product | null> = (route, state) => {
const productService = inject(ProductService);
const router = inject(Router);
const productId = route.paramMap.get('id')!;
return productService.getProduct(productId).pipe(
catchError(error => {
console.error('Product not found:', error);
router.navigate(['/404']);
return of(null);
})
);
};
export const productTitleResolver: ResolveFn<string> = (route, state) => {
const product = route.data['product'] as Product;
return product ? `${product.name} - Shop` : 'Product - Shop';
};
Testing Routes
// product-detail.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { ProductDetailComponent } from './product-detail.component';
import { of } from 'rxjs';
describe('ProductDetailComponent', () => {
let component: ProductDetailComponent;
let fixture: ComponentFixture<ProductDetailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductDetailComponent],
providers: [
provideRouter([
{
path: 'product/:id',
component: ProductDetailComponent
}
])
]
}).compileComponents();
fixture = TestBed.createComponent(ProductDetailComponent);
component = fixture.componentInstance;
});
it('should read route parameter', () => {
// Set input (from route params)
fixture.componentRef.setInput('id', '123');
fixture.detectChanges();
expect(component.id()).toBe('123');
});
});
Migration Guide: Old to New Routing
Before (Angular 14 and earlier)
// Old: NgModule-based
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
const routes: Routes = [ /*...*/ ];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}
After (Angular 15+)
// New: Standalone-based
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes)]
};
Class Guards → Functional Guards
// ❌ OLD: Class-based guard
import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService) {}
canActivate(): boolean {
return this.authService.isLoggedIn();
}
}
// ✅ NEW: Functional guard
export const authGuard = () => {
const authService = inject(AuthService);
return authService.isLoggedIn();
};
Best Practices Summary
✅ DO:
- Use functional guards - More tree-shakeable and composable
- Use input binding -
input()for route params instead of ActivatedRoute - Enable view transitions - Smooth navigation UX
- Implement lazy loading - Reduce initial bundle size
- Use preloading strategies - Smart background loading
- Handle navigation errors - Check navigation results
- Clean up subscriptions - Use
takeUntilDestroyed() - Use relative navigation - More maintainable
- Implement guards - Protect routes properly
- Use resolvers wisely - Only for critical data
❌ DON'T:
- Don't forget to unsubscribe - Memory leaks
- Don't ignore navigation failures - Handle errors
- Don't overuse resolvers - Blocks navigation
- Don't mutate route data - Immutable patterns
- Don't skip error routes - Always have 404
- Don't load everything eagerly - Performance impact
- Don't use deprecated APIs - Stay current
- Don't forget accessibility - Keyboard navigation
Conclusion
Angular v19+ routing represents a significant evolution in how we build navigation in Angular applications. The shift to functional guards, standalone components, and signal-based inputs creates a more modern, performant, and developer-friendly experience.
Key Takeaways 🎯
- Modern routing is simpler with functional APIs
- Performance is better with lazy loading and preloading
- Type safety is stronger with TypeScript inference
- Developer experience improved with clearer patterns
- Future-proof with ongoing Angular evolution
Next Steps
- Migrate existing apps to standalone components and functional guards
- Implement view transitions for better UX
- Optimize lazy loading with custom preload strategies
- Master signal inputs for cleaner component code
- Stay updated with Angular's routing roadmap
Happy routing! 🚀