Routing plays a key role in every single-page application, and securing those routes is often a requirement. We might need to protect our routes based on user permissions or stop users from navigating away unsaved forms by accident.

Angular ships with a set of prebuilt guards that cover many common situations out of the box.

In this piece, I'll go through each of the built-in guards, explain how they work, and walk through practical examples of using them with Angular's routing module.

CanActivate

Among all guards, this one sees the most frequent use. The canActivate guard decides whether a route can be entered by executing whatever logic you define in its implementation. What the guard's method returns can be a boolean, a UrlTree, a Promise<boolean | UrlTree>, or an Observable<boolean | UrlTree>.

canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree
Enter fullscreen mode Exit fullscreen mode

When a route is protected by a guard, the router invokes the guard's method prior to navigating. A return value of true allows the navigation to continue, while a return value of false halts it, keeping the user on their current route. In cases where the method returns a promise or an observable, the router waits for its resolution before proceeding. If a UrlTree is returned, the original navigation is aborted and a fresh navigation is carried out instead.

Example:

@Injectable({ providedIn: 'root' })
export class PermissionsService {
  private user = getUser();

  isAdmin(isAdmin: boolean) {
    return isAdmin ? user.isAdmin : false;
  }
}

@Injectable({ providedIn: 'root' })
export class IsAdminGuard implements CanActivate {
  private permission = inject(PermissionsService);

  canActivate(route: ActivatedRouteSnapshot) {
      const isAdmin: boolean = route.data?.['isAdmin'] ?? false;
      return this.permission.isAdmin(isAdmin);
  }
}

export const APP_ROUTES: [{
  path: 'dashboard',
  canActivate: [IsAdminGuard],
  data: {
    isAdmin: true,
  },
  loadComponent: () => import('./dashboard/admin.component'),
}]

Enter fullscreen mode Exit fullscreen mode

This example demonstrates the standard pattern for building a route guard. We create a service class that implements the CanActivate interface. Here, the guard determines whether the current user holds admin privileges, because this particular route is restricted to administrators only.

We can also communicate extra configuration to the guard by defining properties under the data key within the Route object.

Caution: implementing guards as injectable services is slated for deprecation in v15.2 and removal in v17

Guards based on injectable classes or InjectionToken offer less flexibility and reuse potential, while demanding additional boilerplate. Moreover, they cannot be defined inline, which reduces their overall utility and adds unnecessary complexity.

GitHub logo Deprecate class and `InjectionToken` guards and resolvers #47924

Class and InjectionToken-based guards and resolvers are not as configurable, are less re-usable, require more boilerplate, cannot be defined inline with the route, and require more in-depth knowledge of Angular features (Injectable/providers). In short, they're less powerful and more cumbersome.

In addition, continued support increases the API surface which in turn increases bundle size, code complexity, the learning curve and API surface to teach, maintenance cost, and cognitive load (needing to grok several different types of information in a single place).

Lastly, supporting only the CanXFn types for guards and ResolveFn type for resolvers in the Route interface will enable better code completion and integration with TypeScript. For example, when writing an inline functional resolver today, the function is typed as any and does not provide completions for the ResolveFn parameters. By restricting the type to only ResolveFn, in the example below TypeScript would be able to correctly identify the route parameter as ActivatedRouteSnapshot and when authoring the inline route, the language service would be able to autocomplete the function parameters.

const userRoute: Route = {
  path: 'user/:id',
  resolve: {
    "user": (route) => inject(UserService).getUser(route.params['id']);
  }
};

Importantly, this deprecation only affects the support for class and InjectionToken guards at the Route definition. Injectable classes and InjectionToken providers are not being deprecated in the general sense. Functional guards are robust enough to even support the existing class-based guards through a transform:

function mapToCanMatch(providers: Array<Type<{canMatch: CanMatchFn}>>): CanMatchFn[] {
  return providers.map(provider => (...params) => inject(provider).canMatch(...params));
}
const route = {
  path: 'admin',
  canMatch: mapToCanMatch([AdminGuard]),
};

With regards to tests, because of the ability to map Injectable classes to guard functions as outlined above, nothing needs to change if projects prefer testing guards the way they do today. Functional guards can also be written in a way that's either testable with runInContext or by passing mock implementations of dependencies. For example:

export function myGuardWithMockableDeps(
  dep1 = inject(MyService),
  dep2 = inject(MyService2),
  dep3 = inject(MyService3),
) { }

const route = {
  path: 'admin',
  canActivate: [() => myGuardWithMockableDeps()]
}

// test file
const guardResultWithMockDeps = myGuardWithMockableDeps(mockService1, mockService2, mockService3);
const guardResultWithRealDeps = TestBed.inject(EnvironmentInjector).runInContext(myGuardWithMockableDeps);

