Week 10: Routing Fundamentals

Every app you've built so far has been one view. This week that changes — you'll wire up the Angular Router to turn a single component tree into a real, navigable, multi-page application, complete with nested views and nested URLs to match.

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

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

  • Configure routes, parameters and nested views confidently
  • Navigate programmatically and read route data back out cleanly
  • Handle unknown routes without a broken or blank page

1. Route Configuration & the Router Service

You've had provideRouter(routes) in your app.config.ts since Week 3, sitting there unused. This week it earns its place — routes is just an array mapping URL paths to components.

app.routes.ts
import { Routes } from '@angular/router';
import { DashboardOverview } from './dashboard/overview/overview';
import { ProjectDetail } from './dashboard/project-detail/project-detail';

export const routes: Routes = [
  { path: '', component: DashboardOverview },
  { path: 'projects/:projectId', component: ProjectDetail },
];

And in your template, a single <router-outlet> is where the matched component actually renders:

app.html
<app-nav-bar />
<router-outlet />

Navigate to /projects/42 and Angular matches the second route, renders ProjectDetail into that outlet, and makes 42 available as a route parameter — which is exactly what Section 2 covers.

2. Route Parameters, Query Parameters & Fragments

A segment prefixed with : in a path — like :projectId above — is a route parameter. The modern way to read it is to bind it straight to a component input, no injected service required:

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

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
  ],
};
project-detail.ts
import { Component, input } from '@angular/core';

@Component({
  selector: 'app-project-detail',
  templateUrl: './project-detail.html',
})
export class ProjectDetail {
  // Automatically bound from the :projectId route parameter
  projectId = input.required<string>();
}

Query parameters (?sort=name) and the fragment (#reviews) aren't bound this way — they're read from ActivatedRoute directly, since they're optional and don't map to a fixed input the way a required path segment does:

reading query params — product-list.ts
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';

export class ProductList {
  private route = inject(ActivatedRoute);

  sortBy = toSignal(
    this.route.queryParamMap.pipe(map((params) => params.get('sort') ?? 'name')),
    { initialValue: 'name' }
  );
}

3. Router Outlets & Nested/Child Routes

A route can have children — each with its own path segment, rendered into a <router-outlet> that lives inside the parent route's own component template. This is how a URL like /projects/42/tasks/7 maps to three components nested inside each other.

app.routes.ts — nested routes
export const routes: Routes = [
  {
    path: 'projects/:projectId',
    component: ProjectDetail,
    children: [
      { path: '', component: ProjectOverview },
      { path: 'tasks/:taskId', component: TaskDetail },
    ],
  },
];
project-detail.html
<h1>Project {{ projectId() }}</h1>
<nav> ...project-level tabs... </nav>

<!-- Child route content renders here -->
<router-outlet />

Notice ProjectDetail needs its own <router-outlet> for its children to render at all — the app's top-level outlet only handles the top-level route match.

5. Route Data & Title Strategies

A route can carry static data — useful for things like required permissions, or flags a guard (Week 11) will check — and Angular can set the browser tab title per route automatically, without a component needing to do it manually.

app.routes.ts — data & title
export const routes: Routes = [
  {
    path: 'projects/:projectId',
    component: ProjectDetail,
    title: 'Project Details', // sets document.title on navigation
    data: { requiresRole: 'member' },
  },
];

title can also be a function (a ResolveFn) when the title needs to be dynamic — for example, including the project's actual name once it's loaded, rather than a static string.

6. Handling 404s and Wildcard Routes

A route with path ** matches anything not matched by an earlier route — always place it last in your routes array, since the router matches top to bottom and stops at the first match.

app.routes.ts — wildcard, must be last
export const routes: Routes = [
  { path: '', component: DashboardOverview },
  { path: 'projects/:projectId', component: ProjectDetail },
  { path: '**', component: NotFound }, // catches everything else
];
Why order matters here

If { path: '**' } were placed first, it would swallow every navigation before the router ever reached your real routes — nothing after it would ever match. This is the single most common routing bug for anyone new to Angular's router.

7. Hands-on Exercise

Hands-on

Build a three-level nested routing dashboard

Wire up overview → project → task-detail routing, with real navigation between all three.

Requirements:

  1. A DashboardOverview component at /, listing several fake projects with routerLinks to each.
  2. A ProjectDetail component at /projects/:projectId, with its own <router-outlet> and child routes for a default overview and tasks/:taskId.
  3. A TaskDetail component that reads taskId via component-input binding, and a "Back to project" link using a relative routerLink.
  4. Set a per-route title for all three levels, and confirm the browser tab updates correctly as you navigate.
  5. Add a ** wildcard route rendering a simple "Page not found" component, positioned last, and confirm an invalid URL hits it.
Hint

If the child routes never render, double-check ProjectDetail's own template actually has a <router-outlet /> in it — the app's top-level outlet only ever renders the top-level match.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What does withComponentInputBinding() actually change about how you read route parameters?

Without it, a component has to inject ActivatedRoute and read parameters out of an Observable map. With it enabled, a route parameter named :projectId is bound directly to a component input named projectId automatically — no injected service or subscription needed for that specific value.

Q2

Why are query parameters read via ActivatedRoute rather than component-input binding, unlike path parameters?

Query parameters are optional and not declared anywhere in the route configuration the way :projectId is — there's no fixed list of "the query params this route has" for Angular to bind automatically. They're read reactively from ActivatedRoute.queryParamMap instead, which naturally handles params that may or may not be present.

Q3

Why does a parent route with children need its own <router-outlet>?

Each level of nested routing needs a place to render its own matched child — the app's top-level outlet only renders the top-level route's component. A child route's component renders into whatever outlet exists inside its parent's template, so without one there, the child simply has nowhere to appear.

Q4

You move the { path: '**' } route to the top of the routes array. What breaks?

Every navigation, because the router checks routes in array order and stops at the first match. ** matches literally any path, so placed first it would immediately catch every URL and render the not-found component — none of the real routes below it would ever be reached.