The Angular Router transforms what would otherwise be a full-page reload into seamless in-app navigation. It maps URL paths to component trees, manages the browser’s history stack, handles deep linking (bookmarking and sharing URLs), and coordinates lazy loading. Understanding the Router’s architecture — how route definitions are processed, how <router-outlet> works, how to navigate programmatically, how to work with nested routes, and how to control scroll behaviour — is the foundation for building Angular applications that feel like real applications rather than single-page experiments.
Route Configuration Properties
| Property | Type | Purpose |
|---|---|---|
path |
string | URL segment to match — '', 'tasks', 'tasks/:id', '**' |
component |
Component class | Eagerly loaded component |
loadComponent |
Function returning Promise | Lazy-loaded standalone component |
loadChildren |
Function returning Promise | Lazy-loaded child routes array |
children |
Route[] | Nested child routes (eager) |
redirectTo |
string | Redirect to another path |
pathMatch |
'full' | 'prefix' |
How strictly to match the path (required with redirectTo) |
canActivate |
CanActivateFn[] | Guards that run before route activation |
canDeactivate |
CanDeactivateFn[] | Guards that run before leaving a route |
canMatch |
CanMatchFn[] | Guards that determine if route can be matched |
resolve |
Record<string, ResolveFn> | Pre-fetch data before activating route |
data |
object | Static data attached to route — read via ActivatedRoute.data |
title |
string or TitleStrategy | Document title for this route |
providers |
Provider[] | Services scoped to this route subtree |
Router Navigation Methods
| Method | Use For | Example |
|---|---|---|
router.navigate([...]) |
Navigate to an absolute or relative path | router.navigate(['/tasks', id]) |
router.navigateByUrl(url) |
Navigate to a full URL string | router.navigateByUrl('/auth/login') |
[routerLink]="[...]" |
Declarative link in template | [routerLink]="['/tasks', task._id]" |
routerLinkActive="class" |
Add CSS class when link is active | routerLinkActive="nav__link--active" |
router.navigate([], { queryParams }) |
Update query params without navigation | router.navigate([], { queryParams: { page: 2 }, queryParamsHandling: 'merge' }) |
navigate() and navigateByUrl() matters when working with relative routes and query parameters. navigate(['/tasks', id]) compiles a commands array into a URL and handles relative navigation correctly. navigateByUrl('/tasks/42') navigates to an exact URL string and always treats it as absolute — relative navigation does not work. Use navigate() for programmatic navigation in components and services; use navigateByUrl() only when you have a complete URL string from an external source.data to attach metadata to routes — page titles, breadcrumbs, required permissions. data: { title: 'Task Details', breadcrumb: 'Details', requiresRole: 'admin' } is readable via ActivatedRoute.data or ActivatedRouteSnapshot.data in guards and resolvers. Combine with Angular’s built-in title strategy by setting title: 'Task Details' directly on the route — Angular updates document.title automatically on navigation. This is far cleaner than updating the title in every component’s ngOnInit.path: '**') as the last route in your configuration, pointing to a 404 Not Found component. Without it, navigating to an unknown URL silently shows nothing — Angular cannot find a route match and renders an empty <router-outlet>. The wildcard route must be last because the Router matches routes in order from top to bottom — a ** at the top would match everything.Complete Router Configuration
// app.routes.ts — complete route configuration
import { Routes } from '@angular/router';
import { authGuard, roleGuard, unsavedChangesGuard } from './core/guards';
import { taskResolver } from './core/resolvers/task.resolver';
export const routes: Routes = [
// ── Root redirect ─────────────────────────────────────────────────────
{
path: '',
redirectTo: '/tasks',
pathMatch: 'full', // only matches empty path exactly
},
// ── Auth routes — eager (small, needed immediately) ───────────────────
{
path: 'auth',
children: [
{
path: 'login',
title: 'Sign In',
loadComponent: () =>
import('./features/auth/login/login.component')
.then(m => m.LoginComponent),
},
{
path: 'register',
title: 'Create Account',
loadComponent: () =>
import('./features/auth/register/register.component')
.then(m => m.RegisterComponent),
},
{
path: 'forgot-password',
title: 'Forgot Password',
loadComponent: () =>
import('./features/auth/forgot-password/forgot-password.component')
.then(m => m.ForgotPasswordComponent),
},
{ path: '', redirectTo: 'login', pathMatch: 'full' },
],
},
// ── Protected routes — guarded + lazy loaded ──────────────────────────
{
path: 'tasks',
canActivate: [authGuard], // functional guard
providers: [TaskStore], // route-scoped service
children: [
{
path: '',
title: 'My Tasks',
loadComponent: () =>
import('./features/tasks/task-list/task-list.component')
.then(m => m.TaskListComponent),
},
{
path: 'new',
title: 'New Task',
loadComponent: () =>
import('./features/tasks/task-form/task-form.component')
.then(m => m.TaskFormComponent),
canDeactivate: [unsavedChangesGuard], // warn before leaving dirty form
},
{
path: ':id',
title: 'Task Details',
resolve: { task: taskResolver }, // pre-fetch before render
loadComponent: () =>
import('./features/tasks/task-detail/task-detail.component')
.then(m => m.TaskDetailComponent),
},
{
path: ':id/edit',
title: 'Edit Task',
resolve: { task: taskResolver },
loadComponent: () =>
import('./features/tasks/task-form/task-form.component')
.then(m => m.TaskFormComponent),
canDeactivate: [unsavedChangesGuard],
},
],
},
// ── Admin routes — role-based guard ───────────────────────────────────
{
path: 'admin',
canActivate: [authGuard, () => roleGuard('admin')],
data: { breadcrumb: 'Admin' },
loadChildren: () =>
import('./features/admin/admin.routes')
.then(m => m.adminRoutes), // lazy-loaded child route file
},
// ── 404 catch-all — MUST be last ─────────────────────────────────────
{
path: '**',
title: 'Page Not Found',
loadComponent: () =>
import('./features/not-found/not-found.component')
.then(m => m.NotFoundComponent),
},
];
// app.config.ts — configure router with options
import { provideRouter, withRouterConfig, withInMemoryScrolling } from '@angular/router';
export const appConfig = {
providers: [
provideRouter(routes,
withRouterConfig({
onSameUrlNavigation: 'reload', // re-run guards/resolvers on same URL
paramsInheritanceStrategy: 'always', // child routes inherit parent params
}),
withInMemoryScrolling({
scrollPositionRestoration: 'top', // scroll to top on navigation
anchorScrolling: 'enabled', // support #anchor links
}),
),
],
};
Navigation in Templates and Components
<!-- routerLink — declarative navigation ─────────────────────────────── -->
<!-- Absolute path -->
<a routerLink="/tasks">All Tasks</a>
<!-- Path array — assembles /tasks/42 -->
<a [routerLink]="['/tasks', task._id]">{{ task.title }}</a>
<!-- With query params: /tasks?status=pending&page=1 -->
<a [routerLink]="['/tasks']"
[queryParams]="{ status: 'pending', page: 1 }">Pending Tasks</a>
<!-- With fragment: /tasks#overdue -->
<a [routerLink]="['/tasks']" fragment="overdue">Overdue Tasks</a>
<!-- Active class — adds class when route matches -->
<a routerLink="/tasks"
routerLinkActive="nav__link--active"
[routerLinkActiveOptions]="{ exact: true }">Tasks</a>
<!-- Relative navigation (relative to current route) -->
<a [routerLink]="['../edit']">Edit</a>
<a [routerLink]="['../../tasks']">Back to List</a>
<!-- router-outlet — where routed components are rendered ────────────── -->
<nav>
<a routerLink="/tasks" routerLinkActive="active">Tasks</a>
<a routerLink="/admin" routerLinkActive="active">Admin</a>
</nav>
<main>
<router-outlet></router-outlet> <!-- active route component renders here -->
</main>
<!-- Named outlets — multiple outlets on same page -->
<router-outlet name="sidebar"></router-outlet>
// Programmatic navigation in components
import { Component, inject } from '@angular/core';
import { Router, ActivatedRoute, NavigationExtras } from '@angular/router';
@Component({ ... })
export class TaskListComponent {
private router = inject(Router);
private route = inject(ActivatedRoute);
// Navigate with path array
goToTask(id: string): void {
this.router.navigate(['/tasks', id]);
}
// Navigate with options
goToEditTask(id: string): void {
this.router.navigate(['/tasks', id, 'edit'], {
state: { returnUrl: this.router.url }, // pass navigation state
replaceUrl: false, // push to history (true = replace current entry)
skipLocationChange:false, // update address bar (true = don't update URL)
});
}
// Navigate relative to current route
goToNextPage(): void {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { page: this.currentPage + 1 },
queryParamsHandling:'merge', // keep other query params
});
}
// Navigate back (browser history)
goBack(): void {
history.back();
// OR: this.location.back(); (inject Location from @angular/common)
}
// Navigate after action
async createTask(dto: CreateTaskDto): Promise<void> {
const task = await firstValueFrom(this.taskService.create(dto));
this.router.navigate(['/tasks', task._id], { replaceUrl: true });
}
}
// Read navigation state passed from previous route
const navigation = this.router.getCurrentNavigation();
const returnUrl = navigation?.extras.state?.['returnUrl'] as string ?? '/tasks';
How It Works
Step 1 — The Router Matches URLs to Route Configurations
When the URL changes (user clicks a link, calls navigate(), or the browser loads), the Router compares the new URL against the routes array from top to bottom. The first matching route wins. Static segments (tasks) match literally. Parameter segments (:id) match any single URL segment and extract the value. The wildcard ** matches anything. Child routes append their path to the parent’s — /tasks/:id/edit is the :id/edit child within tasks.
Step 2 — router-outlet Renders the Matched Component
<router-outlet> is a placeholder directive that renders the currently matched route’s component as its sibling in the DOM. When you navigate from /tasks to /tasks/42, the Router destroys the task list component and creates the task detail component in the outlet’s place. For nested routes, child <router-outlet> elements in parent components render the child route’s component while the parent remains visible.
Step 3 — Lazy Loading Downloads Code on Demand
loadComponent: () => import('./feature.component').then(m => m.FeatureComponent) tells the build tool to create a separate JavaScript chunk for this component. When the user first navigates to that route, Angular downloads the chunk, evaluates it, and renders the component. Subsequent navigations use the cached module. The initial page load only downloads the shell (app component, root routes, eagerly imported services), dramatically improving time-to-interactive.
Step 4 — queryParamsHandling Controls Query String Merging
When navigating, you control what happens to existing query parameters. queryParamsHandling: 'merge' adds your new params to the existing ones — existing params you do not specify are preserved. queryParamsHandling: 'preserve' keeps all existing params unchanged (your new params are ignored). The default (no option or '') replaces all query params entirely with your new set. For filter + pagination, 'merge' is correct: updating the filter resets the page, but updating the page keeps the filter.
Step 5 — Resolvers Pre-Fetch Data Before Route Activates
A resolver is a function that runs before the target component is instantiated. The component is only rendered after all resolvers have completed. The resolved data is available via ActivatedRoute.snapshot.data or ActivatedRoute.data. This prevents the component from rendering in an empty/loading state — the data is ready the moment the component appears. For SEO-critical or UX-sensitive pages, resolvers eliminate loading skeletons.
Common Mistakes
Mistake 1 — Wildcard route placed before specific routes
❌ Wrong — ** matches /tasks before the tasks route:
const routes = [
{ path: '**', component: NotFoundComponent }, // placed first!
{ path: 'tasks', loadComponent: () => ... }, // NEVER reached
];
✅ Correct — wildcard always last:
const routes = [
{ path: 'tasks', loadComponent: () => ... },
{ path: '**', loadComponent: () => ... }, // last
];
Mistake 2 — Forgetting pathMatch: ‘full’ on root redirect
❌ Wrong — prefix match on ” redirects EVERY path including /tasks:
{ path: '', redirectTo: '/tasks' } // missing pathMatch: 'full'
// /tasks/42 redirects to /tasks because '' is a prefix of every URL!
✅ Correct — full match only redirects the empty path:
{ path: '', redirectTo: '/tasks', pathMatch: 'full' }
Mistake 3 — Using navigateByUrl for relative navigation
❌ Wrong — navigateByUrl is always absolute:
// On page /tasks/42:
this.router.navigateByUrl('../edit'); // navigates to /edit, not /tasks/42/edit!
✅ Correct — use navigate() with relativeTo for relative navigation:
this.router.navigate(['edit'], { relativeTo: this.route }); // → /tasks/42/edit
Quick Reference
| Task | Code |
|---|---|
| Absolute navigate | router.navigate(['/tasks', id]) |
| Relative navigate | router.navigate(['edit'], { relativeTo: this.route }) |
| With query params | router.navigate(['/tasks'], { queryParams: { page: 2 } }) |
| Merge query params | router.navigate([], { queryParams: { page: 2 }, queryParamsHandling: 'merge' }) |
| Template link | [routerLink]="['/tasks', task._id]" |
| Active class | routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }" |
| Root redirect | { path: '', redirectTo: '/tasks', pathMatch: 'full' } |
| Lazy component | loadComponent: () => import('./comp').then(m => m.MyComp) |
| Lazy child routes | loadChildren: () => import('./routes').then(m => m.routes) |
| Route title | title: 'My Page' in route config |