For those who prefer this approach, or need to support legacy code, a factory function must be defined to inject the service, as shown here:

function mapToActivate(providers: Array<Type<{canActivate: CanActivateFn}>>): CanActivateFn[] {
  return providers.map(provider => (...params) => inject(provider).canActivate(...params));
}
const route = {
  path: 'admin',
  canActivate: mapToActivate([IsAdminGuard]),
};
Enter fullscreen mode Exit fullscreen mode

The new way:

@Injectable({ providedIn: 'root' })
export class PermissionsService {
  isAdmin(isAdmin: boolean) {
    return isAdmin;
  }
}

export const canActivate = (isAdmin: boolean, permissionService = inject(PermissionsService)) => permissionService.isAdmin(isAdmin);

export const APP_ROUTES: [{
  path: 'dashboard',
  canActivate: [() => canActivate(true)],
  loadComponent: () => import('./dashboard/admin.component'),
 }]
Enter fullscreen mode Exit fullscreen mode

Doesn't this approach feel much cleaner? It cuts down on repetitive code and makes the intent clearer (With the old method, you had to manually assign properties on the Route data attribute, which were easy to overlook).


In the rest of this article, every example will adopt this newer style.

CanMatch

The CanMatch guard, introduced in Angular v14.2, is a recent addition. When all guards return true, it activates the route and loads the lazy-loaded component; if any guard fails, it reroutes to the next route with a matching name.

Warning: Keep in mind that at least ONE route must match; if none do, you'll see an error in your console.

ERROR Error: Uncaught (in promise): Error: NG04002: Cannot match any routes. 
URL Segment: 'dashboard'
Enter fullscreen mode Exit fullscreen mode

Example:

@Injectable({ providedIn: 'root' })
export class PermissionService {
  isAllowed(permissions: Permission[]) {
    const user = ...
    return permissions.includes(user.permission);
  }
}

export type Permission = 'ADMIN' | 'USER' | 'MANAGER';

export const canMatch = (permissions: Permission[], permissionService = inject(PermissionsService)) =>
  permissionService.isAllowed(permissions);

export const APP_ROUTES: [
  {
    path: 'dashboard',
    canMatch: [() => canMatch(['ADMIN'])],
    loadComponent: () => import('./dashboard/admin.component'),
  },
  {
    path: 'dashboard',
    canMatch: [() => canMatch(['MANAGER'])],
    loadComponent: () => import('./dashboard/manager.component'),
  },
  {
    path: 'dashboard',
    loadComponent: () => import('./dashboard/everyone.component'),
  }
]
Enter fullscreen mode Exit fullscreen mode

When a user attempts to reach the /dashboard path, the router verifies whether they hold the ADMIN permission — if so, it renders AdminComponent; if not, it moves on to the next matching route. A dedicated fallback route can serve this dashboard navigation scenario, or a broader wildcard route using ** can intercept any navigation that doesn't match earlier patterns:

{
  path: '**',
  loadComponent: () => import('./not-found.component'),
}
Enter fullscreen mode Exit fullscreen mode

Note: A UrlTree is also a valid return type for this guard, and when returned, the prior navigation gets cancelled while a fresh one kicks off.

@Injectable({ providedIn: 'root' })
export class PermissionService {
  constructor(private router: Router) {}

  isAllowed(permissions: Permission[]) {
    if(!user) {
      return this.router.parseUrl('no-user');
    }
    // check permissions
  }
}
Enter fullscreen mode Exit fullscreen mode

CanActivateChild

Frequently confused with CanActivate, this guard works in a very similar fashion.

An illustration of how they diverge is provided below:

export const APP_ROUTES = [
  {
    path: 'dashboard',
    canActivate: [() => true],
    canActivateChild: [() => true],
    loadComponent: () => import('./dashboard/no-user.component'),
    loadChildren: () => import('./child-routes').then((m) => m.CHILDREN_ROUTE),
  }
]

// inside child-routes
export const CHILDREN_ROUTE = [
  {
    path: 'manager',
    loadComponent: () => import('./dashboard/manager.component'),
  },
  {
    path: 'client',
    loadComponent: () => import('./dashboard/client.component'),
  },
];
Enter fullscreen mode Exit fullscreen mode

The main contrasts between these two guards are:

  • Starting from the root / and heading to /dashboard/manager, both the CanActivate and CanActivateChild guards fire. Yet, when moving between child routes (for instance, from /dashboard/manager to /dashboard/client), only CanActivateChild runs. The CanActivate guard remains inactive as long as the parent component already exists.

  • When the navigation target is solely the parent route, CanActivate is the only guard to be triggered.

  • If one of the guards registered within CanActivateChild returns false while navigating to a child, the entire navigation gets blocked, preventing even the parent from being instantiated.

  • CanActivate always precedes CanActivateChild. If CanActivate yields false, then CanActivateChild never gets a chance to run.

  • Attaching CanActivate to every individual child route can serve as a substitute for using CanActivateChild.

