Angular – Guards, Resolvers and Lazy Loading

July 27, 20264 min readUpdated 8/21/2026

Two jobs that sound unrelated and are both configured on the route: keeping people out of pages they should not see, and keeping code out of bundles that do not need it.

CanActivate

export const authGuard: CanActivateFn = async (_route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  await auth.whenReady();

  return auth.isAuthenticated() ? true : signIn(router, state.url);
};

A guard runs before the route's component is created. That is the meaningful difference from React Router, where <ProtectedRoute> is itself a component: it has to render, read auth state, and then return a <Navigate> — which is why the React version needs an initialising spinner to avoid bouncing a valid admin on the frame before the token check finishes.

Here the guard simply awaits. A CanActivateFn may return a boolean, a UrlTree, or a promise or observable of either, so the router holds the navigation until the answer is known and the bad frame never exists.

Return a UrlTree, not a navigate()

function signIn(router: Router, returnUrl: string): UrlTree {
  return router.createUrlTree(['/login'], { queryParams: { returnUrl } });
}

Returning a UrlTree tells the router to replace this navigation, so pressing Back after signing in does not bounce the user straight back to the redirect. Calling router.navigate() instead starts a second navigation while the first is still being decided, which works and leaves a history entry you did not want.

The attempted URL rides along in the query string so the login page can send the user back where they were going.

Guard the parent

{
  path: 'admin',
  canActivate: [adminGuard],
  loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
},

The guard is on the parent, so every admin screen inherits it and a new tab cannot be added unprotected by accident.

⚠️ A guard is a usability control, not a security control. Anyone can edit client-side JavaScript. The real enforcement is the backend rejecting requests without a valid ADMIN token, and the app has API tests asserting exactly that.

CanDeactivate

The counterpart, on the way out. It is handed the live component instance, which is the whole point — only the component knows whether there is anything worth stopping for:

export const confirmLeaveGuard: CanDeactivateFn<ConfirmsNavigation> = (component) =>
  component.canDeactivate();

The component answers:

canDeactivate(): boolean | Promise<boolean> {
  if (this.paid() || !this.created()) return true;
  return new Promise<boolean>((resolve) => this.leaveResolver.set(resolve));
}

Two decisions in there are worth copying.

It guards the unpaid order, not a dirty form. Once checkout's step 1 POSTs, there is a real PENDING_PAYMENT row in the database holding that cart; walking away strands it. A half-typed form is not worth interrupting anyone for.

It returns a promise, which holds the navigation open while a modal asks. No window.confirm: that cannot be styled, freezes the tab, and cannot be driven through the UI by a test.

⚠️ And the trap: the guard runs during router.navigate(), so the "this navigation is fine" flag has to be set before the call, not after —

this.paid.set(true);
this.cart.clear();
await this.router.navigate(['/order-confirmation', orderId]);

— or the "abandon your order?" modal pops on the way to the success page.

Note also what CanDeactivate does not cover: a reload, a closed tab or a typed URL never reach the router. Guarding those needs a beforeunload listener, and browsers deliberately allow it to show only their own generic message.

The other two

CanMatch decides whether a route is even considered, so two routes can share a path and be selected by role — and, unlike CanActivate, it can prevent a lazy chunk being downloaded at all. Resolve fetches data before the route activates, trading a spinner for a slower navigation. Neither is in the pizza app, which is worth noticing: its lazy guard is cheap and its pages fetch their own data.

Lazy loading

loadComponent: () => import('./pages/home/home').then((m) => m.Home),

loadComponent is React.lazy — the route's code becomes its own chunk, fetched the first time someone navigates there. The difference is that Angular needs no <Suspense> wrapper: the router holds the navigation until the chunk lands.

loadChildren does the same for a whole route file. Everything under /admin is behind one, so six admin screens and NgRx and its effects live in a chunk only an admin ever downloads.

The customer pages are lazy too, and that is worth a word. In React they are static imports, because a route component there is just a function and code-splitting each one is a deliberate act with a <Suspense> cost. In Angular the split is one line per route and the router already handles the wait — so the cheap thing and the right thing are the same thing.

Check it worked

ng build lists the chunks by name. If a route you expected to be lazy is not in that list, something is importing it eagerly — usually a stray import of the component in a shared file, which defeats the split silently.

What is next

Forms: the two systems Angular offers, and which to reach for.