Skip to main content
Published / updated

Routing and Navigation

Before you start

You need: services and DI (Article 04).

Time: about 50 minutes, plus the practice.

Learning objective

Build a routed application with lazy-loaded features, route parameters and query state that survives a refresh and a shared link.

Topics

  • Defining routes
  • router-outlet and routerLink
  • Route parameters
  • Query parameters
  • Child routes and nested outlets
  • Lazy loading
  • Programmatic navigation
  • Route resolvers
  • Diagnosing routing problems

Defining routes

// app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
{ path: '', redirectTo: 'students', pathMatch: 'full' },

{
path: 'students',
loadComponent: () => import('./features/students/student-list.component')
.then(m => m.StudentListComponent),
title: 'Students'
},
{
path: 'students/new',
loadComponent: () => import('./features/students/student-form.component')
.then(m => m.StudentFormComponent),
title: 'Add student'
},
{
path: 'students/:publicId',
loadComponent: () => import('./features/students/student-detail.component')
.then(m => m.StudentDetailComponent)
},
{
path: 'login',
loadComponent: () => import('./features/auth/login.component')
.then(m => m.LoginComponent),
title: 'Sign in'
},

{ path: '**', loadComponent: () => import('./shared/not-found.component')
.then(m => m.NotFoundComponent) }
];

Order matters — the first match wins. students/new must come before students/:publicId, or new is captured as a publicId and the detail page tries to load a student with that id.

path: '**' is the catch-all and must be last. Placed earlier it swallows every route below it.

pathMatch: 'full' on the empty-path redirect is mandatory. The default prefix matches every URL, producing an infinite redirect loop.

The title property sets the browser tab and is announced by screen readers on navigation — worth setting on every route.

<!-- app.component.html -->
<header>
<nav aria-label="Main">
<a routerLink="/students" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }">Students</a>
<a routerLink="/teachers" routerLinkActive="active">Teachers</a>
<a routerLink="/fees" routerLinkActive="active">Fees</a>
</nav>
</header>

<main>
<router-outlet />
</main>
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';

@Component({
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
// ...
})

<router-outlet /> is where the matched component renders.

Use routerLink, never href. An href triggers a full page reload — the whole application restarts, all state is lost, and the SPA advantage disappears. This is the most visible routing mistake there is.

<a routerLink="/students">Students</a> <!-- static -->
<a [routerLink]="['/students', student.publicId]">Details</a> <!-- dynamic -->
<a [routerLink]="['/students']" [queryParams]="{ className: '10th' }">Class 10</a>
<a routerLink="./edit">Relative to the current route</a>
<a routerLink="../">Up one level</a>

routerLinkActive adds a class when the route is active. Without { exact: true }, /students stays active on /students/new — because it matches as a prefix. Use exact on parent links in a nav bar.

Route parameters

{ path: 'students/:publicId', loadComponent: () => import('./student-detail.component') }
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs';

export class StudentDetailComponent {
private readonly route = inject(ActivatedRoute);
private readonly studentService = inject(StudentService);

readonly student = toSignal(
this.route.paramMap.pipe(
switchMap(params => this.studentService.getByPublicId(params.get('publicId')!))
),
{ initialValue: null });
}

Subscribe to paramMap; do not read it once.

// Wrong — reads the value once
ngOnInit(): void {
const publicId = this.route.snapshot.paramMap.get('publicId');
this.load(publicId);
}

The snapshot is correct on first load and stale afterwards. Angular reuses the component instance when navigating from /students/a1 to /students/b2, so ngOnInit never runs again and the page keeps showing the first student.

The observable form re-runs on every parameter change. switchMap also cancels the previous request, so rapid navigation cannot produce an out-of-order result.

Enabling withComponentInputBinding() binds parameters straight to inputs:

provideRouter(routes, withComponentInputBinding())
export class StudentDetailComponent {
@Input() publicId!: string; // bound from the route parameter
}

Simplest for a component that only needs the value.

Query parameters

Filters, paging and sorting belong here — they are part of the page's address.

export class StudentListComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);

readonly query = toSignal(this.route.queryParamMap, { initialValue: null });

onSearch(term: string): void {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { term: term || null, page: 1 },
queryParamsHandling: 'merge'
});
}
}
OptionEffect
queryParamsHandling: 'merge'Keep existing parameters, override the named ones
queryParamsHandling: 'preserve'Keep all existing, ignore new ones
omittedReplace all query parameters
value nullRemove that parameter

Putting filter state in the URL is what makes a page shareable and refresh-safe. A search stored only in a component property is lost on refresh, cannot be bookmarked, and breaks the back button.

this.router.navigate(['/students'], {
queryParams: { term },
replaceUrl: true // no new history entry — right for a live search
});