CanDeactivate

This guard governs whether the user can leave a specific route. Employing it, you can block navigation until certain criteria are fulfilled, alternatively, it can trigger a confirmation dialog before the user exits the component.

Note: A typical scenario involves forms — this guard prevents navigation when the form contains unsaved changes. To alert the user about these pending modifications, a modal dialog with a relevant warning can be presented.

Example:

export interface DeactivationGuarded {
  canDeactivate(): Observable<boolean> | Promise<boolean> | boolean;
}

@Component({
  standalone: true,
  imports: [RouterLink, ButtonComponent],
  template: `<button app-button routerLink="/">Logout</button>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class NoUserDashboardComponent implements DeactivationGuarded {
  canDeactivate(): boolean | Observable<boolean> | Promise<boolean> {
    return false;
  }
}

export const APP_ROUTES = [
  {
    path: 'dashboard',
    canDeactivate: [(comp: DeactivationGuarded) => comp.canDeactivate()],
    loadComponent: () => import('./dashboard/no-user.component'),
  }
]
Enter fullscreen mode Exit fullscreen mode
  • CanDeactivate receives the component linked to the route and passes it into the function.

Note: Similar to other guards, it may return a UrlTree to trigger navigation to an alternative route.

CanLoad

CanLoad is a guard frequently paired with CanActivate. It enables the lazy-loaded component when the guard returns true.

Note: Deprecated in Angular v.15.1, this guard is superseded by CanMatch

Tips/ Tricks

Chaining guards

A guard property is typed as an Array, allowing for multiple guards to be linked to a single route.

  • Guards execute sequentially in the order they are listed
  • Any guard returning false causes the navigation to be aborted
  • If the initial guard fails, the remaining guards in the array are skipped
  • When a guard returns a UrlTree, subsequent guards are bypassed and the navigation is redirected.
export const APP_ROUTES = [
  {
    path: 'dashboard',
    canActivate: [() => true, () => false, () => true],
    loadComponent: () => import('./dashboard/no-user.component'),
  }
]
Enter fullscreen mode Exit fullscreen mode

In the example above, the dashboard route is blocked, so the final function never gets invoked.

Parameter-based routing to child components

Consider this setup:

export const CHILDREN_ROUTE = [
  {
    path: '',
    pathMatch: 'full',
    redirectTo: 'compA'
  },
  {
    path: 'compA',
    loadComponent: () => import('./dashboard/comp-a.component'),
  },
  {
    path: 'compB',
    loadComponent: () => import('./dashboard/comp-b.component'),
  },
];

export const APP_ROUTES = [
  {
    path: 'dashboard',
    loadComponent: () => import('./dashboard/no-user.component'),
    loadChildren: () => import('./child_routes').then((m) => m.CHILDREN_ROUTE),
  }
]
Enter fullscreen mode Exit fullscreen mode

Navigating to the dashboard route triggers an automatic redirect to dashboard/compA. However, when the target component must be chosen dynamically based on an external factor—like user permissions or whether a particular action was performed—we can manipulate the routing behavior by employing a guard that returns a UrlTree. This UrlTree then directs the user to the appropriate URL, determined by the specified conditions.

export const redirectTo = (router = inject(Router), userStore = inject(UserStore)) => {
  return userStore.hasDoneAction$.pipe(
    mergeMap((hasDoneAction) =>
      iif(
        () => hasDoneAction,
        of(router.createUrlTree(['dashboard', 'compA'])),
        of(router.createUrlTree(['dashboard', 'compB']))
      )
    )
  );
};

export const CHILDREN_ROUTE: Route[] = [
  {
    path: '',
    pathMatch: 'full',
    children: [],
    canActivate: [() => redirectTo()],
  },
  {
    path: 'compA',
    loadComponent: () => import('./dashboard/comp-a.component'),
  },
  {
    path: 'compB',
    loadComponent: () => import('./dashboard/comp-b.component'),
  },
];
Enter fullscreen mode Exit fullscreen mode

Note: The route configuration needs the children property set to an empty array, as each route definition has to include at least one of these fields: component, loadComponent, redirectTo, children, or loadChildren.


This wraps up this guide! With these details, you can confidently apply guards to all your routes.

Hopefully, you picked up some new Angular knowledge. If you enjoyed this, connect with me on Twitter or Github.

👉 To fast-track your Angular and Nx learning, explore Angular challenges.