Mert Özen Further With Every Line
Frontend Angular — #13

Angular Routing Guide: RouterModule, Route Parameters and Child Routes

Mert Özen Oct 31, 2026 7 min 1 views
Angular Routing Guide: RouterModule, Route Parameters and Child Routes

Routing in Angular 12: app-routing.module.ts, router-outlet, routerLink, route parameters with ActivatedRoute, query params, Router.navigate, child routes, redirectTo and a wildcard for 404s.

Version note: The code and commands in this post are for Angular 12.

One Page, Many Screens

An Angular app is a single-page application: the browser receives one index.html and never asks for a new page again. Yet users still expect different screens, like a home page, a product list and a product detail view. And when they copy the URL from the address bar and send it to someone, they expect that person to land on the same screen.

Think of a television. When you change the channel, the TV stays the same; only the broadcast on the screen changes. In Angular, the TV is AppComponent and the channels are the other components. The thing holding the remote is the router. It looks at the URL, finds the matching component in its lookup table and puts it on the screen. Not a single request for a new page goes to the server.

app-routing.module.ts and RouterModule.forRoot

If you answered yes to "Would you like to add Angular routing?" when creating the project with ng new, the src/app/app-routing.module.ts file is already there. If you said no, you can create the same file yourself and add it to the imports array of AppModule as AppRoutingModule. Let's put three routes in it:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { ProductListComponent } from './product-list/product-list.component';
import { AboutComponent } from './about/about.component';

const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'products', component: ProductListComponent },
  { path: 'about', component: AboutComponent }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Routes is an array, and each entry is a mapping: when this path comes in, show this component. Paths are written without a leading slash; the empty string '' means the root address. I'm assuming we generated these three components beforehand with ng g c home, ng g c product-list and ng g c about.

RouterModule.forRoot(routes) does two things: it registers the route table and creates the single Router service the whole app uses. That's why it is called only once, in the root module. The exports: [RouterModule] line at the bottom matters too. As we saw earlier, a module that imports another can use whatever that module exports. Thanks to this line, the components in AppModule recognize router directives such as router-outlet and routerLink in their templates.

router-outlet: Where the Screen Changes

The route table is ready, but we haven't told the router where to put the components yet. Edit app.component.html like this:

<header>
  <h1>The Bookshop</h1>
</header>

<router-outlet></router-outlet>

<footer>Open every day from 9 am to 6 pm.</footer>

<router-outlet> is a placeholder. The header and footer stay put on every screen, while the area in between changes with the URL. Go to localhost:4200/about in the browser; AboutComponent shows up right at that spot. If you look with the developer tools, you'll see the component is added not inside the router-outlet tag but right after it, as a sibling.

Users won't be typing URLs into the address bar, of course. The classic href is the first thing that comes to mind for a menu, but in Angular we use routerLink instead:

<nav>
  <a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Home</a>
  <a routerLink="/products" routerLinkActive="active">Products</a>
  <a routerLink="/about" routerLinkActive="active">About</a>
</nav>

Here's the difference: href makes the browser reload the page from scratch. The app starts over and all the data in memory is gone. routerLink catches the click, changes the URL through the browser's History API and swaps only the component in the outlet. The back and forward buttons keep working just fine.

routerLinkActive adds the class you give it for as long as the link points to the active route. Define a style for .active in CSS and users can tell from the menu which page they're on. The reason for exact: true on the home link is simple: every URL starts with /, so without it the "Home" link would look active on every page.

Route Parameters: ActivatedRoute and paramMap

We can't write a separate route for every product. Instead, we define a parameter in the path:

{ path: 'products/:id', component: ProductDetailComponent }

The :id starting with a colon is a placeholder. Both /products/7 and /products/42 land on this route. When linking from the list to the detail page, the array form is the cleanest:

<a [routerLink]="['/products', product.id]">{{ product.name }}</a>

In the detail component, we read the parameter through ActivatedRoute:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

@Component({
  selector: 'app-product-detail',
  templateUrl: './product-detail.component.html'
})
export class ProductDetailComponent implements OnInit {
  productId = 0;

  constructor(private route: ActivatedRoute) { }

  ngOnInit(): void {
    this.route.paramMap.subscribe(params => {
      this.productId = Number(params.get('id'));
    });
  }
}

ActivatedRoute is a service that carries everything about the route currently on screen. paramMap is an observable holding the parameters. get('id') always returns a string or null; there's no such thing as a number in a URL, so we convert it with Number.

So why an observable instead of a plain value? If the user is on /products/7 and clicks a link to /products/8, Angular reuses the same component. Neither the constructor nor ngOnInit runs again, but paramMap emits the new value. If you're sure the component won't be reused, you can grab the current value with this.route.snapshot.paramMap.get('id'). The router cleans up these observables itself, so you don't need to unsubscribe here.

