Last chapter, I promised we would turn our components into actual navigable pages. Here we are: it is time for the Router.
One of the nicest things about modern applications is moving between "pages" without actually reloading the browser. No white screen, no waiting: the URL changes, and so does the content, instantly. In Angular, all of this goes through the router, a concept you will use in every project.
This chapter covers what it is and how to configure it, lazy loading, route parameters, ways to navigate, guards and resolvers. There is a lot on the menu, but each piece is short.
🚪 What is the Router?
The router manages navigation between pages in a Single Page Application. And in Angular, "pages" means components: there is no different HTML file for each URL; there is a different component that the router mounts and unmounts based on the address.
In an SPA, the browser loads the application once. From then on, the router decides which component to show for each URL, without additional round trips for HTML.
A concrete example:
/displaysHomeComponent./gamesdisplaysGamesComponent./games/42displays details for the game with ID 42.
Where pages go: router-outlet
First question: if the router "mounts" components, where does it put them? The answer is <router-outlet>, a placeholder placed in the root component's template:
<!-- app.component.html -->
<header>La mia app</header>
<router-outlet />Everything outside the outlet (header, footer, menu) stays in place; the router inserts the current page inside it. Remember the previous chapter's lesson: RouterOutlet is a directive and must be imported into the component using it.
1import { Component } from '@angular/core';
2import { RouterOutlet } from '@angular/router';
3
4@Component({
5 selector: 'app-root',
6 imports: [RouterOutlet],
7 templateUrl: './app.component.html',
8 styleUrl: './app.component.css'
9})
10export class AppComponent {}⚙️ Configuring routes
Configuration lives in two files. The first is app.routes.ts, the list of routes:
1import { Routes } from '@angular/router';
2import { HomeComponent } from './home/home.component';
3
4export const routes: Routes = [
5 { path: '', component: HomeComponent },
6 {
7 path: 'games',
8 loadComponent: () =>
9 import('./games/games.component').then((m) => m.GamesComponent),
10 },
11];The second is app.config.ts, where the routes are registered with provideRouter():
1import { ApplicationConfig } from '@angular/core';
2import { provideRouter } from '@angular/router';
3import { routes } from './app.routes';
4
5export const appConfig: ApplicationConfig = {
6 providers: [provideRouter(routes)],
7};If you created the project with
ng new, these two files already exist and are wired together: just fill theroutesarray.
💤 Lazy loading
The example above shows two ways to declare a route. With component, the component goes into the app's initial bundle; with loadComponent, it is loaded only when the user visits that route. This is lazy loading, and its benefits are concrete:
- The initial app load is lighter and faster.
- Code stays divided into independent blocks by feature.
- Pages the user never visits are never downloaded.
The .then((m) => m.GamesComponent) is needed because import() returns the whole JavaScript module, while the router needs the component. If the component is the file's default export, you can also write loadComponent: () => import('./games/games.component'), but the explicit form reads better.
My rule of thumb: load the homepage eagerly and almost everything else lazily.
🔁 Route parameters
The details for game 42 live at /games/42: 42 is a route parameter, declared using a colon:
1{
2 path: 'games/:id',
3 loadComponent: () =>
4 import('./game-detail/game-detail.component').then(
5 (m) => m.GameDetailComponent
6 ),
7}How do you read it in the component? The modern approach is surprisingly clean, but requires an extra configuration option: withComponentInputBinding().
1import { provideRouter, withComponentInputBinding } from '@angular/router';
2import { routes } from './app.routes';
3
4export const appConfig: ApplicationConfig = {
5 providers: [provideRouter(routes, withComponentInputBinding())],
6};With this option enabled, Angular takes route parameters and passes them to the component as normal inputs. In the component, write:
1import { Component, input } from '@angular/core';
2
3@Component({ /* ... */ })
4export class GameDetailComponent {
5 id = input.required<string>();
6}The input name must match the parameter name: the route declares
:id, so the input is calledid. Query strings work the same way: for/search?q=zelda, an input namedqreceives the value.
The classic approach also exists: inject ActivatedRoute and read paramMap. You will encounter it in existing codebases, but for new code, input bindings are simpler and easier to test.
🚦 Navigating between routes
Two approaches, depending on whether navigation starts in the template or in the logic.
From the template, use the routerLink directive (import it into the component, as always):
1<!-- simple route -->
2<a routerLink="/home">Go home</a>
3
4<!-- route with parameters -->
5<a [routerLink]="['/games', gameId]">Game details</a>
6
7<!-- with a query string -->
8<a [routerLink]="['/search']" [queryParams]="{ q: 'zelda' }">Search Zelda</a>A useful bonus for menus: routerLinkActive adds a CSS class when the link matches the current route.
<a routerLink="/home" routerLinkActive="active-link">Home</a>From code, use the Router service when navigation depends on logic: after saving a form, after login, and so on.
1import { Component, inject } from '@angular/core';
2import { Router } from '@angular/router';
3
4@Component({ /* ... */ })
5export class LoginComponent {
6 private router = inject(Router);
7
8 onLoginSuccess(gameId: string) {
9 // using segments and parameters
10 this.router.navigate(['/games', gameId]);
11
12 // using a full URL string
13 this.router.navigateByUrl('/home');
14
15 // with a query string
16 this.router.navigate(['/search'], { queryParams: { q: 'zelda' } });
17 }
18}In brief: routerLink for actual links, router.navigate() when a decision is involved.
🛡 Guards: protecting routes
A guard decides whether a route can be activated. The typical case is a page restricted to authenticated users. Today, a guard is a simple function:
1import { inject } from '@angular/core';
2import { CanActivateFn, Router } from '@angular/router';
3
4export const authGuard: CanActivateFn = () => {
5 const isLoggedIn = localStorage.getItem('token') !== null;
6
7 // true allows navigation, a UrlTree redirects
8 return isLoggedIn ? true : inject(Router).parseUrl('/login');
9};Returning false alone would work, but could leave the user on an empty page; returning a UrlTree takes them straight to login instead. Attach it to the route with canActivate:
1{
2 path: 'profile',
3 loadComponent: () =>
4 import('./profile/profile.component').then((m) => m.ProfileComponent),
5 canActivate: [authGuard],
6}A guard protects navigation, not data: it runs in the browser, so it is not a security mechanism. Real checks stay on the server; the guard provides a sensible experience for unauthorized users.
🧩 Resolvers: data ready before the page
Sometimes a page makes no sense without its data, so you might as well load it before showing the page. That is the job of resolvers, also simple functions:
1import { inject } from '@angular/core';
2import { ResolveFn } from '@angular/router';
3import { GameService } from './services/game.service';
4import { Game } from './models/game';
5
6export const gameResolver: ResolveFn<Game> = (route) => {
7 const id = route.paramMap.get('id')!;
8 return inject(GameService).getGameById(id);
9};Register one on the route with resolve:
1{
2 path: 'games/:id',
3 loadComponent: () =>
4 import('./game-detail/game-detail.component').then(
5 (m) => m.GameDetailComponent
6 ),
7 resolve: { game: gameResolver },
8}Here, the earlier withComponentInputBinding() comes in handy again: resolved data also reaches the component as inputs, named after the keys used in resolve.
1import { Component, input } from '@angular/core';
2import { Game } from './models/game';
3
4@Component({ /* ... */ })
5export class GameDetailComponent {
6 game = input.required<Game>();
7}The downside: navigation waits until the resolver finishes. Perfect for quick calls; for slow data, consider displaying the page with a spinner instead.
The fallback route
One last small but essential detail: what happens if the user types a URL that does not exist? Without instructions, nothing good. The wildcard route ** catches anything that did not match:
{ path: '**', component: NotFoundComponent }It must be last in the array: routes are evaluated in order, and the wildcard consumes everything it encounters.
Where we are now
The router turns a collection of components into an application: pages with URLs, loaded only when needed, protected where appropriate and with data ready when that makes sense. What we have covered handles the vast majority of real cases.
In the next chapter, we reach the topic I have teased twice: data binding and signals, or how Angular keeps data and the interface synchronized. This is where modern Angular shows its strength.
