Angular Routing
Angular Router is built-in (@angular/router) — no third-party. This skill covers configuration, navigation, guards, lazy loading.
Setup
Standalone (Angular 17+)
// app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './core/auth/auth.guard';
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'login', component: LoginComponent },
{
path: 'dashboard',
loadComponent: () => import('./features/dashboard/dashboard.component').then((m) => m.DashboardComponent),
canActivate: [authGuard],
},
{
path: 'users',
loadChildren: () => import('./features/users/users.routes').then((m) => m.usersRoutes),
},
{ path: '**', component: NotFoundComponent },
];
// main.ts (or app.config.ts)
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withComponentInputBinding(), withViewTransitions()),
provideHttpClient(),
],
});
provideRouter features:
withComponentInputBinding() — auto-binds route params to component @Input() / input().
withViewTransitions() — uses View Transitions API for navigation animations.
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }) — restore scroll on back nav.
withDebugTracing() — log all navigation events.
NgModule (legacy)
// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
const routes: Routes = [ /* same shape */ ];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
// AppModule imports AppRoutingModule
<router-outlet> in app.component.html renders the matched component.
Route configuration
{
path: 'users',
component: UsersComponent,
data: { title: 'Users' }, // arbitrary route metadata
resolve: { users: usersResolver }, // pre-load data
canActivate: [authGuard],
canDeactivate: [unsavedChangesGuard],
children: [
{ path: '', component: UsersListComponent },
{ path: ':id', component: UserDetailComponent, resolve: { user: userResolver } },
{ path: ':id/edit', component: UserEditComponent, canDeactivate: [unsavedChangesGuard] },
],
}
Path matching
{ path: '', component: HomeComponent } // empty path (root)
{ path: 'users', component: UsersComponent } // /users
{ path: 'users/:id', component: UserDetailComponent } // /users/123
{ path: 'users/:id/posts/:postId' } // multiple params
{ path: 'docs/**', component: DocsComponent } // wildcard catch-all
{ path: '**', component: NotFoundComponent } // global 404 (always last)
{ path: 'old', redirectTo: 'new', pathMatch: 'full' } // redirect
pathMatch: 'full' for redirects — otherwise path: '' matches every URL.
Lazy loading
Standalone component:
{
path: 'dashboard',
loadComponent: () => import('./features/dashboard/dashboard.component').then((m) => m.DashboardComponent),
}
Feature routes file:
// features/users/users.routes.ts
import { Routes } from '@angular/router';
import { UsersListComponent } from './users-list.component';
export const usersRoutes: Routes = [
{ path: '', component: UsersListComponent },
{ path: ':id', loadComponent: () => import('./user-detail.component').then((m) => m.UserDetailComponent) },
];
// app.routes.ts
{
path: 'users',
loadChildren: () => import('./features/users/users.routes').then((m) => m.usersRoutes),
}
NgModule (legacy):
{
path: 'users',
loadChildren: () => import('./features/users/users.module').then((m) => m.UsersModule),
}
Lazy loading creates a separate JS chunk — first navigation downloads it, subsequent uses cached.
Functional guards (Angular 14.1+, preferred)
// core/auth/auth.guard.ts
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) {
return true;
}
// Redirect to login with return URL
router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
return false;
};
Apply:
{ path: 'dashboard', component: DashboardComponent, canActivate: [authGuard] }
Multiple guards
{ path: 'admin', component: AdminComponent, canActivate: [authGuard, roleGuard('admin')] }
Composed guards run in order; if any returns false / UrlTree, route blocks.
Guard factory pattern
export const roleGuard = (requiredRole: string): CanActivateFn => (route, state) => {
const auth = inject(AuthService);
return auth.hasRole(requiredRole);
};
// Usage
{ canActivate: [roleGuard('admin')] }
Other guard types
| Guard | When |
|---|
canActivate | Before activating route |
canActivateChild | Before activating child routes |
canDeactivate | Before leaving route (unsaved changes prompt) |
canLoad | Before lazy-loading the route module/component (deprecated in favor of canMatch) |
canMatch | Conditionally match the route (Angular 14.2+) |
resolve | Pre-fetch data before route activates |
Class-based guards (legacy)
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree {
// ...
}
}
Still works but functional API is preferred. Don't migrate existing class guards as part of feature work unless BA spec asks.
Resolvers
Pre-fetch data before route activates. Component receives data via route.data instead of loading inside ngOnInit().
// users.resolver.ts
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { UsersService } from './users.service';
import type { User } from './user.model';
export const userResolver: ResolveFn<User> = (route) => {
const usersService = inject(UsersService);
const id = route.paramMap.get('id')!;
return usersService.getUser(id);
};
// In route config
{ path: 'users/:id', component: UserDetailComponent, resolve: { user: userResolver } }
// In component
@Component({...})
export class UserDetailComponent {
private route = inject(ActivatedRoute);
user = this.route.snapshot.data['user'] as User; // sync access
// OR reactive
user$ = this.route.data.pipe(map((data) => data['user'] as User));
}
Resolvers return Observable, Promise, or value. Navigation pauses until resolution completes.
Programmatic navigation
import { Router } from '@angular/router';
import { inject } from '@angular/core';
@Component({...})
export class MyComponent {
private router = inject(Router);
goToProfile(id: string) {
this.router.navigate(['/users', id]);
}
goRelative() {
this.router.navigate(['..'], { relativeTo: this.route });
}
navigateWithQuery() {
this.router.navigate(['/search'], { queryParams: { q: 'hello', page: 1 } });
}
replaceUrl() {
this.router.navigate(['/login'], { replaceUrl: true });
}
}
router.navigate(commands, extras?) — commands array; first element is path, rest are segments.
router.navigateByUrl('/users/123?tab=overview') — string URL alternative.
Reading params and queries
Snapshot (sync, only initial)
private route = inject(ActivatedRoute);
ngOnInit() {
const id = this.route.snapshot.paramMap.get('id');
const tab = this.route.snapshot.queryParamMap.get('tab') ?? 'overview';
}
Snapshot reflects route at component creation. Won't update if user navigates within same component.
Observables (reactive)
this.route.paramMap
.pipe(takeUntilDestroyed())
.subscribe((params) => {
this.id = params.get('id')!;
});
this.route.queryParamMap
.pipe(takeUntilDestroyed())
.subscribe((qp) => {
this.tab = qp.get('tab') ?? 'overview';
});
Updates on every route change within same component instance.
Signals (Angular 16+)
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
id = toSignal(this.route.paramMap.pipe(map((p) => p.get('id') ?? '')), { initialValue: '' });
tab = toSignal(this.route.queryParamMap.pipe(map((q) => q.get('tab') ?? 'overview')), { initialValue: 'overview' });
Use signals when consuming in computed / template:
title = computed(() => `User ${this.id()}`);
withComponentInputBinding() (Angular 16+)
provideRouter(routes, withComponentInputBinding()),
Route params auto-bind to component inputs:
@Component({ ... })
export class UserDetailComponent {
@Input() id!: string; // bound from route param :id
@Input() tab = 'overview'; // bound from query ?tab=
}
In Angular 17.1+ with signal inputs:
id = input.required<string>(); // signal version
tab = input<string>('overview');
Cleanest pattern. No need for ActivatedRoute injection in many cases.
RouterLink
<a routerLink="/users">Users</a>
<a [routerLink]="['/users', user.id]">{{ user.name }}</a>
<a [routerLink]="['/search']" [queryParams]="{ q: 'hello' }">Search</a>
<a routerLink="/dashboard" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Dashboard</a>
For external URLs, use plain <a href="..."> — routerLink is for internal nav only.
Route metadata via data
{ path: 'admin', component: AdminComponent, data: { title: 'Admin', requiresAuth: true } }
Read in component:
this.route.data.subscribe((data) => {
this.title = data['title'];
});
For app-wide title management:
// In root component
this.router.events
.pipe(
filter((e) => e instanceof NavigationEnd),
takeUntilDestroyed()
)
.subscribe(() => {
let route = this.route;
while (route.firstChild) route = route.firstChild;
const title = route.snapshot.data['title'] ?? 'My App';
this.titleService.setTitle(title);
});
Or use provideRouter(routes, withRouterConfig({ paramsInheritanceStrategy: 'always' })) + Title service.
Or Angular 14.1+: title route property, set automatically:
{ path: 'admin', component: AdminComponent, title: 'Admin | My App' }
{ path: 'users/:id', component: UserDetailComponent, title: userTitleResolver }
Anti-patterns
- ❌ Forgetting
<router-outlet> in layout — routes don't render.
- ❌
<a href="/internal"> for internal nav — full reload, defeats SPA.
- ❌ Hardcoded route strings throughout app — define route constants in
app.routes.ts or shared file.
- ❌ Subscribing to
route.params without takeUntilDestroyed() — memory leak.
- ❌ Heavy logic in resolvers — slows navigation; keep resolvers thin.
- ❌ Returning
Observable<never> from a guard without UrlTree — guard hangs.
- ❌
pathMatch: 'full' missing on redirect — redirect catches every URL.
- ❌ Class-based guards in new Angular 17+ projects — functional API is cleaner.
- ❌ Multiple
<router-outlet> without named outlets — only the first activates.
- ❌ Lazy-loading single-component routes when not needed — premature optimization for small components.