Guards decide whether you can enter (canActivate), whether the route exists for you (canMatch), or whether you can leave (canDeactivate). Resolvers load data before the page appears. In modern Angular all of them are functions that use inject() — no classes, no interfaces to implement.
A functional guard
import { inject } from '@angular/core';
import { CanActivateFn, RedirectCommand, Router } from '@angular/router';
export const adminGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthStore);
const router = inject(Router);
if (auth.isAdmin()) return true;
// UrlTree: simple redirect
// return router.parseUrl('/login');
// RedirectCommand: redirect + navigation options
return new RedirectCommand(router.parseUrl(`/login?returnUrl=${state.url}`), { replaceUrl: true });
};Public area. Anyone can see this page.
canDeactivate: unsaved changes
export interface HasUnsavedChanges {
hasUnsavedChanges(): boolean;
}
export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) =>
!component.hasUnsavedChanges() || confirm('You have unsaved changes. Leave anyway?');
@Component({ /* ... */ })
export class EditProfilePage implements HasUnsavedChanges {
readonly form = form(this.model);
hasUnsavedChanges() {
return this.form().dirty();
}
}Resolvers
export const reportResolver: ResolveFn<Report> = (route) => {
const api = inject(ReportsApi);
return api.get(Number(route.paramMap.get('id'))); // Observable / Promise / plain value
};
@Component({ /* ... */ })
export default class ReportPage {
readonly report = input.required<Report>(); // comes from resolve, already loaded
}Resolver httpResource in the component
───────────────────────────────────────────────────────────────────────────
the page appears with the data ready the page appears at once, with loading
navigation “waits” until data arrives navigation is instant
error = navigation cancelled error = state in the UI (error())
good for: required, small data good for: lists, dashboards, fast UXcanMatch vs canActivate
// canMatch vs canActivate
// canMatch: decides whether the route “exists” for the user. If not → Angular tries the next route
// with the same path, and the lazy chunk is NOT downloaded.
// canActivate: the route exists, but entry is blocked (the chunk may already be downloaded).
const routes: Routes = [
{ path: 'dashboard', canMatch: [isAdmin], loadComponent: () => import('./admin-dashboard') },
{ path: 'dashboard', loadComponent: () => import('./user-dashboard') }, // fallback for everyone else
];
// v22: currentSnapshot is now a required parameter in CanMatchFn / CanMatch.canMatch (route, segments, currentSnapshot)
// v22.2: you can THROW a RedirectCommand from guards/resolvers to redirectThe order they run in
During a navigation the router goes through fixed steps. If one step says “no”, the following ones do not run:
canMatch, while it looks for the route. A “no” here means the route does not exist for this user, and the router tries the next one.canDeactivateon the routes you are leaving.canActivateChildandcanActivate, from parent to child.- The resolvers. Only after all of them finish does the component appear.
An async guard
A guard can return a Promise or an Observable, and the navigation waits. The typical case: the user is not loaded yet when the app starts. Wait for the first real value, not the empty initial state.
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthStore);
const router = inject(Router);
// wait until it is no longer “loading”, then decide once
return toObservable(auth.status).pipe(
filter((s) => s !== 'loading'),
take(1),
map((s) =>
s === 'signed-in'
? true
: router.createUrlTree(['/login'], { queryParams: { next: state.url } }),
),
);
};Resolver data as an input
With withComponentInputBinding(), the keys in resolve go straight into the component’s inputs, just like URL parameters. The component no longer needs ActivatedRoute.
// app.config.ts
provideRouter(routes, withComponentInputBinding());
// routes.ts
{ path: 'reports/:id', component: ReportPage, resolve: { report: reportResolver } }
// report-page.ts — no ActivatedRoute
export class ReportPage {
readonly id = input.required<string>(); // from the URL
readonly report = input.required<Report>(); // from resolve
}When YES, when NO
canMatchon restricted features (admin) — you don’t download the code for nothing.canActivatefor authentication / permissions on pages.canDeactivatefor forms with unsaved changes.- A resolver for small, required data, without which the page makes no sense.
- Guards as the only security — the server must check anyway.
- Resolvers for large lists → the page looks “frozen”. Use
httpResource+ a skeleton. - Business logic in a guard (calculations, transformations) → move it into a service.
Common pitfalls
- A guard that returns
falsewithout a redirect → the user stays put without understanding why. Return aUrlTree/RedirectCommand. - A guard on the parent, but navigations between children:
canActivateon the parent doesn’t re-run when the child changes — usecanActivateChildorrunGuardsAndResolvers. - A resolver that fails cancels the navigation — handle the error (redirect to an error page).
Cheat sheet
// order: canMatch → canDeactivate → canActivateChild / canActivate → resolve
canMatch: [() => inject(AuthStore).isAdmin()]
canActivate: [authGuard]
canActivateChild: [authGuard]
canDeactivate: [(c: HasUnsavedChanges) => !c.hasUnsavedChanges() || confirm('Leave without saving?')]
resolve: { report: reportResolver }
// what a guard can return
return true | false | router.createUrlTree(['/login']) | new RedirectCommand(tree)
return Promise<...> | Observable<...> // the navigation waits
// re-run guards on query param changes too
runGuardsAndResolvers: 'paramsOrQueryParamsChange'