Route Guards, Resolvers, and Route Parameters

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
Note: When to use 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.
Tip: Return a 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.
Warning: Guards that make HTTP calls must handle errors — an unhandled HTTP error in a guard causes the navigation to hang indefinitely. Always add 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

🧠 Test Yourself

A route resolver makes an HTTP call. The API returns a 404. Without error handling, what happens to the navigation?