Skip to main content

6 min read #angular #routing #guards #resolvers #frontend

I deleted my CanActivate classes and my routing got readable

Route guards became plain functions, and the @Injectable ceremony went with them. The CanActivateFn / canMatch / ResolveFn trio, when each one is the right tool, and the redirect pattern that beats returning false.

For years a route guard meant a class: @Injectable, implements CanActivate, a constructor full of services, and an entry in some providers array you'd inevitably forget. The guard was usually four lines of actual logic wrapped in twenty lines of ceremony. Functional guards deleted the wrapper, and the same inject() move that cleaned up interceptors did it again here.

export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);
  return auth.isLoggedIn() || router.createUrlTree(["/login"], {
    queryParams: { returnUrl: state.url },
  });
};

That's the whole thing. No class, no provider registration — you reference the function directly in the route. But the syntax win is the boring part. The interesting part is that going functional made the three jobs guards do finally feel like three different tools instead of one overloaded interface.

Return a UrlTree, not false

The most common guard bug I find in review isn't a logic error — it's a guard that returns false. Returning false cancels the navigation and leaves the user exactly where they were, with no explanation. They clicked a link, nothing happened, and now they think the app is broken. Half the time they're sitting on a blank shell because the click came from a fresh page load.

Returning a UrlTree is almost always what you meant: it cancels the original navigation and redirects in one step. Blocked from a page → send them to /login with a returnUrl so you can bounce them back after they sign in. The rule I use: a guard should either say yes, or say where to go instead. false is for the rare case where staying put with a toast is genuinely the right UX, and even then I'd rather show the toast and redirect.

canActivate vs canMatch — they answer different questions

These get used interchangeably and they shouldn't be. canActivate asks "this route matched the URL — is the user allowed to land on it?" It runs after the router has picked the route. canMatch asks the earlier question: "does this route even apply?" It runs during route matching, before the component is chosen.

That timing difference is the whole point. With canMatch you can have two routes for the same path and let the guard decide which one wins:

const routes: Routes = [
  { path: "dashboard", component: AdminDashboard, canMatch: [isAdmin] },
  { path: "dashboard", component: UserDashboard },
];

Admins match the first route, everyone else falls through to the second. You can't express that with canActivate — by the time it runs, the route is already locked in. The other canMatch superpower: on a lazy route, a failed canMatch means the chunk never even downloads. Guarding a whole admin feature behind canActivate still ships its JavaScript to anonymous visitors; canMatch keeps it on the server.

Small guards compose; god-guards don't

Because canActivate takes an array, I stopped writing guards that check three things and started writing three guards that check one thing each:

{ path: "billing", component: Billing,
  canActivate: [isLoggedIn, hasBillingRole, orgIsActive] }

They run in order and the first one to return a UrlTree wins, so the redirects naturally prioritize: not logged in beats wrong role beats suspended org. Each guard is independently testable and reusable across routes. A single guard doing all three reads fine the day you write it and becomes a tangle of nested conditions the third time someone adds a rule.

Resolvers: powerful, and the easiest way to make your app feel frozen

ResolveFn got the same functional treatment, and it's genuinely useful — but it's the guard-family tool I reach for least, on purpose. A resolver fetches data before the route activates, so the component renders with everything already present. No loading spinner inside the page, no flash of empty state.

export const orderResolver: ResolveFn<Order> = (route) => {
  const id = route.paramMap.get("id")!;
  return inject(OrderService).getOrder(id);
};

The catch is in that word "before." Navigation blocks on the resolver. If the API takes two seconds, clicking the link does nothing visible for two seconds — the user is stuck on the previous page with no feedback. You've moved the loading state from "a spinner on the new page" to "the entire app appears to hang." If you use resolvers, you almost have to wire a progress bar to the router's NavigationStart/NavigationEnd events, or the UX is worse than just fetching in the component.

So my line is: a resolver earns its place when the page is genuinely meaningless without the data — an order detail page with no order isn't a page, it's a 404 waiting to happen, and resolving lets you redirect on a missing record before anything renders. For everything else, fetching in the component with httpResource gives you the same data with an honest, local loading state and no global freeze.

The decision rule

Deciding whether the route applies, or want to skip downloading a lazy chunk → canMatch. Deciding whether this user may enter a route that does apply → canActivate, returning a UrlTree when the answer is no. The page is structurally meaningless without server data, and you've got a progress indicator → ResolveFn. Otherwise, let the component fetch its own data. All three are just functions now, which means all three are just unit tests now too.