Mert Özen Further With Every Line
Frontend Angular — #14

Angular Guards and Lazy Loading: CanActivate, CanDeactivate, CanLoad and Resolve

Mert Özen Oct 31, 2026 8 min 2 views
Angular Guards and Lazy Loading: CanActivate, CanDeactivate, CanLoad and Resolve

We protect routes with class-based guards in Angular 12: CanActivate, CanDeactivate, CanLoad and Resolve. We lazy load a feature module with loadChildren and preload it with PreloadAllModules.

Version note: The code and commands in this post are for Angular 12.

The Guard at the Door

The router we saw earlier looked at the URL and put the right component on the screen. But it never cared who was allowed into which page. A signed-out user could type /admin into the address bar and the admin panel would open right up.

A guard is an attendant the router checks with before every navigation. Picture security at a concert: people with tickets get in, people without are turned away, and if you leave something half-done inside, someone asks "are you sure?" on your way out. In this post we'll meet four attendants: CanActivate, which checks the way in; CanDeactivate, which checks the way out; CanLoad, which decides whether a module gets downloaded at all; and Resolve, which prepares data before the page opens. Along the way we'll set up lazy loading, which splits the app into pieces and downloads only what's needed.

In Angular 12, guards are written as classes. We'll see how Angular changed this in the version posts later on.

CanActivate: May They Enter This Page?

Let's start with a simple AuthService. If there's a token in localStorage, it treats the user as signed in:

import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class AuthService {
  isLoggedIn(): boolean {
    return localStorage.getItem('token') !== null;
  }
}

Let's generate the guard with the CLI. The ng generate guard auth command asks which interfaces you want to implement; pick CanActivate from the list. Then edit the generated file like this:

import { Injectable } from '@angular/core';
import {
  ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree
} from '@angular/router';
import { AuthService } from './auth.service';

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {

  constructor(private authService: AuthService, private router: Router) { }

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree {
    if (this.authService.isLoggedIn()) {
      return true;
    }
    return this.router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
  }
}

A guard is really just a service. It's marked with @Injectable and gets its dependencies through the constructor. Before navigating, the router calls canActivate and decides based on what comes back. Return true and the page opens; return false and the navigation is cancelled, leaving the user where they were. Return a UrlTree and the user is redirected to that address.

Here we send signed-out users to /login and attach the address they wanted as a returnUrl query param. After a successful sign-in, the login page can take them back to where they left off. Returning a UrlTree is cleaner than calling router.navigate inside the guard; the router handles the redirect itself, in the right order. When you need to ask the server, the method can also return an Observable or a Promise.

Attaching the guard to a route takes a single line:

{ path: 'admin', component: AdminComponent, canActivate: [AuthGuard] }

CanDeactivate: Unsaved Changes

A user fills in half of the profile form and accidentally clicks another link in the menu. Everything they typed is gone in an instant. CanDeactivate prevents exactly that. First, let's track in the component whether anything has changed:

export class ProfileEditComponent {
  hasUnsavedChanges = false;

  save(): void {
    this.hasUnsavedChanges = false;
  }
}
<input (input)="hasUnsavedChanges = true" placeholder="Display name">
<button (click)="save()">Save</button>

And the guard itself:

import { Injectable } from '@angular/core';
import { CanDeactivate } from '@angular/router';
import { ProfileEditComponent } from './profile-edit/profile-edit.component';

@Injectable({ providedIn: 'root' })
export class UnsavedChangesGuard implements CanDeactivate<ProfileEditComponent> {

  canDeactivate(component: ProfileEditComponent): boolean {
    if (!component.hasUnsavedChanges) {
      return true;
    }
    return confirm('You have unsaved changes. Are you sure you want to leave this page?');
  }
}
{ path: 'profile/edit', component: ProfileEditComponent, canDeactivate: [UnsavedChangesGuard] }

CanDeactivate is a generic interface. The type in angle brackets is the type of the component being left. The router hands that very component to the guard, and we look at its hasUnsavedChanges field to decide. If you want to reuse the same guard across several components, write an interface that declares a shared canDeactivate() method and pass that as the generic type.

It has one limit: this guard only runs for navigation inside the app. If the user closes the tab or refreshes the page, the router never gets involved; for that you also need to look at the browser's beforeunload event.

Resolve: Don't Open the Page Until the Data Is Ready

The product detail page opens, looks empty for a moment, then fills in when the data arrives. Most of the time that's acceptable. Sometimes, though, you don't want the page to open at all until its data is ready. Like a hotel that won't hand over the key until the room is cleaned. For that we write a resolver; the CLI equivalent is ng generate resolver product:

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve } from '@angular/router';
import { Observable } from 'rxjs';
import { Product, ProductService } from './product.service';

@Injectable({ providedIn: 'root' })
export class ProductResolver implements Resolve<Product> {

  constructor(private productService: ProductService) { }

  resolve(route: ActivatedRouteSnapshot): Observable<Product> {
    const id = Number(route.paramMap.get('id'));
    return this.productService.getProduct(id);
  }
}
{ path: 'products/:id', component: ProductDetailComponent, resolve: { product: ProductResolver } }

