Skip to main content

Angular router: routing, guards, and lazy loading

Angular Router maps URL paths to components, controls access with guards, and loads feature modules on demand with lazy loading.

Theory

TL;DR

  • Router acts like a hotel front desk: matches the URL (room number) to a component (room), checks the token (guard) before entry, and loads floors (modules) only when a guest arrives
  • Core split: eager loading bundles everything at startup; lazy loading splits code into separate chunks fetched per route
  • Use loadComponent for one standalone component, loadChildren for a whole route file with sub-routes
  • Wildcard { path: '**' } must always be last - it matches everything before it
  • canMatch (Angular 14+) blocks the lazy chunk download entirely; canActivate only blocks rendering

Quick example

typescript
// app.routes.ts export const routes: Routes = [ { path: '', component: HomeComponent }, { path: 'admin', canMatch: [authGuard], // blocks download for unauthorized users loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES) }, { path: '**', redirectTo: '' } // wildcard always last ];
html
<a routerLink="/" routerLinkActive="active">Home</a> <router-outlet></router-outlet>

Navigate to /admin while logged in: the admin chunk downloads, guard passes, component renders. Navigate while logged out: guard fires, redirects to /login, the chunk never downloads.

Lazy loading vs eager loading

Eager loading bundles every component into one JS file at startup. A 2MB bundle ships on every first load, whether or not the user ever visits the admin panel. Lazy loading splits that into main.js (say, 100KB) plus admin.js (300KB) downloaded only when someone actually navigates to /admin. For any app beyond 10-15 routes, this difference in first paint time is measurable - often cutting initial load by more than half.

loadComponent targets a single standalone component. loadChildren points to a route file that defines multiple sub-routes with their own components and guards. Use loadChildren when a feature has its own route tree.

Route parameters

typescript
export class UserDetailComponent { private route = inject(ActivatedRoute); ngOnInit() { // Subscribe to the observable - handles re-navigation to same component this.route.paramMap.subscribe(params => { const id = params.get('id')!; this.loadUser(id); }); } }

snapshot.paramMap.get('id') reads the value once at init. If the user goes from /users/1 to /users/2 and Angular reuses the component instance, ngOnInit does not fire again and the snapshot never updates. The observable handles this correctly.

Guards

Guards run checks before a route activates. Angular 14+ replaced class-based guards with functional ones that use inject() directly.

typescript
// auth.guard.ts export const authGuard: CanActivateFn = (route, state) => { const authService = inject(AuthService); const router = inject(Router); return authService.isLoggedIn() ? true : router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } }); };

Three guard types you will actually use:

  • canActivate: runs before the component renders. Standard auth check.
  • canDeactivate: runs when leaving a route. Use it to warn about unsaved form data.
  • canMatch: runs before the lazy chunk downloads. Better than canActivate for protected lazy routes because unauthorized users never download the module code.
typescript
// canDeactivate example export const unsavedChangesGuard: CanDeactivateFn<EditFormComponent> = (component) => { if (component.hasUnsavedChanges()) { return confirm('Leave without saving?'); } return true; };

Resolvers

A resolver fetches data before the route renders. The component receives the data already available via ActivatedRoute.snapshot.data.

typescript
// user.resolver.ts export const userResolver: ResolveFn<User> = (route) => { return inject(UserService).getUserById(route.paramMap.get('id')!); }; // routes { path: 'users/:id', component: UserDetailComponent, resolve: { user: userResolver } } // component - data is ready immediately export class UserDetailComponent { user = inject(ActivatedRoute).snapshot.data['user'] as User; }

The trade-off is real: resolvers hold navigation until the API responds. On a slow network, the user stares at the previous page longer. In most production apps I have seen, showing a loading skeleton inside the component works better - navigation feels instant and the fetch happens in the background.

Nested routes

typescript
{ path: 'settings', component: SettingsLayoutComponent, children: [ { path: '', redirectTo: 'profile', pathMatch: 'full' }, { path: 'profile', component: ProfileSettingsComponent }, { path: 'security', component: SecuritySettingsComponent }, ] }

SettingsLayoutComponent needs its own <router-outlet> for children to render inside it. The app-level outlet renders SettingsLayoutComponent, and the nested outlet renders whichever tab is active.

Programmatic navigation

typescript
export class AppComponent { private router = inject(Router); goToUser(id: string) { this.router.navigate(['/users', id]); } search(term: string) { this.router.navigate(['/search'], { queryParams: { q: term, page: 1 } }); } }

Common mistakes

Reading params from snapshot in a component that handles re-navigation.

typescript
// wrong - breaks when navigating from /users/1 to /users/2 ngOnInit() { const id = this.route.snapshot.paramMap.get('id'); } // right ngOnInit() { this.route.paramMap.subscribe(params => { this.id = params.get('id')!; }); }

Angular may reuse the component instance on same-route navigation. Snapshot does not update. The observable does.

Missing pathMatch: 'full' on empty path redirects.

typescript
// wrong - /home also matches '' by prefix, causes redirect loops { path: '', redirectTo: 'home' } // right { path: '', redirectTo: 'home', pathMatch: 'full' }

The default matching strategy is prefix-based, so an empty path matches every URL. This is the most common Angular routing question on Stack Overflow.

Using canActivate instead of canMatch on lazy routes.

typescript
// wrong - chunk downloads before guard runs { path: 'admin', loadChildren: () => import('./admin'), canActivate: [authGuard] } // right - guard fires before the download starts { path: 'admin', canMatch: [authGuard], loadChildren: () => import('./admin') }

Wildcard route in the wrong position.

typescript
// wrong - wildcard catches everything, other routes never match { path: '**', component: NotFoundComponent }, { path: 'home', component: HomeComponent }, // dead code // right { path: 'home', component: HomeComponent }, { path: '**', component: NotFoundComponent },

Real-world usage

