The Angular Router — Routes, RouterOutlet, and Navigation

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' })
Note: The difference between 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.
Tip: Use route 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.
Warning: Always include a wildcard catch-all route (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

🧠 Test Yourself

A route definition has { path: '', redirectTo: '/dashboard' } without pathMatch. What unexpected behaviour occurs?