Without replaceUrl, every keystroke of a debounced search adds a history entry and the back button becomes unusable.

Child routes

{
path: 'students/:publicId',
loadComponent: () => import('./features/students/student-shell.component')
.then(m => m.StudentShellComponent),
children: [
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
{ path: 'overview', loadComponent: () => import('./student-overview.component') },
{ path: 'results', loadComponent: () => import('./student-results.component') },
{ path: 'fees', loadComponent: () => import('./student-fees.component') }
]
}
<!-- student-shell.component.html -->
<h1>{{ student()?.name }}</h1>

<nav aria-label="Student sections">
<a routerLink="overview" routerLinkActive="active">Overview</a>
<a routerLink="results" routerLinkActive="active">Results</a>
<a routerLink="fees" routerLinkActive="active">Fees</a>
</nav>

<router-outlet />

The shell renders the header and tabs; the child outlet renders the selected tab. The shell is not recreated when switching tabs, so its data is fetched once.

A child reads a parent's parameter through parent:

private readonly publicId = this.route.parent?.snapshot.paramMap.get('publicId');

Or set paramsInheritanceStrategy: 'always' so children inherit them directly:

provideRouter(routes, withRouterConfig({ paramsInheritanceStrategy: 'always' }))

Lazy loading

// Eager — in the initial bundle
{ path: 'students', component: StudentListComponent },

// Lazy — a separate chunk, downloaded on first navigation
{ path: 'students', loadComponent: () => import('./…').then(m => m.StudentListComponent) }

A whole feature area:

// app.routes.ts
{
path: 'students',
loadChildren: () => import('./features/students/students.routes').then(m => m.STUDENT_ROUTES)
}
// features/students/students.routes.ts
export const STUDENT_ROUTES: Routes = [
{ path: '', loadComponent: () => import('./student-list.component').then(m => m.StudentListComponent) },
{ path: 'new', loadComponent: () => import('./student-form.component').then(m => m.StudentFormComponent) },
{ path: ':publicId', loadComponent: () => import('./student-detail.component').then(m => m.StudentDetailComponent) }
];

Lazy-load every feature area. The initial bundle then contains only the shell and the first route, so the application starts faster — which matters most on the mobile connections your users actually have.

Verify it in the Network tab: navigating to a lazy route should download a new .js chunk.

Preloading fetches lazy chunks in the background after the app starts:

import { PreloadAllModules, withPreloading } from '@angular/router';

provideRouter(routes, withPreloading(PreloadAllModules))

Fast start and instant navigation. A custom strategy can preload only the routes a user is likely to visit.

Programmatic navigation

private readonly router = inject(Router);

this.router.navigate(['/students']);
this.router.navigate(['/students', publicId]);
this.router.navigate(['/students'], { queryParams: { className: '10th' } });
this.router.navigate(['../'], { relativeTo: this.route });
this.router.navigateByUrl('/students/a1/results');

// After a successful save
async save(): Promise<void> {
await firstValueFrom(this.studentService.create(this.form.value));
await this.router.navigate(['/students'], { queryParams: { saved: '1' } });
}

navigate returns a promise resolving true on success and false when a guard blocked it. Awaiting it matters when the next step depends on the navigation having happened.

// Losing state across navigation
this.router.navigate(['/students'], { state: { message: 'Student saved.' } });
const message = this.router.getCurrentNavigation()?.extras.state?.['message'];

state does not appear in the URL, so it does not survive a refresh — correct for a transient success banner, wrong for anything the page needs to render.

Route resolvers

Load data before the route activates, so the component never renders empty.

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

export const studentResolver: ResolveFn<Student> = (route) => {
const studentService = inject(StudentService);
const router = inject(Router);

const publicId = route.paramMap.get('publicId')!;

return studentService.getByPublicId(publicId).pipe(
catchError(() => {
router.navigate(['/students']);
return EMPTY;
})
);
};
{
path: 'students/:publicId',
loadComponent: () => import('./student-detail.component'),
resolve: { student: studentResolver }
}
export class StudentDetailComponent {
private readonly route = inject(ActivatedRoute);

readonly student = this.route.snapshot.data['student'] as Student;
}

A resolver blocks navigation until the data arrives, so the URL does not change and the old page stays visible. Fine for a fast request; on a slow one the application appears frozen, with nothing indicating why.

Use a resolver when the component is meaningless without the data. Otherwise load in the component and show a loading state — which is usually the better user experience.

Show progress during a slow resolve:

this.router.events.subscribe(event => {
if (event instanceof NavigationStart) { this.loading.set(true); }
if (event instanceof NavigationEnd || event instanceof NavigationCancel
|| event instanceof NavigationError) { this.loading.set(false); }
});

Scroll and titles