  • E-commerce checkout: canActivate checks if the cart has items before allowing /checkout
  • Admin dashboards: canMatch blocks the admin bundle download for non-admin users
  • Profile edit pages: canDeactivate prevents losing form data on accidental back navigation
  • Multi-step wizards: resolvers pre-load required data before the first step renders
  • Large SPAs (NGX-Admin pattern): nested lazy routes for paths like /crm/clients/:id/orders

Follow-up questions

Q: What is the difference between canActivate and canMatch?
A: canActivate runs after the lazy module chunk has already downloaded. canMatch runs before. For protected lazy routes, canMatch is the right choice - unauthorized users never download the feature code at all.

Q: When would you use a resolver vs loading data inside the component?
A: Use a resolver when the page layout or title depends on data that must exist before render. Inside-component loading is better when the API is slow and you want the user to see the page skeleton immediately - navigation feels instant and the app feels faster.

Q: How does Angular handle a browser refresh on a lazy route?
A: The router initializes from the current URL using APP_BASE_HREF, re-downloads the matching lazy chunk, and runs the full activation sequence including guards. No special handling is needed unless you use SSR, where provideClientHydration() handles chunk matching on the client.

Q: Why does forRoot/forChild matter in NgModule-based apps?
A: RouterModule.forRoot() registers the router providers globally and must be called exactly once. RouterModule.forChild() adds routes without re-registering providers. Calling forRoot inside a lazy module creates a second router instance and breaks navigation entirely.

Q: How do you debug route matching problems?
A: Add enableTracing: true to RouterModule.forRoot(), or use withDebugTracing() in standalone config. The router logs every match step to the console - which routes were tried, what matched, and why others were skipped.

Examples

Basic routing with wildcard

typescript
export const routes: Routes = [ { path: '', component: HomeComponent }, { path: 'about', component: AboutComponent }, { path: 'users/:id', component: UserDetailComponent }, { path: '**', component: NotFoundComponent }, // must be last ];
html
<nav> <a routerLink="/" routerLinkActive="active">Home</a> <a routerLink="/about" routerLinkActive="active">About</a> <a [routerLink]="['/users', user.id]">View Profile</a> </nav> <router-outlet></router-outlet>

routerLinkActive adds the CSS class when the link's route matches the current URL. The [routerLink] array syntax handles dynamic segments cleanly - no string concatenation needed.

Auth guard with lazy-loaded admin module

typescript
// auth.guard.ts export const authGuard: CanActivateFn = (route, state) => { const authService = inject(AuthService); const router = inject(Router); return authService.isLoggedIn() ? true : router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } }); }; // app.routes.ts export const routes: Routes = [ { path: '', component: HomeComponent }, { path: 'login', component: LoginComponent }, { path: 'admin', canMatch: [authGuard], loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES), }, ]; // admin/admin.routes.ts export const ADMIN_ROUTES: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: 'dashboard', loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent), }, ];

Logged-out users hitting /admin are redirected to /login?returnUrl=/admin. The admin bundle never downloads. After login, read returnUrl from query params and call router.navigate([returnUrl]) to send the user to their intended destination.

Resolver with nested routes and canDeactivate

typescript
// user.resolver.ts export const userResolver: ResolveFn<User> = (route) => { return inject(UserService).getUser(route.paramMap.get('id')!); }; // unsaved-changes.guard.ts export const unsavedChangesGuard: CanDeactivateFn<UserEditComponent> = (component) => { return component.hasUnsavedChanges() ? confirm('Leave without saving?') : true; }; // user.routes.ts export const USER_ROUTES: Routes = [ { path: ':id', component: UserShellComponent, resolve: { user: userResolver }, children: [ { path: '', redirectTo: 'overview', pathMatch: 'full' }, { path: 'overview', component: UserOverviewComponent }, { path: 'edit', component: UserEditComponent, canDeactivate: [unsavedChangesGuard] }, ], }, ]; // user-shell.component.ts export class UserShellComponent { user = inject(ActivatedRoute).snapshot.data['user'] as User; // user is available immediately, no loading state needed }

UserShellComponent renders the user name in a shared header that both child routes inherit. The resolver fetches the user before navigation completes. The canDeactivate guard on the edit route prompts before leaving when the form has unsaved changes.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?