Route guards are the access control layer of an Angular application. They run before routes activate, while routes are active, or before routes are abandoned — giving you the ability to enforce authentication, role-based access, unsaved changes warnings, and data pre-loading. Angular 14+ introduced functional guards and resolvers that use the inject() function directly, eliminating the boilerplate of class-based guards. Understanding how to build each guard type, read route parameters and data, and chain multiple guards on a single route gives you complete control over how your application routes behave.
Guard and Resolver Types
| Type | Runs | Returns | Use For |
|---|---|---|---|
CanActivateFn |
Before route activates | boolean / UrlTree / Observable / Promise | Authentication, role checks |
CanActivateChildFn |
Before any child route activates | boolean / UrlTree | Section-level access control |
CanDeactivateFn<T> |
Before leaving the route | boolean / Observable / Promise | Unsaved changes confirmation |
CanMatchFn |
Before route is considered for matching | boolean / UrlTree | Feature flags, A/B testing |
ResolveFn<T> |
Before route activates, after guards | T / Observable / Promise | Pre-fetch required data |
ActivatedRoute Properties
| Property | Type | Contains |
|---|---|---|
params |
Observable<Params> | URL path parameters (:id → { id: '42' }) |
queryParams |
Observable<Params> | Query string parameters (?page=2 → { page: '2' }) |
data |
Observable<Data> | Static route data + resolved data combined |
fragment |
Observable<string | null> | URL fragment (#section) |
snapshot |
ActivatedRouteSnapshot | Point-in-time snapshot — synchronous access to all above |
url |
Observable<UrlSegment[]> | URL segments for this route |
paramMap |
Observable<ParamMap> | Type-safe params with .get(key) and .has(key) |
queryParamMap |
Observable<ParamMap> | Type-safe query params |
route.snapshot.params vs route.params (Observable): use the snapshot for one-time reads in ngOnInit when the component is always recreated on navigation (the common case). Use the Observable when the component stays mounted while the route parameter changes — a component that stays alive while the user navigates from /tasks/1 to /tasks/2 by clicking “Next Task”. Angular recreates components on route change by default unless you configure routes to reuse components, in which case the Observable variant is required.UrlTree from a guard instead of false to redirect unauthenticated users to the login page. return router.createUrlTree(['/auth/login'], { queryParams: { returnUrl: state.url } }) redirects to login and passes the originally requested URL as a query param. After successful login, the auth component reads returnUrl and navigates there, giving users the URL they originally wanted — a much better UX than just landing on the home page.catchError(() => of(false)) or redirect to an error page when the guard’s HTTP call fails. Also, resolver errors by default cancel the navigation — use ResolveFn with error handling to prevent this, or configure withRouterConfig({ resolveNavigationErrors: 'resolve' }) to continue navigation even when resolvers error.Complete Guard and Resolver Examples
// ── Functional auth guard ────────────────────────────────────────────────
// core/guards/auth.guard.ts
import { inject } from '@angular/core';
import { Router, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { CanActivateFn } from '@angular/router';
import { AuthStore } from '../stores/auth.store';
export const authGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
): boolean | UrlTree => {
const authStore = inject(AuthStore);
const router = inject(Router);
if (authStore.isAuthenticated()) return true;
// Redirect to login, preserving the intended URL
return router.createUrlTree(['/auth/login'], {
queryParams: { returnUrl: state.url },
});
};
// ── Role-based guard (factory function) ──────────────────────────────────
// core/guards/role.guard.ts
export function roleGuard(requiredRole: string): CanActivateFn {
return (): boolean | UrlTree => {
const authStore = inject(AuthStore);
const router = inject(Router);
if (authStore.hasRole(requiredRole)) return true;
return router.createUrlTree(['/auth/forbidden']);
};
}
// Usage in routes:
// canActivate: [authGuard, roleGuard('admin')]
// ── Unsaved changes guard ─────────────────────────────────────────────────
// core/guards/unsaved-changes.guard.ts
import { CanDeactivateFn } from '@angular/router';
// Interface for components that have unsaved changes
export interface CanDeactivate {
hasUnsavedChanges(): boolean;
}
export const unsavedChangesGuard: CanDeactivateFn<CanDeactivate> = (
component: CanDeactivate,
): boolean | Observable<boolean> => {
if (!component.hasUnsavedChanges()) return true;
// Use Angular CDK Dialog service for a proper modal in production
const dialog = inject(DialogService);
return dialog.confirm('You have unsaved changes. Leave anyway?');
};
// Component implements the interface:
@Component({ ... })
export class TaskFormComponent implements CanDeactivate {
form = this.fb.group({ ... });
hasUnsavedChanges(): boolean {
return this.form.dirty && !this.form.pristine;
}
}
// ── Functional resolver ───────────────────────────────────────────────────
// core/resolvers/task.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { Router, ActivatedRouteSnapshot } from '@angular/router';
import { catchError, EMPTY } from 'rxjs';
import { TaskService } from '../services/task.service';
import { Task } from '../../shared/models/task.model';
export const taskResolver: ResolveFn<Task> = (
route: ActivatedRouteSnapshot,
): Observable<Task> => {
const taskService = inject(TaskService);
const router = inject(Router);
const id = route.paramMap.get('id');
if (!id) {
router.navigate(['/tasks']);
return EMPTY;
}
return taskService.getById(id).pipe(
catchError(() => {
router.navigate(['/tasks'], {
queryParams: { error: 'task-not-found' }
});
return EMPTY;
}),
);
};
// Route with resolver:
// { path: ':id', resolve: { task: taskResolver }, loadComponent: ... }
// Component reads resolved data:
@Component({ ... })
export class TaskDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
task = signal<Task | null>(null);
ngOnInit(): void {
// Synchronous — data is already resolved before component renders
const task = this.route.snapshot.data['task'] as Task;
this.task.set(task);
// Or reactive — updates if resolver runs again (same URL with reload)
this.route.data.pipe(
map(data => data['task'] as Task),
takeUntilDestroyed(),
).subscribe(task => this.task.set(task));
}
}
// ── Reading route parameters ──────────────────────────────────────────────
@Component({ ... })
export class TaskDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
task = signal<Task | null>(null);
ngOnInit(): void {
// Snapshot — use when component is always recreated on navigation
const id = this.route.snapshot.paramMap.get('id');
const page = this.route.snapshot.queryParamMap.get('page') ?? '1';
// Observable — use when component stays mounted (reused across navigations)
this.route.paramMap.pipe(
map(params => params.get('id')),
filter((id): id is string => id !== null),
switchMap(id => this.taskService.getById(id).pipe(
catchError(() => { this.router.navigate(['/tasks']); return EMPTY; })
)),
takeUntilDestroyed(),
).subscribe(task => this.task.set(task));
// Query params — reactive filter/pagination
this.route.queryParamMap.pipe(
takeUntilDestroyed(),
).subscribe(params => {
this.currentPage.set(parseInt(params.get('page') ?? '1'));
this.statusFilter.set(params.get('status') ?? '');
});
}
}
How It Works
Step 1 — Guards Run in Order Before Route Activation
When navigation starts, Angular runs all canActivate guards in the order they are listed. If any guard returns false or a UrlTree, navigation stops and the URL reverts (or redirects to the UrlTree). All guards must return true for navigation to proceed. Guards can return synchronous boolean values, Promises, or Observables — Angular waits for async guards to complete before proceeding.
Step 2 — UrlTree Return Triggers a Redirect
Returning a UrlTree from a guard does not just prevent navigation — it actively redirects to the URL represented by the tree. This is cleaner than returning false and then calling router.navigate() separately (which could race or create navigation history entries). router.createUrlTree(['/auth/login'], { queryParams: { returnUrl: state.url } }) creates a UrlTree representing the login page with the return URL encoded as a query parameter.
Step 3 — Resolvers Guarantee Data Availability on Component Init
A resolver is a function that returns data before the target component is created. Angular waits for all resolver Observables/Promises to complete, collects their results into the route’s data object (keyed by resolver name), and only then creates the component. The component reads this.route.snapshot.data['task'] synchronously in ngOnInit — no loading state, no null check for initial render required.
Step 4 — paramMap vs params — Type-Safe Parameter Access
route.paramMap returns an Observable of ParamMap, which provides .get(key), .getAll(key), and .has(key) methods. This is preferred over route.params (which gives a plain object) for several reasons: paramMap.get('id') returns string | null — forcing you to handle the missing case; .getAll('tag') handles multi-value parameters; and the type-safe API catches key typos at development time.
Step 5 — canDeactivate Guards Protect Against Data Loss
The canDeactivate guard runs when the user tries to leave the current route — by clicking a link, pressing the back button, or calling router.navigate(). It receives the current component instance as its first argument, allowing it to check component.hasUnsavedChanges(). Returning false prevents navigation, giving the user a chance to save their work. Returning an Observable allows showing an async confirmation dialog before deciding.
Common Mistakes
Mistake 1 — Guard returns false instead of a UrlTree for redirect
❌ Wrong — navigation stops but URL reverts, no redirect to login:
export const authGuard: CanActivateFn = () => {
if (inject(AuthStore).isAuthenticated()) return true;
inject(Router).navigate(['/auth/login']); // starts new navigation
return false; // and also blocks current — creates two navigations!
};
✅ Correct — return a UrlTree for atomic redirect:
export const authGuard: CanActivateFn = (_, state) => {
if (inject(AuthStore).isAuthenticated()) return true;
return inject(Router).createUrlTree(['/auth/login'], {
queryParams: { returnUrl: state.url },
});
};
Mistake 2 — Reading route params synchronously outside ngOnInit
❌ Wrong — snapshot may not be initialised in constructor:
export class TaskDetailComponent {
task = signal<Task | null>(null);
constructor(private route: ActivatedRoute) {
const id = this.route.snapshot.paramMap.get('id'); // may be null in constructor!
this.loadTask(id!);
}
}
✅ Correct — read params in ngOnInit:
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
if (id) this.loadTask(id);
}
Mistake 3 — Resolver error silently cancels navigation
❌ Wrong — HTTP error in resolver cancels navigation with no feedback:
export const taskResolver: ResolveFn<Task> = route =>
inject(TaskService).getById(route.paramMap.get('id')!);
// If getById() fails: navigation cancelled, user stuck on previous page silently
✅ Correct — handle errors explicitly:
export const taskResolver: ResolveFn<Task> = route =>
inject(TaskService).getById(route.paramMap.get('id')!).pipe(
catchError(() => { inject(Router).navigate(['/tasks']); return EMPTY; })
);
Quick Reference
| Need | Code |
|---|---|
| Auth guard | const guard: CanActivateFn = () => inject(AuthStore).isAuthenticated() || redirect |
| Redirect from guard | return inject(Router).createUrlTree(['/login']) |
| Role guard factory | function roleGuard(role): CanActivateFn { return () => ... } |
| Unsaved changes | const guard: CanDeactivateFn<T> = comp => comp.hasUnsavedChanges() ? confirm() : true |
| Resolver | const resolver: ResolveFn<Task> = route => inject(TaskService).getById(route.params['id']) |
| Read path param (snapshot) | this.route.snapshot.paramMap.get('id') |
| Read path param (reactive) | this.route.paramMap.pipe(map(p => p.get('id'))) |
| Read query param | this.route.snapshot.queryParamMap.get('page') ?? '1' |
| Read resolved data | this.route.snapshot.data['task'] as Task |