provideRouter(routes,
withInMemoryScrolling({
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled'
}))

scrollPositionRestoration: 'enabled' scrolls to the top on a new navigation and restores the previous position on a back navigation. Without it, navigating from halfway down a long list to a detail page leaves the user halfway down the new page.

{ path: 'students', loadComponent: () => import('./student-list.component'),
title: 'Students — NexCoding Academy' }
export const studentTitleResolver: ResolveFn<string> = (route) =>
`${route.data['student'].name} — NexCoding Academy`;

The title is announced by screen readers on navigation. Without it, a SPA navigation is silent to a screen-reader user — they have no indication the page changed.

Diagnosing routing problems

SymptomCause
Full page reload on a linkhref instead of routerLink
Infinite redirectMissing pathMatch: 'full' on an empty-path redirect
students/new loads the detail page:publicId route declared first
Every URL shows the 404 page** route not last
Detail page shows the previous studentReading snapshot instead of subscribing to paramMap
Parent nav link always activeMissing { exact: true }
No lazy chunk in the Network tabcomponent used instead of loadComponent
Filters lost on refreshState in a component property, not in query parameters
Back button unusable after searchingMissing replaceUrl
404 on refresh in productionServer not configured to fall back to index.html

That last one is worth expanding. /students/a1 exists only in the Angular router — the server has no such file. A direct request or a refresh must be served index.html so the router can take over:

location / {
try_files $uri $uri/ /index.html;
}
<!-- IIS: web.config -->
<rewrite>
<rules>
<rule name="Angular">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
</conditions>
<action type="Rewrite" url="/index.html" />
</rule>
</rules>
</rewrite>

Everything works in ng serve because the dev server does this automatically — which is exactly why the problem is only discovered after deployment.

Errors you will hit

What you seeCauseFix
NG04002: Cannot match any routesNo route for that URL, and no wildcardAdd a ** route
Component does not update when the id changesRead snapshot instead of subscribingSubscribe to paramMap
Refresh gives a 404 on the serverServer not configured for client-side routingRewrite all paths to index.html
A lazy route loads on startupImported the component eagerly somewhereCheck for a stray import
routerLink navigates but nothing rendersMissing <router-outlet>Add it

snapshot reads the parameter once. Navigating from one student to another reuses the component, and only a subscription sees the new id.

Common mistakes

  • href instead of routerLink
  • Missing pathMatch: 'full', causing a redirect loop
  • Specific routes declared after parameterised ones
  • ** not last
  • Reading snapshot.paramMap on a route the user can navigate within
  • Missing { exact: true } on routerLinkActive
  • Eager loading every feature
  • Filter state in a component property rather than the URL
  • No replaceUrl on a live search
  • A resolver on a slow request with no loading indicator
  • No title, so navigation is silent to screen readers
  • No scrollPositionRestoration
  • Server not configured for SPA fallback

Practice

  1. Build routes for the student list, detail, create and login, plus a ** fallback.
  2. Move ** to the top and confirm every route breaks.
  3. Declare students/:publicId before students/new and confirm new loads the detail page.
  4. Remove pathMatch: 'full' from the empty-path redirect. Record the redirect loop.
  5. Change one routerLink to href and watch the full reload in DevTools.
  6. Read snapshot.paramMap in the detail component. Navigate from one student to another via a link and confirm the page does not change.
  7. Fix it with paramMap and switchMap.
  8. Add routerLinkActive to the nav. Navigate to /students/new and confirm the Students link is still active. Add { exact: true }.
  9. Move search and paging into query parameters. Refresh and confirm the filter survives.
  10. Add replaceUrl: true to the search navigation. Type five characters, then press Back once.
  11. Convert a feature to loadChildren. Confirm the new chunk in the Network tab.
  12. Add withPreloading(PreloadAllModules) and confirm chunks download after startup.
  13. Add child routes with a shell component and confirm the shell is not recreated between tabs.
  14. Add a resolver, then throttle to Slow 3G and observe the frozen appearance. Add navigation-event loading.
  15. Add title to every route and navigate with a screen reader running.
  16. Run ng build, serve dist/ with a plain static server, and refresh on /students/a1. Confirm the 404, then configure the fallback.

Exercises 6 and 16 are the two that reach production most often.

You can now

  • Build a lazy-loaded routed application
  • Read route parameters so they update on navigation
  • Keep filters and paging in query parameters
  • Add a wildcard route for not-found
  • Say why a refresh 404s without server configuration

Review questions

  1. Why does href break a single-page application?
  2. Why is snapshot.paramMap wrong for a detail page reachable from another detail page?
  3. Why do filters belong in query parameters rather than component state?
  4. Why does refreshing a deep link 404 in production but not in ng serve?

Next: Template-driven forms