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.