Angular Functional Route Guards: CanActivateFn, CanDeactivateFn, CanMatchFn, ResolveFn [2026]

Link copied
Angular Functional Route Guards: CanActivateFn, CanDeactivateFn, CanMatchFn, ResolveFn [2026]

Angular Functional Route Guards: CanActivateFn, CanDeactivateFn, CanMatchFn, ResolveFn [2026]

Angular Tutorial Module 5: Routing Lesson 5.3

Route guards decide whether navigation goes ahead — "is this user allowed to enter?" — and whether navigation can stop — "are there unsaved changes?" Since v14 they are functions, not class-based guards with Injectable decorators. The functional form is shorter, easier to test, and integrates cleanly with inject(). This lesson walks each guard kind, what shape it returns, and when to use each.

This is lesson 5.3, following lesson 5.2 on route configuration. After this lesson you can build a fully gated app — auth flows, leave-confirmation dialogs, feature flags.

The four guard kinds #

Guard Type When it fires Returns
CanMatchFn (route, segments) => boolean | UrlTree | Promise/Observable Before route matching false skips this route entirely
CanActivateFn (route, state) => boolean | UrlTree | ... After matching, before activating false cancels navigation
CanActivateChildFn Same as above Before activating any descendant Same
CanDeactivateFn<T> (component, currentRoute, currentState, nextState) => ... When leaving the route false blocks navigation

All four can return:

  • true / false — allow / block synchronously
  • Promise<true \| false> — allow / block asynchronously
  • Observable<true \| false> — RxJS-flavored asynchronous
  • UrlTree — redirect to another URL (instead of just blocking)

The UrlTree return is the cleanest pattern for "redirect on failure" — e.g., auth guard sending the user to /login.

CanActivateFn — the auth guard pattern #

The most common guard. Block access to a route until some condition is met:

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.isLoggedIn()) return true;

  // Redirect to login, with the intended URL preserved as a return path
  return router.createUrlTree(['/login'], {
    queryParams: { returnUrl: state.url },
  });
};

Apply on the route:

{ path: 'dashboard', component: DashboardPage, canActivate: [authGuard] }

Three facts:

  1. inject() works inside the guard function — Angular wraps the call in an injection context
  2. Returning a UrlTree triggers a redirect — cleaner than calling router.navigate(...) and returning false
  3. Multiple guards can stack: canActivate: [authGuard, adminGuard]. ALL must pass

CanActivateChildFn — gate the subtree #

Applied on a parent route, runs for every navigation to a descendant route:

{
  path: 'admin',
  component: AdminShell,
  canActivateChild: [adminGuard],
  children: [
    { path: 'users', component: AdminUsersPage },
    { path: 'roles', component: AdminRolesPage },
  ],
}

The adminGuard fires before activating /admin/users AND /admin/roles AND any future child. Cheaper than copying canActivate: [adminGuard] onto every child.

CanDeactivateFn<T> — block leaving with unsaved changes #

The component being left in passed as the first argument:

import { CanDeactivateFn } from '@angular/router';

export interface CanLeave {
  canLeave(): boolean;   // component must implement this
}

export const confirmLeaveGuard: CanDeactivateFn<CanLeave> = (component) => {
  if (component.canLeave()) return true;
  return confirm('You have unsaved changes. Leave anyway?');
};

Apply on the route:

{ path: 'edit/:id', component: EditPage, canDeactivate: [confirmLeaveGuard] }

The component implements the contract:

export class EditPage implements CanLeave {
  dirty = signal(false);
  canLeave() { return !this.dirty(); }
}

The confirm() here is the browser's native dialog. For a styled custom modal, return a Promise<boolean>:

return dialogService.open(ConfirmLeaveDialog).then(r => r.confirmed);

CanMatchFn — route disappears if guard fails #

Unlike canActivate, a failing canMatch makes the route invisible to the matcher — the URL falls through to the next matching route:

import { CanMatchFn } from '@angular/router';

export const betaFlagGuard: CanMatchFn = () => {
  const flags = inject(FeatureFlagsService);
  return flags.isOn('beta');
};
export const routes: Routes = [
  { path: 'beta-feature', component: BetaPage, canMatch: [betaFlagGuard] },
  { path: '**', component: NotFoundPage },
];

When the flag is off, /beta-feature falls through to the 404. The user sees "Not found" — no "access denied" banner, no redirect to login. The route effectively doesn't exist for that user.

Classic use cases: feature flags, role-based route variants (different /dashboard per role), A/B-tested routes.

ResolveFn<T> — pre-fetch data before navigating #

The last "guard-like" function — it doesn't gate; it pre-fetches data:

import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { UserService } from './user.service';

export const userResolver: ResolveFn<User> = (route) => {
  const id = route.paramMap.get('id')!;
  return inject(UserService).load(id);
};

Wired on the route:

{ path: 'users/:id', component: UserDetailPage, resolve: { user: userResolver } }

The component reads the resolved value:

// With withComponentInputBinding()
export class UserDetailPage {
  user = input.required<User>();   // ← resolved data lands here
}

The route does NOT activate until the resolver's Promise/Observable completes. The user sees the previous page until the data is ready. Lesson 5.5 covers resolvers + prefetching in depth.

For Promise returns, the resolver awaits before activation. For Observable returns, it takes the FIRST emission.

Returning async values #

All guards accept Promise or Observable returns:

export const tokenValidGuard: CanActivateFn = async () => {
  const auth = inject(AuthService);
  const valid = await auth.validateTokenWithServer();
  return valid;
};

export const tokenValidGuard$: CanActivateFn = () => {
  return inject(AuthService).validateTokenWithServer$().pipe(
    map(valid => valid || createUrlTree(['/login'])),
  );
};

For most cases, async/await reads more naturally than the Observable form.

Composing multiple guards on one route #

Stacked guards run in declaration order. ALL must return truthy for navigation to proceed:

{
  path: 'admin/billing',
  component: BillingPage,
  canActivate: [authGuard, adminGuard, billingFeatureGuard],
}

If authGuard returns false (or a UrlTree), the rest don't run. Short-circuit evaluation.

For OR-logic across guards ("either A or B"), wrap into a single guard:

export const adminOrSelfGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const userIdInRoute = route.paramMap.get('id');
  return auth.isAdmin() || auth.userId() === userIdInRoute;
};

Testing guards #

Functional guards are easy to test because they're just functions:

import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';

it('redirects to /login when not authenticated', async () => {
  TestBed.configureTestingModule({
    providers: [
      { provide: AuthService, useValue: { isLoggedIn: () => false } },
      { provide: Router, useValue: { createUrlTree: jest.fn(() => 'URLTREE' as any) } },
    ],
  });

  const result = TestBed.runInInjectionContext(() => authGuard({} as any, { url: '/dashboard' } as any));
  expect(result).toBe('URLTREE');
});

No class instantiation, no decorator parsing. The guard's logic is the function body.

Common gotchas #

Symptom Cause Fix
NG0203: inject() must be called from an injection context Called inject() outside the top-level body of the guard Move all inject() calls to the top of the guard function
Guard fires twice on navigation Both canActivate AND canActivateChild on parent Pick one; canActivateChild covers all descendants
canDeactivate doesn't fire User refreshed the browser or closed the tab Browser-level — use window.beforeunload for that case
Async guard returns Promise but route activates instantly The guard returned a plain value, not a Promise Make sure the guard's return type is Promise<...> or has await somewhere
canMatch route still appears in URL match attempts even when flag is off Inject called outside the guard body or guard returns truthy when it shouldn't Double-check the boolean logic
Class-based guards still work They're deprecated but supported Migrate to functional form opportunistically

What's next #

Lesson 5.4 covers router events — the 11 events fired during a navigation cycle, useful for loading bars, scroll restoration, analytics. Lesson 5.5 covers data resolvers in depth (the resolve config in real apps). Lesson 5.6 closes Module 5 with lazy loading.

Try it yourself #

A mini app with auth guard, admin sub-guard, and deactivate guard:

import { Component, signal, inject, input } from '@angular/core';
import { provideRouter, withComponentInputBinding, RouterOutlet, RouterLink, CanActivateFn, CanDeactivateFn, Router } from '@angular/router';
import { bootstrapApplication } from '@angular/platform-browser';
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
class Auth { loggedIn = signal(false); admin = signal(false); }

const authGuard: CanActivateFn = () => {
  const a = inject(Auth), r = inject(Router);
  return a.loggedIn() || r.createUrlTree(['/login']);
};

const adminGuard: CanActivateFn = () => inject(Auth).admin();

const leaveGuard: CanDeactivateFn<EditPage> = (cmp) =>
  !cmp.dirty() || confirm('Unsaved changes — leave?');

@Component({ template: `<button (click)="a.loggedIn.set(true)">Log in</button>` })
class LoginPage { a = inject(Auth); }

@Component({ template: `Dashboard. <button (click)="dirty.set(true)">Make dirty</button>` })
class EditPage { dirty = signal(false); }

@Component({ template: `Admin area` })
class AdminPage {}

@Component({ selector: 'app-root', imports: [RouterOutlet, RouterLink],
  template: `
    <a routerLink="/">Edit</a> | <a routerLink="/admin">Admin</a> | <a routerLink="/login">Login</a>
    <router-outlet />
  `,
})
class App {}

bootstrapApplication(App, {
  providers: [provideRouter([
    { path: '', component: EditPage, canActivate: [authGuard], canDeactivate: [leaveGuard] },
    { path: 'admin', component: AdminPage, canActivate: [authGuard, adminGuard] },
    { path: 'login', component: LoginPage },
  ], withComponentInputBinding())],
});

Click around — without logging in, / redirects to /login. Log in but not as admin, /admin is blocked. Make / dirty and try to leave — confirm dialog appears.

YouI see old code using class-based @Injectable guards. Should I migrate to functional?
YouYes — class-based guards are deprecated. Migrate opportunistically when you touch the route config. The mapping is mechanical: class AuthGuard implements CanActivate { canActivate(...) {...} } becomes const authGuard: CanActivateFn = (...) => {...}. The Angular CLI ships ng generate @angular/core:control-flow-style migrations for many of these, including guard migrations. Functional guards are faster, easier to test, and the only form documented for new code.

Lesson 5.4 picks up router events — all 11 navigation events.

Angular Tutorial · Lesson 5.3
← Previous lesson Angular Complete Route Configuration: Every Route Property Explained [2026] Next lesson → Router events & navigation lifecycle (coming soon)

Up next in Angular

More from this topic

View all Angular articles →
Angular

When Angular is launched ?

Link copied When Angular is launched ? When Angular Landed: A History of the Popular Web Framework # Angular, …

Feb 8, 2024 Read →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *