Angular NotesRohit’s interview study guide
Chapter 13

Routing

Mapping URLs to components — route config, params, navigation, nested and lazy routes, guards and resolvers.

12 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Configuring routes

A single-page app has one HTML page, but users still expect URLs: they bookmark /users/2, press Back, share a link. The router keeps the URL and the screen in sync. It watches the address bar, finds the route that matches, and puts that route's component on the page; and when your code or a link asks for a new URL, it does the same in reverse and updates the address bar.

The mental model is a lookup table read from top to bottom. Your Routes array is a list of path → component rows. On every navigation the router walks the list, takes the first row that matches, and stops. Everything else in this chapter — params, nesting, lazy loading, guards — is extra columns on those rows.

A navigation is not instant, though. It's a small pipeline: match the URL against the table, run the guards, run the resolvers, then swap the components. That pipeline is what lets a guard redirect to /login before anything renders.

app.routes.ts
import { Routes } from '@angular/router';
import { authMatchGuard } from './routing/guards';
import { Home, Login, NotFound } from './routing/pages';
import { UserInput } from './routing/users';

export const routes: Routes = [
  { path: '', redirectTo: 'home', pathMatch: 'full' },
  { path: 'home', component: Home, title: 'Home' },
  { path: 'login', component: Login },
  { path: 'users/:id', component: UserInput },
  // One lazy component: the file is only downloaded when /reports is first visited.
  { path: 'reports', loadComponent: () => import('./routing/lazy/reports') },
  // A lazy route tree: the guard runs first, and the chunk is fetched only if it passes.
  {
    path: 'admin',
    canMatch: [authMatchGuard],
    loadChildren: () => import('./routing/lazy/admin.routes'),
  },
  { path: '**', component: NotFound },
];
AdvancedRoute table walkthrough in app.config.ts
app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes, withComponentInputBinding())],
};
What’s happening
  1. routes is just an array of plain objects typed as Routes (Route[]). Nothing happens when this file loads — it's data. Paths never start with /; the router rejects path: '/home' with an NG04014 "path cannot start with a slash" error.
  2. provideRouter(routes, …) in appConfig registers the Router service and hands it the table. It runs once at bootstrap (see appConfig and application providers). The extra arguments are features: withComponentInputBinding() lets the router write params into component inputs (see Route parameters); others include withRouterConfig, withPreloading, withHashLocation, withViewTransitions.
  3. When the browser asks for /, the router reads the table top to bottom. Row 1 matches the empty path exactly (pathMatch: 'full', see Route order, wildcards and pathMatch), so it redirects to /home and starts matching again. Row 2 now matches, so Home is created and its title becomes document.title (verified in a test: document.title was 'Home page' for title: 'Home page').
  4. /users/7 skips the first three rows and matches users/:id, with id = '7'. /reports and /admin point at code that isn't in the main bundle yet; see Lazy loading.
  5. Anything else — /nope/deeper — falls through to **, the wildcard, which is why it must be last.
AdvancedRouter events for one navigation

Every navigation goes through the same phases, and the router publishes an event for each one on router.events. I logged them for a navigation to a route with one guard and one resolver:

Output
NavigationStart → RoutesRecognized → GuardsCheckStart → ChildActivationStart → ActivationStart
→ GuardsCheckEnd → ResolveStart → ResolveEnd → ActivationEnd → ChildActivationEnd → NavigationEnd
What’s happening
  1. NavigationStart: a navigation was requested (link click, router.navigate, Back button, or the first page load).
  2. RoutesRecognized: the URL was matched against the table and the router now has a snapshot of the future route tree, with params already parsed.
  3. GuardsCheckStart … GuardsCheckEnd: canDeactivate guards on the routes being left, then canActivateChild/canActivate on the routes being entered. Any guard can cancel or redirect here (see Route guards).
  4. ResolveStart … ResolveEnd: resolvers fetch data; the router waits for them (see Resolvers and route data).
  5. ActivationEnd … NavigationEnd: components are created or reused, the outlet is updated and the URL in the address bar is committed. If something goes wrong you get NavigationCancel (a guard said no or redirected) or NavigationError (no route matched, a resolver threw) instead of NavigationEnd.
AdvancedOlder syntax: AppRoutingModule vs provideRouter

From my notes, techStatus (Angular 16) did the same thing with NgModules. The table is identical; only the wiring differs.

app-routing.module.ts (Angular 16, techStatus)
@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {}

// a feature module registers its own rows
@NgModule({
  imports: [RouterModule.forChild(adminRoutes)],
})
export class AdminRoutingModule {}
app.config.ts (Angular 22)
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
  ],
};

// a lazy feature just exports its routes
export default [
  { path: '', component: AdminLayout, children: [/* … */] },
] satisfies Routes;
AdvancedWhat forRoot and forChild did

RouterModule.forRoot provided the Router once for the app; forChild added routes without creating a second router. exports: [RouterModule] was what made <router-outlet> and routerLink usable in every template declared in AppModule. In standalone Angular, provideRouter replaces forRoot, a lazy routes file replaces forChild modules, and each component imports RouterOutlet/RouterLink itself (next topic). See NgModules: the old way.

#Route parameters

A route parameter is a placeholder segment in a path: users/:id matches /users/1, /users/2, /users/new, and the router hands you id as a string. The question my notes kept coming back to is how you read it, because there are three ways and one of them has a classic bug.

The thing to understand first is component reuse. When you navigate from /users/1 to /users/2, both URLs match the same row with the same component. The router doesn't destroy UserDetail and build a new one; it keeps the existing instance and just updates its ActivatedRoute. That's efficient, but it means any code that read the param once (in the constructor or ngOnInit) never runs again.

The three ways, oldest first:

  • route.snapshot.paramMap.get('id') — a one-time read of the current value.
  • route.paramMap — an Observable that emits the params every time they change.
  • Component input binding — with withComponentInputBinding(), the router sets an input() called id for you, and keeps setting it.
users.ts
// ❌ The stale-snapshot bug: reads the param once, in the constructor.
@Component({
  selector: 'app-user-snapshot',
  imports: [RouterLink],
  template: `<h2>User {{ id }}</h2>
    <a routerLink="/users/2">Next user</a>`,
})
export class UserSnapshot {
  static created = 0;
  private route = inject(ActivatedRoute);
  id = this.route.snapshot.paramMap.get('id');
  constructor() {
    UserSnapshot.created++;
  }
}

// ✅ Reactive: paramMap is an Observable that emits on every param change.
@Component({
  selector: 'app-user-reactive',
  template: `<h2>User {{ id() }}: {{ user()?.name }}</h2>`,
})
export class UserReactive {
  private route = inject(ActivatedRoute);
  id = toSignal(this.route.paramMap.pipe(map((p) => Number(p.get('id')))), {
    initialValue: 0,
  });
  user = computed(() => USERS.find((u) => u.id === this.id()));
}

// ✅ Modern: the router writes the param into a signal input.
@Component({
  selector: 'app-user-input',
  template: `<h2>User {{ id() }}: {{ user()?.name }}</h2>`,
})
export class UserInput {
  id = input.required({ transform: numberAttribute });
  user = computed(() => USERS.find((u) => u.id === this.id()));
}
AdvancedSpec: snapshot vs reactive vs input params
params.spec.ts
it('snapshot goes stale when only the param changes', async () => {
  TestBed.configureTestingModule({
    providers: [provideRouter([{ path: 'users/:id', component: UserSnapshot }])],
  });
  const harness = await RouterTestingHarness.create();

  const first = await harness.navigateByUrl('/users/1', UserSnapshot);
  expect(harness.routeNativeElement?.querySelector('h2')?.textContent).toBe('User 1');

  // click the "Next user" link, like a real user would
  harness.routeNativeElement!.querySelector('a')!.click();
  await harness.fixture.whenStable();

  expect(TestBed.inject(Router).url).toBe('/users/2'); // the URL changed...
  expect(harness.routeDebugElement?.componentInstance).toBe(first); // ...but it is the SAME instance
  expect(UserSnapshot.created).toBe(1);
  expect(harness.routeNativeElement?.querySelector('h2')?.textContent).toBe('User 1'); // stale!
});

it('component input binding keeps up', async () => {
  TestBed.configureTestingModule({
    providers: [provideRouter([{ path: 'users/:id', component: UserInput }], withComponentInputBinding())],
  });
  const harness = await RouterTestingHarness.create();
  const cmp = await harness.navigateByUrl('/users/1', UserInput);
  expect(cmp.id()).toBe(1); // a number, thanks to numberAttribute
  await harness.navigateByUrl('/users/2');
  expect(cmp.id()).toBe(2);
  expect(harness.routeNativeElement?.textContent).toBe('User 2: Linus');
});
What’s happening
  1. Snapshot, first visit. Navigating to /users/1 creates UserSnapshot (created goes 0 → 1). Its field initializer reads snapshot.paramMap.get('id') → '1' — a string, frozen into the id field. The page shows User 1.
  2. Click "Next user". The URL becomes /users/2. Same row, same component type, so the router reuses the instance: the test proves it with toBe(first) and created is still 1. The router did update the ActivatedRoute — but nothing re-reads it, so id is still '1' and the heading still says User 1. That's the bug: correct URL, wrong user on screen.
  3. Reactive. UserReactive subscribes to route.paramMap through toSignal. The Observable emits { id: '1' }, then { id: '2' } after the second navigation, so the id signal goes 1 → 2, user() recomputes, and the page shows User 2: Linus — still one instance. In older code you'd see this.route.paramMap.subscribe(p => …) in ngOnInit, which works the same way but needs manual unsubscribing (see The async pipe and toSignal()).
  4. Input binding. With withComponentInputBinding(), the router matches the param name id to the input named id and calls the equivalent of setInput('id', '2') on every navigation. transform: numberAttribute turns the string into a number, so cmp.id() is 1, then 2. This is the one I'd use in new code: no ActivatedRoute, no subscription, and the component is trivially testable with setInput.
  5. Forget the feature and it fails loudly. The same UserInput route without withComponentInputBinding() throws when it renders: "NG0950: Input "id" is required but no value is available yet." That's the router not setting anything — the input is just a normal input with no parent binding.
AdvancedEdge case: string params and NaN

Params are always strings. With id = input.required({ transform: numberAttribute }), /users/new gives NaN, not an error — the route-order test below rendered "User NaN". My notes called the missing transform a hidden bug in my own implementation; the full story is in Required inputs and input transforms and Route parameters as component inputs.

AdvancedSafe uses of the route snapshot
Which user does the page show?

A UserDetail component reads this.route.snapshot.paramMap.get('id') in ngOnInit and loads that user. You are on /users/1 and click a routerLink to /users/2. What do you see, and how many times did ngOnInit run?

Show answer

You still see user 1, and ngOnInit ran once.

What’s happening
  1. /users/1 matches users/:id; the router creates UserDetail, ngOnInit runs and reads '1'.
  2. /users/2 matches the same route config with the same component. The default RouteReuseStrategy reuses the instance, so no constructor and no ngOnInit.
  3. The ActivatedRoute object's observables (paramMap, params) emit the new value, and its snapshot property is replaced — but your code read the old snapshot once and isn't listening.
  4. Fix: read params reactively (paramMap → switchMap to load), or switch to id = input.required(...) with withComponentInputBinding() and derive the user from the input.

#Query parameters

Query parameters are the ?key=value part of the URL: /search?q=signals&page=2. Unlike route params they are not part of matching — /search and /search?q=x hit the same row — and they're optional by nature. Use them for state that shapes a view without identifying a resource: search terms, filters, sort order, page numbers, an open tab. A useful rule: if removing it should still show something on the same page, it's a query param; if the page makes no sense without it, it's a route param.

You read them the same three ways as route params: route.snapshot.queryParamMap, route.queryParamMap (Observable), or inputs with withComponentInputBinding(). My notes put it as paramMap for the path, queryParamMap for the query string.

search.ts
@Component({
  selector: 'app-search',
  template: `
    <p class="q">Searching for "{{ q() }}", page {{ page() }}</p>
    <button (click)="nextPage()">Next page</button>
  `,
})
export class Search {
  private route = inject(ActivatedRoute);
  private router = inject(Router);

  private params = toSignal(this.route.queryParamMap, { requireSync: true });
  q = computed(() => this.params().get('q') ?? '');
  page = computed(() => Number(this.params().get('page') ?? 1));

  nextPage() {
    return this.router.navigate([], {
      relativeTo: this.route,
      queryParams: { page: this.page() + 1 },
      queryParamsHandling: 'merge',
    });
  }
}
AdvancedSpec: query params and queryParamsHandling
params.spec.ts
const cmp = await harness.navigateByUrl('/search?q=signals&page=1', Search);
expect(harness.routeNativeElement?.querySelector('.q')?.textContent).toBe('Searching for "signals", page 1');

const ok = await cmp.nextPage();
harness.detectChanges();
expect(ok).toBe(true);
expect(TestBed.inject(Router).url).toBe('/search?q=signals&page=2');

// without queryParamsHandling, the existing params are dropped:
await router.navigate(['/search'], { queryParams: { page: 2 } });
expect(router.url).toBe('/search?page=2');
What’s happening
  1. toSignal(this.route.queryParamMap, { requireSync: true }) turns the Observable into a signal. requireSync is safe because ActivatedRoute observables are backed by a BehaviorSubject and emit their current value immediately on subscribe — and if one didn't, toSignal would throw instead of giving you undefined.
  2. q and page are computeds over that signal. get() returns string | null, so the ?? '' and ?? 1 defaults handle a missing key, and Number(...) converts the string. For ?q=signals&page=1 the page reads Searching for "signals", page 1.
  3. nextPage() navigates to [] — "the current route" — relativeTo: this.route, with { page: 2 }. queryParamsHandling: 'merge' keeps q=signals and overwrites page, giving /search?q=signals&page=2. The promise resolves true, the route is reused, queryParamMap emits, and the computeds update the text.
  4. Without queryParamsHandling, the new queryParams replace the old ones completely: /search?page=2 — the search term is gone. The other option, 'preserve', keeps the current query params and ignores the new ones (useful on a link that should carry ?lang=fr forward).
  5. Both navigations stay on the same component, so this is the same reuse story as route params — reading snapshot.queryParamMap once would go stale exactly the same way.
AdvancedPitfall: input defaults with route binding

#Route order, wildcards and pathMatch

My notes say "route order matters, so put specific routes first" and flag pathMatch as very important. Both come from how matching works. The router walks the array in order and takes the first route that can consume the URL. It doesn't look for the best match — there's no scoring. So a general pattern above a specific one shadows it forever.

Two special paths:

  • '**' (wildcard) matches any URL. It's your 404 page and must be the last row; anything below it is unreachable.
  • '' (empty path) matches zero segments, which is where pathMatch comes in.

pathMatch decides how much of the URL a route's path has to cover. 'prefix' (the default) means "the URL starts with this path" — any remaining segments are left for child routes or, for a redirect, carried along. 'full' means "this path is the entire remaining URL". For normal paths the difference only shows on redirects; for '' it's everything, because every URL starts with the empty string.

order.spec.ts
it(':id first swallows users/new', async () => {
  const harness = await setup([
    { path: 'users/:id', component: UserInput },
    { path: 'users/new', component: NewUser },
  ]);
  await harness.navigateByUrl('/users/new');
  expect(harness.routeNativeElement?.textContent).toContain('User NaN');
});

it('specific first works', async () => {
  const harness = await setup([
    { path: 'users/new', component: NewUser },
    { path: 'users/:id', component: UserInput },
  ]);
  await harness.navigateByUrl('/users/new');
  expect(harness.routeNativeElement?.textContent).toBe('New user form');
  await harness.navigateByUrl('/users/2');
  expect(harness.routeNativeElement?.textContent).toBe('User 2: Linus');
});

it('wildcard first swallows everything', async () => {
  const harness = await setup([
    { path: '**', component: NotFound },
    { path: 'about', component: About },
  ]);
  await harness.navigateByUrl('/about');
  expect(harness.routeNativeElement?.textContent).toBe('Page not found');
});
What’s happening
  1. setup() in this spec configures provideRouter(routes, withComponentInputBinding()) and returns RouterTestingHarness.create() — a test root with a <router-outlet> (see Testing routes and guards).
  2. :id first. /users/new is checked against users/:id first. :id matches any single segment, including new, so the router stops there with id = 'new'. numberAttribute('new') is NaN, so the page says User NaN: — and the NewUser row is dead code. No error, no warning.
  3. Specific first. Now users/new is checked first and only matches the literal new. /users/2 fails that row and falls to users/:id. Both pages work.
  4. Wildcard first. ** matches /about before about is even looked at, so every URL shows "Page not found". With the wildcard last, /about showed About, and /nope/deeper showed "Page not found" while router.url stayed /nope/deeper — the wildcard renders a component, it doesn't redirect.
AdvancedpathMatch and the redirect mistake

Now pathMatch, including the classic redirect mistake:

order.spec.ts
// 1. No pathMatch on an empty-path redirect: rejected at startup
provideRouter([{ path: '', redirectTo: 'home' }, { path: 'home', component: Home }]);
TestBed.inject(Router);
// NG04014: Invalid configuration of route '{path: "", redirectTo: "home"}': please provide 'pathMatch'.
// The default value of 'pathMatch' is 'prefix', but often the intent is to use 'full'.

// 2. Forcing 'prefix': every URL starts with ''
{ path: '', redirectTo: 'home', pathMatch: 'prefix' },
{ path: 'home', component: Home },
{ path: 'about', component: About },
{ path: '**', component: NotFound },
await harness.navigateByUrl('/about');
expect(TestBed.inject(Router).url).toBe('/home/about');
expect(harness.routeNativeElement?.textContent).toBe('Page not found');

// 3. 'full': only the bare / redirects
{ path: '', redirectTo: 'home', pathMatch: 'full' },
await harness.navigateByUrl('/');      // → /home
await harness.navigateByUrl('/about'); // → /about, shows About

// 4. A prefix redirect carries the rest of the URL along
{ path: 'old', redirectTo: 'users' },
await harness.navigateByUrl('/old/2'); // → /users/2, "User 2: Linus"
// with pathMatch: 'full', /old/2 doesn't match the redirect → "Page not found"
What’s happening
  1. The guard rail. An empty-path redirect without pathMatch is such a common mistake that the router refuses it: in dev mode, creating the Router threw NG04014 with the message above. It's a runtime check during config validation, not a compile error, so you see it the first time the app (or a test) starts.
  2. The mistake itself. Set pathMatch: 'prefix' explicitly (or meet it in an old Angular version without the check) and '' matches the start of every URL. /about hits row 1 first, which redirects by replacing the matched part (nothing) with home and keeping the rest: /home/about. Matching restarts, home/about has no row, and the wildcard shows "Page not found". The user asked for About and got a 404 at a URL they never typed.
  3. The fix. With 'full', '' only matches when nothing is left — the bare /. That one redirects to /home; /about skips row 1 and shows About. This is the line you write in almost every app.
  4. Prefix is useful for redirects of old URLs. { path: 'old', redirectTo: 'users' } turned /old/2 into /users/2 — the leftover 2 was carried over. With 'full', /old/2 is longer than old, so the redirect doesn't apply and the URL falls through to the wildcard.
  5. Not a problem for component routes. A myth worth killing: { path: '', component: Home } placed above about did not swallow /about in my test — it showed About. A component route without children has to consume the whole URL; when segments are left over, the router backs out and tries the next row. pathMatch matters on empty-path redirects (and on empty-path parents with children).

#Nested routes

Real apps have layouts: an admin area with its own header and side menu, where only the right-hand panel changes between Dashboard and Settings. Nested routes model that directly. A parent route has a component (the layout) and children; the layout's template has its own <router-outlet>, and the matched child renders into it.

The mental model is a tree of outlets. The URL /admin/settings is split across levels: admin is matched by the parent row and its component goes into the root outlet; settings is matched among the parent's children and its component goes into the layout's outlet. Navigating between children keeps the parent component alive — only the inner outlet changes.

admin-pages.ts
@Component({
  selector: 'app-admin-layout',
  imports: [RouterOutlet, RouterLink],
  template: `
    <h1>Admin</h1>
    <nav>
      <a routerLink="./">Dashboard</a>
      <a routerLink="settings">Settings</a>
    </nav>
    <router-outlet />
  `,
})
export class AdminLayout {}
AdvancedSpec: child routes in a layout
params.spec.ts
TestBed.configureTestingModule({
  providers: [
    provideRouter([
      {
        path: 'admin',
        component: AdminLayout,
        children: [
          { path: '', component: AdminDashboard },
          { path: 'settings', component: AdminSettings },
        ],
      },
    ]),
  ],
});
const harness = await RouterTestingHarness.create();
await harness.navigateByUrl('/admin');
const el = harness.routeNativeElement!;
expect(el.querySelector('h1')?.textContent).toBe('Admin');
expect(el.querySelector('app-admin-dashboard')?.textContent).toBe('Dashboard');

await harness.navigateByUrl('/admin/settings');
expect(el.querySelector('app-admin-dashboard')).toBeNull();
expect(el.querySelector('app-admin-settings')?.textContent).toBe('Settings');
What’s happening
  1. /admin matches the parent row (admin, prefix) and leaves nothing. The router then looks for a child that matches the empty remainder: { path: '', component: AdminDashboard } — the default child. AdminLayout goes in the harness's root outlet; AdminDashboard goes in the layout's own outlet.
  2. harness.routeNativeElement is the AdminLayout element (the component in the top outlet). Inside it you find both the layout's <h1>Admin</h1> and <app-admin-dashboard>.
  3. /admin/settings matches admin again, then settings among the children. el is the same element as before — the layout wasn't recreated — but the inner outlet swapped AdminDashboard (now null) for AdminSettings.
  4. The links in the layout are relative to the layout's route: routerLink="settings" → /admin/settings, routerLink="./" → /admin. Move the whole tree under /backoffice and they still work.
  5. Without the '' child, /admin still works — I removed it and the navigation succeeded — but the layout rendered with an empty inner outlet (<router-outlet></router-outlet> and nothing after it). The default child is how you say "what shows at the parent URL"; the other common choice is { path: '', redirectTo: 'dashboard', pathMatch: 'full' }.
AdvancedReading parent params in child routes

Children can read the parent's params. With users/:id as the parent and posts as a child, the child component's id = input<string>() was '7' on /users/7/posts — in Angular 22 the default paramsInheritanceStrategy is 'always' (the router's .d.ts says so, and DEFAULT_PARAMS_INHERITANCE_STRATEGY = 'always' in its source). With the legacy setting, provideRouter(routes, withRouterConfig({ paramsInheritanceStrategy: 'emptyOnly' })), the same child got undefined: a child then inherits only when its own path is empty or the parent has no component. Older code that does route.parent.paramMap is a sign it was written for the old default.

AdvancedGuarding every child route

#Lazy loading

By default everything your routes import ends up in the initial bundle, downloaded before the first screen appears. An admin area that 5% of users visit still costs every user its bytes. Lazy loading moves a route's code into a separate file (a chunk) that the browser fetches the first time someone navigates there.

The mechanism is the dynamic import() expression. When the builder sees () => import('./reports'), it can't inline that file into the main bundle (it's only needed later), so it emits it as its own chunk and rewrites the call to fetch it. The router calls your loader function the first time it needs the route and caches the result.

  • loadComponent: () => import('./x') — lazy-load one standalone component.
  • loadChildren: () => import('./x.routes') — lazy-load a whole routes array (a feature area with its own children, guards and providers).
lazy/reports.ts
@Component({
  selector: 'app-reports',
  template: `<h1>Reports</h1><p>{{ rows.length }} rows</p>`,
})
export default class Reports {
  rows = Array.from({ length: 50 }, (_, i) => ({ id: i, total: i * 17 }));
}
AdvancedLazy admin.routes.ts child routes
lazy/admin.routes.ts
import { Routes } from '@angular/router';
import { AdminDashboard, AdminLayout, AdminSettings } from './admin-pages';

export default [
  {
    path: '',
    component: AdminLayout,
    children: [
      { path: '', component: AdminDashboard },
      { path: 'settings', component: AdminSettings },
    ],
  },
] satisfies Routes;
AdvancedBuild output: initial vs lazy chunks

With the app.routes.ts from the first topic, npx ng build printed:

Output
Initial chunk files | Names         |  Raw size | Estimated transfer size
chunk-7OTMIL2L.js   | -             | 120.10 kB |                36.28 kB
chunk-Q45JVFFY.js   | -             |  94.75 kB |                23.91 kB
main-TA3AXHPT.js    | main          |  20.68 kB |                 5.78 kB
styles-5INURTSO.css | styles        |   0 bytes |                 0 bytes

                    | Initial total | 235.53 kB |                65.96 kB

Lazy chunk files    | Names         |  Raw size | Estimated transfer size
chunk-XJW7HGSG.js   | admin-routes  |   1.06 kB |               400 bytes
chunk-FTP24CCW.js   | reports       | 427 bytes |               427 bytes
What’s happening
  1. Initial chunks are what index.html loads up front: main (your app code) plus shared chunks (Angular itself, the router). That's the 235 kB the user waits for.
  2. Lazy chunks are the two import() targets, named after their files: admin-routes (the layout, dashboard and settings components plus their routes) and reports. They are fetched only on the first navigation to /admin or /reports. The reports chunk starts import{…}from"./chunk-7OTMIL2L.js" — it reuses Angular from the initial shared chunk instead of bundling it again.
  3. Both files use export default, so the loaders are just () => import('./…'); the router unwraps the default export. With a named export you'd write import('./reports').then(m => m.Reports). In Angular 16 (techStatus) it was a module: loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule).
  4. In a test with loadComponent: vi.fn(() => import('./lazy/reports')), the loader wasn't called on /home, was called on the first /reports, and was still at 1 call after leaving and coming back — the router caches the loaded component.
  5. Lazy routes still show the page instantly on later visits, but the first visit waits for a network round-trip. If that matters, provideRouter(routes, withPreloading(PreloadAllModules)) downloads lazy chunks in the background after the app has started.
AdvancedPitfall: static imports defeat lazy loading
AdvancedPitfall: guarded lazy chunks still download

#Route guards

My notes flag guards with "!!!!" and mention techStatus used them. A guard is a function the router calls during navigation to ask "may this happen?". It answers with a GuardResult: true (go ahead), false (cancel; stay where you are), or a redirect — a UrlTree or a RedirectCommand. It can answer synchronously or with a Promise/Observable of that (MaybeAsync<GuardResult> in the types).

The kinds, by when they run:

  • canMatch — while matching: may this row even be considered? Failing skips the row (the router tries the next one), and lazy code behind it isn't downloaded.
  • canActivate — after matching: may we enter this route?
  • canActivateChild — on a parent: may we enter any of its children? Runs on every child change.
  • canDeactivate — may we leave the current component? (unsaved changes)
  • canLoad — deprecated, replaced by canMatch.

Guards are UX, not security. They decide what the browser shows; anyone can still call your API directly. The server must check permissions on every request.

guards.ts
@Service()
export class AuthService {
  readonly loggedIn = signal(false);
  readonly roles = signal<string[]>([]);
}

// Modern: a functional guard. inject() works because the router runs guards
// in the route's injection context.
export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);
  if (auth.loggedIn()) return true;
  return router.createUrlTree(['/login'], {
    queryParams: { returnUrl: state.url },
  });
};

// Same guard, but redirect with navigation options (here: keep the URL bar unchanged).
export const authGuardKeepUrl: CanActivateFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  if (auth.loggedIn()) return true;
  return new RedirectCommand(router.parseUrl('/login'), { skipLocationChange: true });
};

// A guard factory: route config passes the required role.
export const roleGuard =
  (role: string): CanActivateFn =>
  () =>
    inject(AuthService).roles().includes(role);
AdvancedSpec: auth and role guard outcomes
guards.spec.ts
const routes: Routes = [
  { path: 'home', component: Home },
  { path: 'login', component: Login },
  { path: 'admin', component: AdminDashboard, canActivate: [authGuard] },
];

it('logged out: redirects to /login with returnUrl', async () => {
  const harness = await setup(routes);
  await harness.navigateByUrl('/admin');
  expect(TestBed.inject(Router).url).toBe('/login?returnUrl=%2Fadmin');
  expect(harness.routeNativeElement?.textContent).toBe('Please log in');
});

it('logged in: lets you through', async () => {
  const harness = await setup(routes);
  TestBed.inject(AuthService).loggedIn.set(true);
  await harness.navigateByUrl('/admin', AdminDashboard);
  expect(TestBed.inject(Router).url).toBe('/admin');
});

it('returning false cancels: you stay where you were', async () => {
  const harness = await setup([
    { path: 'home', component: Home },
    { path: 'admin', component: AdminDashboard, canActivate: [roleGuard('admin')] },
  ]);
  await harness.navigateByUrl('/home');
  const result = await TestBed.inject(Router).navigateByUrl('/admin');
  expect(result).toBe(false);
  expect(TestBed.inject(Router).url).toBe('/home');
});
What’s happening
  1. Logged out → /admin. The URL matches the admin row, then the router calls authGuard(route, state). route is the ActivatedRouteSnapshot being entered; state.url is the full target URL, '/admin'. inject() works inside the function because the router runs guards in an injection context (the route's environment injector) — the same rule as The injection context.
  2. loggedIn() is false, so the guard returns router.createUrlTree(['/login'], { queryParams: { returnUrl: '/admin' } }). A UrlTree is a parsed URL object; returning it tells the router "cancel this navigation and go there instead". The test ends on /login?returnUrl=%2Fadmin showing "Please log in". The login page can later navigateByUrl(returnUrl).
  3. Logged in. The test flips the signal on the real root AuthService, then the same navigation passes: the guard returns true and AdminDashboard activates (the typed navigateByUrl('/admin', AdminDashboard) would throw if anything else had).
  4. false. roleGuard('admin') is a factory: calling it with the role returns the actual guard. With no roles it returns false, and the router cancels: navigateByUrl resolved false, the URL stayed /home, Home stayed on screen. On the very first navigation of an app there's nowhere to "stay", so the user sees a blank outlet — which is why guards usually redirect rather than return false.
  5. RedirectCommand. authGuardKeepUrl returns new RedirectCommand(urlTree, { skipLocationChange: true }) — a redirect with navigation options. In the test the outlet showed "Please log in" and router.url was /login, but Location.path() stayed /home: the address bar didn't change. A plain UrlTree can't carry options like that.
  6. A redirect still resolves true. Calling router.navigateByUrl('/admin') while logged out resolved true and a NavigationCancel event fired with reason NavigationCancelingError: Redirecting to "/login?returnUrl=%2Fadmin". The original navigation was cancelled and replaced; the promise reports the outcome of the replacement.
AdvancedOrder of multiple guards

When a route has several guards, the array order decides. Three synchronous guards [g1 → true, g2 → false, g3 → true] logged only g1 and g2 — g3 never ran. With async guards [slowRedirect (30 ms, returns /login), fastFalse (5 ms)], both started immediately, fastFalse finished first, but the result was the redirect to /login: the router waits for results in array order, and the first non-true result by position wins, not the first to finish.

AdvancedOlder syntax: class-based guards

Class-based guards: the techStatus way

In Angular 16, guards were injectable classes implementing an interface. They still work in 22, so you'll see them in older codebases:

guards.ts
// Old (Angular 16, techStatus style): a class implementing CanActivate.
@Injectable({ providedIn: 'root' })
export class LegacyAuthGuard implements CanActivate {
  constructor(
    private auth: AuthService,
    private router: Router,
  ) {}

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

// in the routes:
{ path: 'a', component: AdminDashboard, canActivate: [LegacyAuthGuard] },                    // deprecated form
{ path: 'b', component: AdminDashboard, canActivate: mapToCanActivate([LegacyAuthGuard]) },  // bridge
What’s happening
  1. The class is a service: @Injectable({ providedIn: 'root' }) and constructor injection. The router used to resolve LegacyAuthGuard from DI and call its canActivate method. Same arguments, same return types as the functional version.
  2. canActivate: [LegacyAuthGuard] still works in 22 — in a test both /a and /b redirected to /login?returnUrl=…. But passing a class (or any DI token) in a guard array is typed as DeprecatedGuard, and the router's .d.ts marks it @deprecated "in favor of plain JavaScript functions".
  3. mapToCanActivate([LegacyAuthGuard]) is the migration bridge: it wraps each class in a function that does inject(LegacyAuthGuard).canActivate(...). You can switch the route config now and rewrite the class later.
  4. Why functions won: no class, no interface, no provider; a guard is a few lines next to the route; factories like roleGuard('admin') give you parameters without data lookups; and composing guards is just calling functions.
AdvancedGuarding unsaved edits with confirm

canDeactivate: unsaved changes

canDeactivate runs on the route you're leaving, and it receives the component instance, so it can ask the component whether it's safe to go:

guards.ts
export interface HasUnsavedChanges {
  hasUnsavedChanges(): boolean;
}

export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) =>
  component.hasUnsavedChanges() ? window.confirm('Discard unsaved changes?') : true;

@Component({
  selector: 'app-edit-profile',
  template: `<input (input)="dirty.set(true)" />`,
})
export class EditProfile implements HasUnsavedChanges {
  readonly dirty = signal(false);
  hasUnsavedChanges() {
    return this.dirty();
  }
}
// route: { path: 'edit', component: EditProfile, canDeactivate: [unsavedChangesGuard] }
guards.spec.ts
it('dirty form + user says Cancel: stays on /edit', async () => {
  const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
  const harness = await setup(routes);
  const cmp = await harness.navigateByUrl('/edit', EditProfile);
  const input = harness.routeNativeElement!.querySelector('input')!;
  input.value = 'Rohit';
  input.dispatchEvent(new Event('input'));
  expect(cmp.hasUnsavedChanges()).toBe(true);

  const left = await TestBed.inject(Router).navigateByUrl('/home');
  expect(confirm).toHaveBeenCalledWith('Discard unsaved changes?');
  expect(left).toBe(false);
  expect(TestBed.inject(Router).url).toBe('/edit');
});
What’s happening
  1. CanDeactivateFn<HasUnsavedChanges> types the first argument as the component. The interface keeps the guard reusable: any component with hasUnsavedChanges() can use it.
  2. In the test, typing into the input fires the (input) handler, dirty goes false → true.
  3. Navigating to /home runs unsavedChangesGuard on the edit route before anything else. hasUnsavedChanges() is true, so it calls window.confirm(...). jsdom doesn't implement confirm, so the test replaces it with vi.spyOn(window, 'confirm').mockReturnValue(false) — the user pressed Cancel.
  4. The guard returns false, the navigation is cancelled: the promise resolved false, the URL is still /edit, the component (and what was typed) is still there. With mockReturnValue(true) the same navigation reached /home; with a clean form confirm was never called.
  5. It only guards in-app navigation. Closing the tab or typing a new URL in the address bar leaves the Angular app entirely; for that you also need a beforeunload listener.

#Resolvers and route data

A resolver fetches data before the route activates. The router runs it after the guards pass, waits for it, and only then creates the component, handing the result over as route data. The component never sees a "loading" state, because by the time it exists the data is there.

Static route data is the simpler sibling: a data object on the route config for constant values — a breadcrumb label, a page section, a required role. Resolved values and static data end up in the same place: ActivatedRoute.data, or component inputs with withComponentInputBinding().

resolvers.ts
@Service()
export class UserStore {
  async load(id: number): Promise<User | undefined> {
    log.push(`resolver: loading user ${id}`);
    await new Promise((r) => setTimeout(r, 20)); // pretend this is HTTP
    return USERS.find((u) => u.id === id);
  }
}

export const userResolver: ResolveFn<User> = async (route) => {
  const store = inject(UserStore);
  const router = inject(Router);
  const user = await store.load(Number(route.paramMap.get('id')));
  return user ?? new RedirectCommand(router.parseUrl('/not-found'));
};

// Reading the same data through component input binding.
@Component({
  selector: 'app-user-profile-inputs',
  template: `<h2>{{ user().name }}</h2>
    <small>{{ section() }}</small>`,
})
export class UserProfileInputs {
  user = input.required<User>();
  section = input.required<string>();
}

// routes
{
  path: 'u/:id',
  component: UserProfileInputs,
  data: { section: 'People' },
  resolve: { user: userResolver },
},
AdvancedSpec: resolver order and redirects
guards.spec.ts
await harness.navigateByUrl('/users/2'); // the same route, read through ActivatedRoute.data, plus a logging canActivate
// log:
// [ 'guard: canActivate', 'event: GuardsCheckEnd', 'resolver: loading user 2', 'component: constructed' ]

const cmp = await harness.navigateByUrl('/u/1', UserProfileInputs);
expect(cmp.user()).toEqual({ id: 1, name: 'Ada' });
expect(cmp.section()).toBe('People');

await harness.navigateByUrl('/users/99');
expect(TestBed.inject(Router).url).toBe('/not-found');
What’s happening
  1. Order. The log shows the pipeline from Configuring routes: the guard ran, guards finished (GuardsCheckEnd), then the resolver started loading user 2, and only after its 20 ms wait did the component's constructor run. The previous page stayed on screen the whole time.
  2. resolve: { user: userResolver } means "run userResolver and store the result under the key user". ResolveFn<User> gets the ActivatedRouteSnapshot, reads :id, and returns MaybeAsync<User | RedirectCommand> — here an async function, so a Promise.
  3. With input binding, the router merges params, query params, static data and resolved data and sets matching inputs: user = { id: 1, name: 'Ada' } (from the resolver) and section = 'People' (from data). The .d.ts gives the precedence: query params, then path params, then static data, then resolvers — later sources win on duplicate keys.
  4. Not found. For /users/99, load returns undefined and the resolver returns new RedirectCommand(router.parseUrl('/not-found')); the router abandoned the navigation and went to /not-found. Resolvers can redirect just like guards (ResolveFn<T> returns MaybeAsync<T | RedirectCommand>).
  5. When it re-runs. On /users/1 → /users/1?tab=posts → /users/2 the resolver logged loading user 1 and loading user 2 only — a query-param change didn't re-run it. That's the default runGuardsAndResolvers: 'paramsChange'; set 'paramsOrQueryParamsChange' or 'always' on the route if the data depends on the query string.
AdvancedResolver trade-off: perceived speed

The trade-off is perceived speed. While a resolver waits, the user sees the old page with nothing happening (unless you show a global progress bar from router events). Loading inside the component — with httpResource or resource() — shows the new page immediately with a skeleton or spinner. Resolvers fit when the page is meaningless without the data and the data is fast, or when you want to redirect on "not found" before rendering anything. For slow data I'd rather navigate immediately and show a loading state (see httpResource).

AdvancedPreview feature: withRouterResources

#routerLinkActive

routerLinkActive is the directive behind "highlight the current menu item". You put it next to a routerLink and give it one or more CSS classes; it watches router navigations and adds the classes while the link's URL is active, removing them when it isn't. My notes file it under "for CSS classes", which is exactly what it does — it never navigates.

"Active" by default means the current URL contains the link's URL as a prefix of path segments. So a /users link is active on /users/2, and a / link is active on every URL. [routerLinkActiveOptions]="{ exact: true }" switches to an exact path match.

nav.ts
@Component({
  selector: 'app-shell',
  imports: [RouterOutlet, RouterLink, RouterLinkActive],
  template: `
    <nav>
      <a id="home" routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Home</a>
      <a id="home-loose" routerLink="/" routerLinkActive="active">Home (no exact)</a>
      <a id="users" routerLink="/users" routerLinkActive="active bold" ariaCurrentWhenActive="page">Users</a>
    </nav>
    <router-outlet />
  `,
})
export class Shell {}
AdvancedrouterLinkActive exact vs loose matching
Output
AT / { home: 'active', loose: 'active', users: '' }
AT /users/2 { home: '', loose: 'active', users: 'active bold' }
aria-current page
What’s happening
  1. The test renders Shell with TestBed.createComponent, then calls router.navigateByUrl('/') and await fixture.whenStable() before reading className of each link.
  2. At /: both Home links are active (the URL is exactly /), Users isn't.
  3. At /users/2: the exact Home link lost its class. The loose Home link is still active — / is a prefix of every URL — which is the bug you get if you forget exact: true on a home link. Users is active because /users is a prefix of /users/2, and it received both classes from the space-separated "active bold".
  4. ariaCurrentWhenActive="page" adds aria-current="page" while active, so screen readers announce the current page. Use it; a CSS class alone is invisible to assistive tech.
  5. Query params count as a subset match by default. For finer control, pass IsActiveMatchOptions: { paths: 'exact' | 'subset', queryParams: 'exact' | 'subset' | 'ignored', matrixParams: …, fragment: … }.
AdvancedisActive outside templates

Outside templates, isActive(url, router, matchOptions?) from @angular/router (public since 21.1) gives you the same answer as a Signal<boolean>. In a test, isActive('/users', router) was true on /users/2 and false after navigating away, while isActive('/users', router, { paths: 'exact' }) was false on /users/2. Handy for a computed like "show the admin toolbar".

#Routing interview notes

The ideas I'd want ready when routing comes up:

  • The router is an ordered lookup table plus a navigation pipeline. Match (first wins) → guards → resolvers → activate. Events mark each phase; NavigationCancel and NavigationError are the failure endings.
  • Order matters. Literal paths before :params, wildcard last; '' redirects need pathMatch: 'full'.
  • Components are reused when only params change. Never read params once from the snapshot if a link can change them; use input binding (withComponentInputBinding()) or paramMap. Params are strings — transform them.
  • Query params aren't part of matching; queryParamsHandling: 'merge' keeps the others; with input binding a missing key becomes undefined.
  • Programmatic navigation: navigate (commands, relative only with relativeTo) vs navigateByUrl (absolute). The promise resolves true/false, rejects on no match.
  • Nested routes = a layout component with its own <router-outlet> and children; default child is path: ''. In Angular 22 children inherit parent params by default.
  • Lazy loading: loadComponent for one component, loadChildren for a route tree; verify with the "Lazy chunk files" table from ng build; an eager import anywhere defeats it.
  • Guards are functions returning boolean | UrlTree | RedirectCommand (maybe async); class guards (what techStatus used in Angular 16) still work but passing a class in a guard array is deprecated; mapToCanActivate bridges them. canMatch protects lazy code; canDeactivate protects unsaved work. Guards are UX, not security.
  • Resolvers fetch before activation; the component gets data via ActivatedRoute.data or inputs; they can redirect; they don't re-run on query changes by default.
  • routerLinkActive adds classes by prefix match; use exact: true for /, plus ariaCurrentWhenActive.
Where does /about go?
TypeScript
const routes: Routes = [
  { path: '', redirectTo: 'home', pathMatch: 'prefix' },
  { path: 'home', component: Home },
  { path: 'about', component: About },
  { path: '**', component: NotFound },
];
// the user opens /about
Show answer

To /home/about, which renders NotFound.

What’s happening
  1. The router checks row 1. pathMatch: 'prefix' asks "does the URL start with ''?" — every URL does — so this redirect applies to /about.
  2. A prefix redirect replaces the matched part (zero segments) with home and keeps the rest (about), producing /home/about, and matching restarts.
  3. home matches the first segment, but Home has no children to consume about, so that row fails; about doesn't match home; the wildcard ** takes it.
  4. My test ended with router.url === '/home/about' and "Page not found" on screen. With pathMatch: 'full', row 1 only matches the bare /, and /about shows About. Without any pathMatch, Angular refuses the config at startup with NG04014.
How many times does the loader run?
TypeScript
{ path: 'admin', canActivate: [authGuard], loadChildren: () => import('./admin.routes') }
// logged out: navigate to /admin/settings (redirected to /login), then log in and navigate there again
Show answer

Once — and it ran on the first, rejected navigation.

What’s happening
  1. First navigation: to match admin/settings the router needs the children of admin, so it calls loadChildren during matching. The chunk downloads.
  2. Then canActivate runs, the user isn't logged in, and the guard redirects to /login. The download already happened.
  3. Second navigation: the router already has the loaded routes cached on the route config, so it doesn't call the loader again; the guard passes and Settings renders.
  4. My test counted 1 loader call after the rejected navigation. With canMatch: [authMatchGuard] instead, the count after the rejected navigation was 0 — a failed canMatch means the row never matched, so its children were never needed.

Built from Rohit’s “Angular interview prep” doc. Examples target Angular 22 and are compiled and tested with the Angular CLI and Vitest.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.