The resolve object in the route definition says "run ProductResolver before entering this route and store the result under the name product." I'm assuming ProductService.getProduct fetches a single product with the HttpClient we saw earlier. On the component side, the data is waiting in the route's data observable:

export class ProductDetailComponent implements OnInit {
  product?: Product;

  constructor(private route: ActivatedRoute) { }

  ngOnInit(): void {
    this.route.data.subscribe(data => this.product = data.product);
  }
}

The router holds the navigation until the observable returned by the resolver completes. An HttpClient request completes after its single response, so that's fine. But return a stream that never completes and the page never opens. If the request fails, the navigation is cancelled, so setting up a fallback with catchError in the resolver is a good idea. Keep in mind that while it waits, the user sits on the old page as if nothing is happening. With a slow API, that feeling can be worse than an empty page.

Lazy Loading: Not Downloading Everything Up Front

By default, all components and modules are bundled into one big JavaScript package. Everyone who visits your app also downloads the code for an admin panel that only a handful of administrators use. As the app grows, the first load gets slower.

Lazy loading splits that package. A feature module is moved into a separate file, a separate chunk, and is downloaded only when the user first visits that section's address. Think of an encyclopedia; you don't carry every volume on your back, you take the one you need off the shelf. The unit of lazy loading is the feature module we saw earlier; we lazy load a module and its routes, not a single component.

Lazy Loading a Feature Module

The CLI sets up a lazy-loaded module with a single command:

ng generate module admin --route admin --module app.module

This command creates the module, its routing file and an AdminComponent in the admin folder, then adds this line to app-routing.module.ts:

const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'admin', loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule) }
];

loadChildren takes a function, and that function returns a dynamic import(). When the build tool sees this import(), it pulls the admin module out of the main bundle and puts it in its own chunk. When the user goes to /admin, the router calls the function, downloads the file and loads AdminModule.

The admin module's own routing file looks like this:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AdminComponent } from './admin.component';

const routes: Routes = [{ path: '', component: AdminComponent }];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class AdminRoutingModule { }

This one uses forChild, not forRoot. forChild doesn't create a new Router service; it only adds this module's routes to the existing router. The paths here are relative to admin as well: the empty path means /admin, and if you add { path: 'users', ... }, that becomes /admin/users.

A crucial warning: don't add AdminModule to the imports array of AppModule. If you do, the module goes back into the main bundle and lazy loading silently breaks. The CLI never adds it, but it's easy to slip in while editing by hand. If the ng build output shows a separate table titled Lazy Chunk Files below the files loaded on startup, you're on the right track. In the browser's Network tab, you can also watch that file download only when you go to /admin.

CanLoad: Not Downloading the Module at All

If you put canActivate on a lazy route, signed-out users can't see the admin page, but the router still downloads the module. The code has already made it to the user's browser. To prevent that entirely, we use CanLoad. Let's add a second interface to the existing AuthGuard:

import { Injectable } from '@angular/core';
import {
  ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router,
  RouterStateSnapshot, UrlSegment, UrlTree
} from '@angular/router';
import { AuthService } from './auth.service';

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate, CanLoad {

  constructor(private authService: AuthService, private router: Router) { }

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree {
    return this.authService.isLoggedIn() || this.router.createUrlTree(['/login']);
  }

  canLoad(route: Route, segments: UrlSegment[]): boolean | UrlTree {
    return this.authService.isLoggedIn() || this.router.createUrlTree(['/login']);
  }
}
{
  path: 'admin',
  loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule),
  canLoad: [AuthGuard],
  canActivate: [AuthGuard]
}

canLoad runs before the module is downloaded. If it returns false or a UrlTree, the chunk is never requested. So why keep canActivate too? Because once the module has loaded, canLoad never runs again. If the user signs out and tries to go back to /admin, canActivate is what stops them.

Preloading: Putting Idle Time to Work

Lazy loading speeds up the first load, but it comes at a small cost: the first time users enter a section, they wait for its chunk to download. Preloading finds the middle ground:

import { PreloadAllModules, RouterModule } from '@angular/router';

@NgModule({
  imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })],
  exports: [RouterModule]
})
export class AppRoutingModule { }

With PreloadAllModules, the app first downloads only what the initial screen needs and opens. Then, while the user is looking at the screen, it quietly downloads the lazy modules in the background. By the time the user clicks into a section, the code is already there. The default strategy is to preload nothing. There's one exception: routes protected by canLoad are not preloaded, since we put a permission check on downloading them in the first place. If you want to choose which modules get preloaded, you can write your own strategy by implementing the PreloadingStrategy interface.

A Small Experiment

Your turn. Create a lazy module with ng g module dashboard --route dashboard --module app.module. Run it with ng serve, open the Network tab, and watch a new JavaScript file download the first time you go to /dashboard.

Next, set up the AuthService and AuthGuard from above. From the console, pretend to sign in with localStorage.setItem('token', 'test') and sign out with localStorage.removeItem('token'). Add canLoad to the dashboard route and confirm that the file never downloads while you're signed out. Finally, turn on PreloadAllModules and watch an unprotected lazy module download on its own right after the page opens.

In the profile edit example, we tracked by hand whether anything had been typed into the input. A real form needs more than that: collecting field values, checking required fields and email format, and showing clear error messages to the user. In the next post we'll talk about template-driven forms in Angular.