Functional Route Guards
Route guards decide whether navigation to or away from a route is allowed. Since Angular 14, guards are plain functions instead of class-based services, and with inject() they can pull in any dependency they need — services, the router, signals — without constructors or @Injectable. Functional guards are the modern standard: they are smaller, composable, tree-shakable, and trivially testable. This page covers the three guards you reach for most: CanActivateFn, CanMatchFn, and CanDeactivateFn.
How functional guards work
A functional guard is a function with a typed signature. It runs inside an injection context, so you can call inject() at the top to grab dependencies. It returns a boolean, a UrlTree (to redirect), or an Observable/Promise resolving to one of those. Returning true allows navigation; false blocks it; a UrlTree cancels the current navigation and starts a new one.
You attach guards directly in the route config:
import { Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./dashboard.component'),
canActivate: [authGuard],
},
];
CanActivateFn — allow or block entry
CanActivateFn runs before a route is activated. The classic use case is authentication: redirect anonymous users to the login page. Because the guard returns a UrlTree, the redirect is handled by the router itself — no manual navigate() call needed.
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) {
return true;
}
// Redirect to login, preserving the attempted URL
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
The guard receives the ActivatedRouteSnapshot and RouterStateSnapshot, giving you access to route params, data, and the target URL. You can use route.data to pass role requirements and build a single reusable guard:
export const roleGuard: CanActivateFn = (route) => {
const auth = inject(AuthService);
const required = route.data['role'] as string;
return auth.hasRole(required) || inject(Router).createUrlTree(['/forbidden']);
};
{ path: 'admin', component: AdminComponent, canActivate: [roleGuard], data: { role: 'admin' } }
CanMatchFn — control whether a route matches at all
CanMatchFn runs before the router commits to a route, during path matching. If it returns false, the router skips this route and continues evaluating later routes with the same path. This is more powerful than canActivate for two reasons: it lets you show different components for the same URL depending on conditions, and it prevents the route’s lazy chunk from being downloaded when access is denied.
import { inject } from '@angular/core';
import { CanMatchFn, Router } from '@angular/router';
import { FeatureFlags } from '../services/feature-flags.service';
export const betaGuard: CanMatchFn = () => {
const flags = inject(FeatureFlags);
return flags.isEnabled('beta-ui') ? true : inject(Router).createUrlTree(['/']);
};
export const routes: Routes = [
{
path: 'reports',
canMatch: [betaGuard],
loadComponent: () => import('./reports-v2.component'),
},
{
path: 'reports',
loadComponent: () => import('./reports-v1.component'),
},
];
Use
canMatchinstead ofcanActivatewhen you want to gate lazy-loaded code. WithcanActivatethe chunk still downloads before the guard rejects; withcanMatchthe router never loads it.
CanDeactivateFn — confirm before leaving
CanDeactivateFn runs when the user navigates away from a route. The most common use is warning about unsaved form changes. The guard is generic over the component type, so it receives the actual component instance and can read its state.
import { CanDeactivateFn } from '@angular/router';
export interface FormComponent {
hasUnsavedChanges(): boolean;
}
export const unsavedChangesGuard: CanDeactivateFn<FormComponent> = (component) => {
if (component.hasUnsavedChanges()) {
return confirm('You have unsaved changes. Leave anyway?');
}
return true;
};
The component simply exposes the method the guard expects:
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-edit',
template: `<input [value]="name()" (input)="dirty.set(true)" />`,
})
export class EditComponent {
name = signal('');
dirty = signal(false);
hasUnsavedChanges() {
return this.dirty();
}
}
Guard return types and timing
| Guard | When it runs | Receives | Typical use |
|---|---|---|---|
CanActivateFn | Before activating a matched route | snapshot, router state | Auth, role checks |
CanActivateChildFn | Before activating any child route | snapshot, router state | Section-wide auth |
CanMatchFn | During path matching, before load | route, segments | Feature flags, lazy gating |
CanDeactivateFn<T> | When leaving a route | component instance | Unsaved-change prompts |
All of them accept the same return shape:
type GuardResult = boolean | UrlTree;
type MaybeAsync<T> = T | Observable<T> | Promise<T>;
Testing a functional guard
Because guards are functions, you test them by running inside an injection context with TestBed.runInInjectionContext:
import { TestBed } from '@angular/core/testing';
it('allows access when logged in', () => {
TestBed.configureTestingModule({
providers: [{ provide: AuthService, useValue: { isLoggedIn: () => true } }],
});
const result = TestBed.runInInjectionContext(() =>
authGuard({} as any, { url: '/dashboard' } as any),
);
expect(result).toBe(true);
});
Output:
✓ allows access when logged in (12 ms)
Tests: 1 passed, 1 total
Best Practices
- Prefer functional guards over class-based ones; they are the recommended approach and class guards are effectively legacy.
- Return a
UrlTreefor redirects rather than callingrouter.navigate()— it keeps navigation cancellation atomic. - Use
canMatchto gate lazy-loaded routes so unauthorized users never download the chunk. - Drive role/permission checks from
route.dataso one guard serves many routes. - Keep guards thin — delegate real logic to injected services and signals for easy testing and reuse.
- Compose multiple small guards in the array (
canActivate: [authGuard, roleGuard]) instead of one monolithic guard.