In real life you take the id and fetch the product from the server. The switchMap we saw earlier is a perfect fit:

this.product$ = this.route.paramMap.pipe(
  switchMap(params => this.productService.getProduct(Number(params.get('id'))))
);

When the parameter changes, switchMap cancels the previous HTTP request and starts a new one. In the template you display product$ with the async pipe.

Query Params

A path parameter identifies a resource: product number 7. Optional information such as sorting, page number or filters goes after the question mark as query params: /products?sort=price&page=2.

<a routerLink="/products" [queryParams]="{ sort: 'price', page: 2 }">Sort by price</a>

Reading them works the same way as paramMap; only the name differs:

this.route.queryParamMap.subscribe(params => {
  this.sort = params.get('sort') ?? 'name';
  this.page = Number(params.get('page') ?? 1);
});

If a query param is missing, get returns null again, so we supply a default with ??. The nice part of this approach is that users can share a filtered list exactly as they see it, just by copying the URL.

Programmatic Navigation: Router.navigate

Not every navigation comes from clicking a link. When you want to go to the detail page after a form is saved, or switch pages from a button, you inject the Router service:

import { Router } from '@angular/router';

constructor(private router: Router) { }

openProduct(id: number): void {
  this.router.navigate(['/products', id]);
}

goToPage(page: number): void {
  this.router.navigate(['/products'], { queryParams: { page } });
}

navigate takes the same array we gave routerLink as its first argument. The second argument lets you pass extra options such as query params. If you already have a URL string, this.router.navigateByUrl('/products/5') does the same job. Both return a Promise that tells you whether the navigation succeeded.

Child Routes

Let's say the product detail page has two tabs, "Specs" and "Reviews", and each tab gets its own URL. For that we add children to the route:

{
  path: 'products/:id',
  component: ProductDetailComponent,
  children: [
    { path: 'specs', component: ProductSpecsComponent },
    { path: 'reviews', component: ProductReviewsComponent }
  ]
}

The parent's template decides where the child components are drawn. We put a second outlet inside product-detail.component.html:

<h2>Product #{{ productId }}</h2>
<nav>
  <a routerLink="specs" routerLinkActive="active">Specs</a>
  <a routerLink="reviews" routerLinkActive="active">Reviews</a>
</nav>
<router-outlet></router-outlet>

Notice there is no leading slash on these links. They are relative and resolve against the current route, so if you're on /products/7, the specs link goes to /products/7/specs. When the tab changes, the detail component stays in place and only the inner outlet changes. If a child component needs the :id value, it reads it through this.route.parent, because the parameter belongs to the parent route.

One thing is missing: when someone lands directly on /products/7, the inner outlet stays empty. We'll fix that in the next section.

Wildcards and redirectTo

The router reads the route table from top to bottom and uses the first route that matches. Think of the sorting shelves at a post office; a letter is checked against the slots in order and goes into the first one that fits, with a "no such address" box at the very bottom. Here's the final version of the table:

const routes: Routes = [
  { path: '', redirectTo: 'home', pathMatch: 'full' },
  { path: 'home', component: HomeComponent },
  { path: 'products', component: ProductListComponent },
  {
    path: 'products/:id',
    component: ProductDetailComponent,
    children: [
      { path: '', redirectTo: 'specs', pathMatch: 'full' },
      { path: 'specs', component: ProductSpecsComponent },
      { path: 'reviews', component: ProductReviewsComponent }
    ]
  },
  { path: 'about', component: AboutComponent },
  { path: '**', component: NotFoundComponent }
];

redirectTo sends an incoming address to another one. A user who hits the root address now ends up on /home, and a user who lands directly on a detail page ends up on the specs tab. That takes care of the empty outlet from the previous section.

pathMatch: 'full' is a must when redirecting from an empty path. The default value, 'prefix', checks "does the URL start with this path?", and every URL starts with an empty string. Without this setting, every address would redirect to the home page.

The '**' at the bottom is the wildcard route, and it matches anything. Addresses that match no other route fall through to NotFoundComponent. Because order matters, this line must always come last; put it at the top and every page in the app shows a 404.

A Small Experiment

Your turn. In a new project, build a tiny blog using JSONPlaceholder's /posts endpoint: a /posts list, a /posts/:id detail page, an "About" page and a 404 page. In the detail component, get the id with paramMap and fetch the post with the HttpClient and switchMap we saw earlier.

Add a "Next post" button to the detail page and use Router.navigate to go to the next id. Put a console.log inside ngOnInit and another inside the paramMap subscription; click the button and see which one runs again. Then move the '**' route to the top of the table and watch what happens.

Right now every page is open to everyone. When you want to keep signed-out users away from the admin panel, stop someone from leaving a half-filled form, or avoid downloading big sections of the app on first load, you'll need the router's more advanced features. In the next post we'll talk about guards and lazy loading.