Route guards are functions that run before Angular completes a navigation — they can allow, redirect, or cancel the navigation. Angular 15+ uses simple functional guards rather than class-based implementations, making them easier to write and test. The auth guard protects private routes by checking whether the user is logged in. The deactivate guard prevents accidental form abandonment. The resolve guard pre-fetches data before the component renders, avoiding skeleton/loading states for critical page data.
Functional Route Guards
import { CanActivateFn, CanDeactivateFn, ResolveFn, Router } from '@angular/router';
import { inject } from '@angular/core';
// ── Auth guard — redirect to login if not authenticated ───────────────────
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isLoggedIn()) return true;
// Store the attempted URL so we can redirect after login
return router.createUrlTree(['/auth/login'], {
queryParams: { returnUrl: state.url }
});
};
// ── Admin guard — require Admin role ──────────────────────────────────────
export const adminGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.hasRole('Admin')) return true;
// Authenticated but not admin — show forbidden page
return router.createUrlTree(['/forbidden']);
};
// ── Unsaved changes guard ─────────────────────────────────────────────────
export interface HasUnsavedChanges {
hasUnsavedChanges(): boolean;
}
export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) => {
if (!component.hasUnsavedChanges()) return true;
return confirm('You have unsaved changes. Leave anyway?');
// Returning false cancels navigation and stays on the current route
};
// Apply to a form component:
// { path: 'posts/:id/edit', component: PostEditComponent,
// canDeactivate: [unsavedChangesGuard] }
// ── Resolve guard — pre-fetch data before component renders ───────────────
export const postResolver: ResolveFn<PostDto> = (route) => {
const api = inject(PostsApiService);
const router = inject(Router);
const slug = route.paramMap.get('slug')!;
return api.getBySlug(slug).pipe(
catchError(() => {
router.navigate(['/not-found']);
return EMPTY; // cancel the navigation
}),
);
};
// ── Route with resolver — data available synchronously in component ───────
// { path: 'posts/:slug', component: PostDetailComponent,
// resolve: { post: postResolver } }
// In the component:
// @Component({ standalone: true, template: '...' })
// export class PostDetailComponent implements OnInit {
// @Input() post!: PostDto; // withComponentInputBinding maps resolve data to @Input
// }
// ── Combining guards ───────────────────────────────────────────────────────
// { path: 'admin/posts', canActivate: [authGuard, adminGuard] }
// Both guards must return true; if either returns false/UrlTree, navigation stops
inject() to access services — this works because Angular sets up an injection context when running guards. The guard function is called within the router’s injection context, making all DI tokens available. You do not need to pass services as parameters; just call inject(ServiceName) at the top of the guard function. This is significantly simpler than the class-based approach which required implementing an interface and providing the guard class.UrlTree from a guard instead of calling router.navigate() directly. Returning a UrlTree (router.createUrlTree(['/login'])) integrates correctly with Angular’s navigation lifecycle — the router cancels the current navigation and starts a new one to the returned URL, without creating nested navigation calls. Calling router.navigate() inside a guard while a navigation is in progress can cause race conditions and unexpected behaviour in some Angular versions.canDeactivate guard with confirm() blocks the browser’s event loop — use it only as a simple fallback. Production applications should replace the browser’s built-in confirm() dialog with a custom Angular Material dialog that is non-blocking. The blocking confirm() prevents Angular’s change detection from running, which can cause visual glitches. Use a Subject-based approach: show the dialog, return an Observable that emits true/false when the user responds.Common Mistakes
Mistake 1 — Using router.navigate() inside a guard instead of returning UrlTree
❌ Wrong — calling router.navigate(['/login']) inside a guard creates nested navigation; may cause issues.
✅ Correct — return router.createUrlTree(['/login']) from the guard function.
Mistake 2 — Forgetting to handle resolve errors (navigation hangs if API fails)
❌ Wrong — resolver throws; navigation never completes; user sees blank page with spinner.
✅ Correct — use catchError(() => { router.navigate(['/not-found']); return EMPTY; }) in resolvers.