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-outletandrouterLink- 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.
router-outlet and routerLink
<!-- 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'
});
}
}
| Option | Effect |
|---|---|
queryParamsHandling: 'merge' | Keep existing parameters, override the named ones |
queryParamsHandling: 'preserve' | Keep all existing, ignore new ones |
| omitted | Replace all query parameters |
value null | Remove 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
| Symptom | Cause |
|---|---|
| Full page reload on a link | href instead of routerLink |
| Infinite redirect | Missing 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 student | Reading snapshot instead of subscribing to paramMap |
| Parent nav link always active | Missing { exact: true } |
| No lazy chunk in the Network tab | component used instead of loadComponent |
| Filters lost on refresh | State in a component property, not in query parameters |
| Back button unusable after searching | Missing replaceUrl |
| 404 on refresh in production | Server 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 see | Cause | Fix |
|---|---|---|
NG04002: Cannot match any routes | No route for that URL, and no wildcard | Add a ** route |
| Component does not update when the id changes | Read snapshot instead of subscribing | Subscribe to paramMap |
| Refresh gives a 404 on the server | Server not configured for client-side routing | Rewrite all paths to index.html |
| A lazy route loads on startup | Imported the component eagerly somewhere | Check for a stray import |
routerLink navigates but nothing renders | Missing <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
hrefinstead ofrouterLink- Missing
pathMatch: 'full', causing a redirect loop - Specific routes declared after parameterised ones
**not last- Reading
snapshot.paramMapon a route the user can navigate within - Missing
{ exact: true }onrouterLinkActive - Eager loading every feature
- Filter state in a component property rather than the URL
- No
replaceUrlon 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
- Build routes for the student list, detail, create and login, plus a
**fallback. - Move
**to the top and confirm every route breaks. - Declare
students/:publicIdbeforestudents/newand confirmnewloads the detail page. - Remove
pathMatch: 'full'from the empty-path redirect. Record the redirect loop. - Change one
routerLinktohrefand watch the full reload in DevTools. - Read
snapshot.paramMapin the detail component. Navigate from one student to another via a link and confirm the page does not change. - Fix it with
paramMapandswitchMap. - Add
routerLinkActiveto the nav. Navigate to/students/newand confirm the Students link is still active. Add{ exact: true }. - Move search and paging into query parameters. Refresh and confirm the filter survives.
- Add
replaceUrl: trueto the search navigation. Type five characters, then press Back once. - Convert a feature to
loadChildren. Confirm the new chunk in the Network tab. - Add
withPreloading(PreloadAllModules)and confirm chunks download after startup. - Add child routes with a shell component and confirm the shell is not recreated between tabs.
- Add a resolver, then throttle to Slow 3G and observe the frozen appearance. Add navigation-event loading.
- Add
titleto every route and navigate with a screen reader running. - Run
ng build, servedist/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
- Why does
hrefbreak a single-page application? - Why is
snapshot.paramMapwrong for a detail page reachable from another detail page? - Why do filters belong in query parameters rather than component state?
- Why does refreshing a deep link 404 in production but not in
ng serve?
Next: Template-driven forms