Week 11: Advanced Routing

Last week got you navigating. This week is about controlling that navigation — who's allowed where, what data needs to be ready before a route renders, and how much of your app's code actually has to load before the user sees anything at all.

Phase 3 of 7 Week 11 of 26 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Protect routes with functional guards
  • Pre-fetch data before a route renders, instead of showing an empty shell
  • Lazy-load routes and defer expensive UI so initial load stays fast

1. Functional Guards

A guard is a function the router calls before committing to a navigation. Return true to allow it, false to block it, or a UrlTree to redirect elsewhere entirely.

auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';

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

  if (auth.isLoggedIn()) {
    return true;
  }

  return router.createUrlTree(['/login'], {
    queryParams: { redirectTo: state.url },
  });
};
app.routes.ts
{
  path: 'projects/:projectId',
  component: ProjectDetail,
  canActivate: [authGuard],
}

This is exactly the inject() use case from Week 9 — authGuard is a plain function, no class, no constructor, so inject() is the only way it can reach AuthService and Router. CanDeactivate works the same way in reverse — it runs before leaving a route, commonly used to warn about unsaved changes. CanMatch is subtly different again: it decides whether a route even matches a URL in the first place, useful for showing an entirely different route (rather than just blocking one) based on a condition.

2. Resolvers for Pre-Fetching Route Data

Without a resolver, a component renders immediately with no data, then has to show its own loading state while it fetches. A resolver fetches the data before the navigation completes, so the component's inputs are already populated the instant it renders.

project.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { ProjectsService } from './projects.service';
import { Project } from './project.model';

export const projectResolver: ResolveFn<Project> = (route) => {
  const projects = inject(ProjectsService);
  return projects.getById(route.paramMap.get('projectId')!);
};
app.routes.ts
{
  path: 'projects/:projectId',
  component: ProjectDetail,
  resolve: { project: projectResolver },
}

The resolved value arrives as a route-data-bound input, same mechanism as withComponentInputBinding() from last week — add a project = input.required<Project>() to ProjectDetail and it's populated automatically, no manual subscription needed.

The tradeoff to know

A resolver delays the navigation itself — the URL doesn't change and no component renders until the resolver's data arrives. That's often the right feel for critical data, but for anything slow or non-essential, letting the component render immediately and show its own loading state (with resource(), Week 16) usually feels faster to a user, even though the data technically arrives at the same time either way.

3. Lazy Loading with loadComponent & loadChildren

By default, every component your routes reference ships in the initial JavaScript bundle — even ones a user might never visit. loadComponent defers a route's component (and everything it imports) into its own chunk, downloaded only when that route is actually navigated to.

app.routes.ts — lazy-loaded route
export const routes: Routes = [
  { path: '', component: DashboardOverview },
  {
    path: 'settings',
    loadComponent: () =>
      import('./settings/settings').then((m) => m.Settings),
  },
];

For an entire feature area with its own nested routes, loadChildren lazy-loads a whole routes array at once:

app.routes.ts — lazy-loaded feature
{
  path: 'admin',
  loadChildren: () => import('./admin/admin.routes').then((m) => m.adminRoutes),
}

The dynamic import() is what tells the bundler to split this code into a separate file — not an Angular-specific mechanism, but the standard JavaScript feature Angular's build tooling is built to recognize and act on.

4. Preloading Strategies

Lazy loading alone means a user waits for the download the moment they click — a preloading strategy downloads lazy chunks after the initial app loads, quietly in the background, so navigation feels instant by the time they actually click.

app.config.ts
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withPreloading(PreloadAllModules)),
  ],
};

PreloadAllModules preloads everything, which is often the right default. For finer control — say, preloading only routes flagged in their data — you can write a custom PreloadingStrategy class instead, checking each route's data before deciding whether to preload it.

5. Deferrable Views with @defer

You saw @defer mentioned briefly in Week 4 — here's the full picture. @defer splits a piece of a template into its own lazy-loaded chunk, independent of routing entirely. It's for expensive components below the fold, or rarely-used UI you don't want in the initial bundle.

product-page.html
@defer (on viewport) {
  <app-related-products [productId]="product().id" />
} @placeholder {
  <div class="skeleton"></div>
} @loading (minimum 200ms) {
  <app-spinner />
} @error {
  <p>Couldn't load related products.</p>
}

on viewport is one of several triggers — others include on interaction (a click or keypress), on hover, on timer(5s), and on idle (the browser's idle callback). @placeholder shows before the trigger fires; @loading shows while the chunk downloads; @error covers a failed download. None of this requires a route — a single component on an otherwise simple page can defer its own heavy children.

6. Route-Level Code-Splitting Strategy

Putting Sections 3–5 together, a reasonable strategy for a real app looks like this:

  • Lazy-load every route except the one or two a user hits immediately after login
  • Preload everything (or at least high-traffic routes) once the initial app is interactive
  • Within a route's own template, @defer anything expensive that isn't immediately visible — a chart library, a rich-text editor, a modal's contents

You'll measure the real-world effect of this in Week 20 (Performance), using the bundle analysis tools introduced back in Week 2 — for now, the goal is simply having the vocabulary and the instinct for where to reach for each technique.

7. Hands-on Exercise

Hands-on

Guard, resolve, and lazy-load the Week 10 dashboard

Take last week's three-level dashboard and make it production-shaped.

Requirements:

  1. Add a fake AuthService (a signal you can toggle) and an authGuard protecting every route except a new /login route.
  2. Add a projectResolver that pre-fetches project data before ProjectDetail renders, and bind the resolved value to an input.
  3. Convert every top-level route (dashboard, projects, and the not-found page) to loadComponent.
  4. Enable withPreloading(PreloadAllModules) and confirm — via your browser's network tab — that lazy chunks load shortly after the initial app, not only on click.
  5. @defer (on viewport) one deliberately "heavy" component in ProjectOverview (a fake chart component is fine), with a skeleton @placeholder.
Hint

If the guard redirect loop never stops (login redirects back to a protected route, which redirects back to login), check that /login itself isn't accidentally covered by the guard too — it should be one of the few routes explicitly left unprotected.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What are the three possible return values from a CanActivateFn, and what does each one do?

true allows the navigation to proceed; false blocks it (the URL doesn't change and the user stays put); a UrlTree (from router.createUrlTree()) redirects the navigation to a different route entirely, such as a login page.

Q2

What's the practical tradeoff of pre-fetching data with a resolver vs. letting the component load its own data after rendering?

A resolver means the component never renders in an empty or loading state — but the navigation itself (URL change, any transition animation) waits until the data arrives, which can feel like a delay if the request is slow. Letting the component render immediately and manage its own loading state gets the user to a page faster, at the cost of the component needing to actually build a loading UI.

Q3

What's the difference between what loadComponent and @defer each lazy-load?

loadComponent operates at the routing layer — an entire route's component, loaded only on navigation to that URL. @defer operates inside a single template, independent of routing entirely — a piece of markup within an already-rendered component, loaded based on a trigger like viewport visibility or user interaction, whether or not routing is involved at all.

Q4

If every route is lazy-loaded and preloading is enabled with PreloadAllModules, what's actually different about the user's experience compared to no lazy loading at all?

The initial load is faster — only the code for the first route downloads up front, instead of the whole app. Everything else downloads shortly after, in the background, so by the time the user actually navigates elsewhere those chunks are usually already cached. The net effect: faster first paint, with little to no perceived slowdown on subsequent navigation — the best of both approaches, at the cost of some background bandwidth use right after load.