DEV Community

Atilla Baspinar
Atilla Baspinar

Posted on

Routing

Angular's router has three core pieces: routes (which component shows at which URL), outlets (where the component renders in the template), and links (how users navigate without a full page reload).


1. Setup

app.routes.ts

Define your routes in a dedicated file. Angular uses first-match wins, so order matters — put more specific routes before general ones. Unlike React Router (v6+), which automatically ranks routes by specificity regardless of order, Angular matches top-to-bottom and stops at the first hit:

import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { TasksComponent } from './tasks/tasks.component';
import { NotFoundComponent } from './not-found/not-found.component';

export const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'tasks', component: TasksComponent },
  { path: '**', component: NotFoundComponent },  // wildcard — must be last
];
Enter fullscreen mode Exit fullscreen mode

app.config.ts

Pass the routes to provideRouter in your app config:

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
  ],
};
Enter fullscreen mode Exit fullscreen mode

main.ts

import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, appConfig);
Enter fullscreen mode Exit fullscreen mode

2. <router-outlet>

Place <router-outlet /> in your root component's template where matched components should render:

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

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `
    <nav>...</nav>
    <router-outlet />
  `,
})
export class AppComponent {}
Enter fullscreen mode Exit fullscreen mode

3. routerLink — Navigation Links

Use routerLink instead of href to navigate without a full page reload:

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

@Component({
  imports: [RouterLink],
  template: `
    <a routerLink="/tasks">Tasks</a>
  `,
})
export class NavComponent {}
Enter fullscreen mode Exit fullscreen mode

routerLink also accepts an array, which is useful for building dynamic links:

<!-- static -->
<a [routerLink]="['/tasks']">Tasks</a>

<!-- with a dynamic param -->
<a [routerLink]="['/tasks', task.id]">{{ task.title }}</a>
Enter fullscreen mode Exit fullscreen mode

Paths starting with / are absolute — they always navigate from the app root. Paths without a leading slash are relative — they resolve from the current route:

<!-- absolute: always goes to /tasks -->
<a routerLink="/tasks">Tasks</a>

<!-- relative: from /users/123 → /users/123/tasks -->
<a routerLink="tasks">Tasks</a>

<!-- go up one level: from /users/123 → /users -->
<a routerLink="..">Back</a>
Enter fullscreen mode Exit fullscreen mode

For programmatic relative navigation, pass relativeTo to Router.navigate():

// from /users/123 → /users/123/edit
this.router.navigate(['edit'], { relativeTo: this.route });

// go up one level
this.router.navigate(['..'], { relativeTo: this.route });
Enter fullscreen mode Exit fullscreen mode

Active link styling with RouterLinkActive

RouterLinkActive adds a CSS class to the element when its route is active:

import { RouterLink, RouterLinkActive } from '@angular/router';

@Component({
  imports: [RouterLink, RouterLinkActive],
  template: `
    <a routerLink="/tasks" routerLinkActive="active-link">Tasks</a>
  `,
})
export class NavComponent {}
Enter fullscreen mode Exit fullscreen mode

For the root path, use [routerLinkActiveOptions]="{ exact: true }" to prevent it from matching every route:

<a routerLink="/" routerLinkActive="active-link" [routerLinkActiveOptions]="{ exact: true }">Home</a>
Enter fullscreen mode Exit fullscreen mode

4. Route Parameters

Define a dynamic segment with :paramName:

{ path: 'tasks/:id', component: TaskDetailComponent }
Enter fullscreen mode Exit fullscreen mode

Option A — ActivatedRoute

Inject ActivatedRoute and subscribe to its params or paramMap observable.

params gives a plain object — simple, but less safe:

this.route.params.subscribe(params => {
  this.taskId.set(params['id']);
});
Enter fullscreen mode Exit fullscreen mode

paramMap is preferred — it provides helper methods (get(), has(), getAll()) and handles multi-value params:

import { Component, inject, signal, DestroyRef } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Component({ ... })
export class TaskDetailComponent {
  taskId = signal('');
  private route = inject(ActivatedRoute);

  constructor() {
    this.route.paramMap
      .pipe(takeUntilDestroyed())
      .subscribe(params => {
        this.taskId.set(params.get('id') ?? '');
      });
  }
}
Enter fullscreen mode Exit fullscreen mode

Use takeUntilDestroyed() to automatically unsubscribe when the component is destroyed. Call it inside the constructor (or any injection context) — it needs access to DestroyRef internally.

Option B — input() binding (Angular 16+)

Since Angular 16 you can bind route params directly to component inputs. Enable it once in app.config.ts:

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

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
  ],
};
Enter fullscreen mode Exit fullscreen mode

Then declare an input whose name matches the route param — no ActivatedRoute needed:

import { Component, input } from '@angular/core';

@Component({ ... })
export class TaskDetailComponent {
  id = input<string>();  // automatically set to the :id param
}
Enter fullscreen mode Exit fullscreen mode

This also works with @Input() for class-based style. Prefer input() signals for new code.

Redirect routes

Redirect one path to another using redirectTo. Use pathMatch: 'full' on the empty path so it doesn't match every route:

{ path: '', redirectTo: '/tasks', pathMatch: 'full' },
Enter fullscreen mode Exit fullscreen mode

5. Programmatic Navigation

Inject the Router service to navigate from code (e.g. after a form submit):

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

@Component({ ... })
export class NewTaskComponent {
  private router = inject(Router);

  onSubmit() {
    // ... save task
    this.router.navigate(['/tasks']);
    // or with a param:
    this.router.navigate(['/tasks', taskId]);
  }
}
Enter fullscreen mode Exit fullscreen mode

navigateByUrl is an alternative when you have a full URL string:

this.router.navigateByUrl('/tasks');
Enter fullscreen mode Exit fullscreen mode

6. Page Titles

Set the browser tab title per route with the title property:

{ path: 'tasks', component: TasksComponent, title: 'My Tasks' },
Enter fullscreen mode Exit fullscreen mode

The title can also be a resolver function for dynamic titles:

{
  path: 'tasks/:id',
  component: TaskDetailComponent,
  title: (route: ActivatedRouteSnapshot) => `Task ${route.paramMap.get('id')}`,
}
Enter fullscreen mode Exit fullscreen mode

7. Nested Routes

Use children to nest routes under a parent. The parent component must contain its own <router-outlet /> where child components render.

export const routes: Routes = [
  {
    path: 'users/:userId',
    component: UserShellComponent,  // renders the layout, contains <router-outlet />
    children: [
      { path: '', component: UserDetailComponent },    // /users/42
      { path: 'edit', component: UserEditComponent },  // /users/42/edit
    ],
  },
];
Enter fullscreen mode Exit fullscreen mode

UserShellComponent holds the shared layout (e.g. a header with the user's name) and a <router-outlet /> for the active child:

@Component({
  imports: [RouterOutlet],
  template: `
    <h2>User Profile</h2>
    <router-outlet />
  `,
})
export class UserShellComponent {}
Enter fullscreen mode Exit fullscreen mode

Child components can read the :userId param from ActivatedRoute directly — the router provides the parent route's params to all child routes:

@Component({ ... })
export class UserDetailComponent {
  private route = inject(ActivatedRoute);

  constructor() {
    this.route.paramMap
      .pipe(takeUntilDestroyed())
      .subscribe(params => {
        const userId = params.get('userId');
        // load user...
      });
  }
}
Enter fullscreen mode Exit fullscreen mode

8. snapshot vs Observable

ActivatedRoute exposes two ways to read route state:

  • snapshot — a one-time read of the current route. Fast and simple, but not reactive.
  • paramMap observable — emits every time the route changes. Required when the component stays mounted while params change.

The trap with snapshot: if you navigate from /users/1 to /users/2 and the UserDetailComponent is reused (not destroyed), the snapshot stays frozen at the first value and your UI won't update.

// ❌ snapshot — only reads once, misses future param changes
const userId = this.route.snapshot.paramMap.get('userId');
Enter fullscreen mode Exit fullscreen mode
// ✓ observable — reacts to every navigation
this.route.paramMap
  .pipe(takeUntilDestroyed())
  .subscribe(params => {
    const userId = params.get('userId');
    // load user...
  });
Enter fullscreen mode Exit fullscreen mode

Use snapshot only when you know the component is always destroyed and recreated on navigation (i.e. it's not a reused route). In all other cases, subscribe to the observable.


9. Query Params

Query params are the ?key=value part of the URL (e.g. /tasks?order=asc). Unlike route params, they are optional and don't affect which route is matched.

Setting query params

Via routerLink using [queryParams]:

<a routerLink="." [queryParams]="{ order: 'asc' }">Sort ascending</a>
Enter fullscreen mode Exit fullscreen mode

Or programmatically:

this.router.navigate(['/tasks'], { queryParams: { order: 'asc' } });
Enter fullscreen mode Exit fullscreen mode

Use queryParamsHandling: 'merge' to update one param while keeping the rest:

this.router.navigate([], {
  queryParams: { order: 'desc' },
  queryParamsHandling: 'merge',
  relativeTo: this.route,
});
Enter fullscreen mode Exit fullscreen mode

Reading query params

Option A — input() signal (Angular 16+)

With withComponentInputBinding() enabled, query params are automatically bound to inputs whose name matches the param key:

@Component({ ... })
export class TaskListComponent {
  order = input<'asc' | 'desc'>('asc');  // bound to ?order=...
}
Enter fullscreen mode Exit fullscreen mode
<!-- read and pass the current value back when linking -->
<a routerLink="." [queryParams]="{ order: order() }">Re-sort</a>
Enter fullscreen mode Exit fullscreen mode

Option B — observable

@Component({ ... })
export class TaskListComponent {
  private route = inject(ActivatedRoute);
  order: 'asc' | 'desc' = 'asc';

  constructor() {
    this.route.queryParams
      .pipe(takeUntilDestroyed())
      .subscribe(params => {
        this.order = params['order'] ?? 'asc';
      });
  }
}
Enter fullscreen mode Exit fullscreen mode

10. Route Data Resolvers

A resolver fetches data before a route activates, so the component receives it immediately on load instead of having to handle a loading state itself. Define one with ResolveFn:

// user-tasks.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn, ActivatedRouteSnapshot } from '@angular/router';
import { TaskService } from './task.service';
import { Task } from './task.model';

export const userTasksResolver: ResolveFn<Task[]> = (route: ActivatedRouteSnapshot) => {
  const userId = route.paramMap.get('userId')!;
  return inject(TaskService).getTasksByUser(userId);
};
Enter fullscreen mode Exit fullscreen mode
// user-friends.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn, ActivatedRouteSnapshot } from '@angular/router';
import { UserService } from './user.service';
import { User } from './user.model';

export const userFriendsResolver: ResolveFn<User[]> = (route: ActivatedRouteSnapshot) => {
  const userId = route.paramMap.get('userId')!;
  return inject(UserService).getFriends(userId);
};
Enter fullscreen mode Exit fullscreen mode

Attach both to the route under the resolve key:

{
  path: 'users/:userId',
  component: UserDetailComponent,
  resolve: {
    tasks: userTasksResolver,
    friends: userFriendsResolver,
  },
}
Enter fullscreen mode Exit fullscreen mode

By default, resolvers only re-run when path params change. Use runGuardsAndResolvers to control when they execute:

{
  path: 'users/:userId',
  component: UserDetailComponent,
  resolve: { tasks: userTasksResolver },
  runGuardsAndResolvers: 'paramsOrQueryParamsChange',  // also re-run on ?sort=... changes
}
Enter fullscreen mode Exit fullscreen mode
Value Re-runs when
paramsChange (default) Path or path params change
pathParamsChange Only path params change (ignores query params)
paramsOrQueryParamsChange Path params or query params change
always Every navigation, regardless of what changed

Read the resolved data in the component via ActivatedRoute.data:

import { Component, inject, computed } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({ ... })
export class UserDetailComponent {
  private data = toSignal(inject(ActivatedRoute).data);

  tasks   = computed(() => this.data()?.['tasks']);
  friends = computed(() => this.data()?.['friends']);
}
Enter fullscreen mode Exit fullscreen mode

Or with withComponentInputBinding() (Angular 16+), resolved data is bound directly to inputs — no ActivatedRoute needed:

@Component({ ... })
export class UserDetailComponent {
  tasks   = input<Task[]>();
  friends = input<User[]>();
}
Enter fullscreen mode Exit fullscreen mode

The resolver can return a value, a Promise, or an Observable. Angular waits for it to complete before rendering the component.

Comparison to React Router loaders

React Router v6.4+ has a very similar concept called loaders. The main differences:

Angular resolver React loader
Definition Separate ResolveFn function Inline loader function on the route object
Reading data ActivatedRoute.data or input() useLoaderData() hook
Multiple resolvers Multiple keys under resolve: {} Single loader, return an object with multiple keys
Cancellation Unsubscribes Observable on navigation cancel Uses request.signal (AbortSignal)

Both block navigation until data is ready, and both support returning Promises or observables/async values.

11. Route Guards

Guards are functions that run before (or after) a navigation and decide whether it should proceed. Since Angular 14, the preferred style is functional guards — plain functions using inject(), no class needed. Class-based guards still work but are deprecated.

Guard Applied on route as Runs when
canActivate canActivate: [guard] Entering a route
canActivateChild canActivateChild: [guard] Entering any child route
canDeactivate canDeactivate: [guard] Leaving a route
canMatch canMatch: [guard] Deciding whether a route can be matched at all

A guard returns true to allow navigation, false to block it, or a RedirectCommand / UrlTree to redirect instead.

canActivate — protect a route

// auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router, RedirectCommand } from '@angular/router';
import { AuthService } from './auth.service';

export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);

  if (auth.isLoggedIn()) {
    return true;
  }

  return new RedirectCommand(router.parseUrl('/login'));
};
Enter fullscreen mode Exit fullscreen mode

Attach it to a route:

{
  path: 'dashboard',
  component: DashboardComponent,
  canActivate: [authGuard],
}
Enter fullscreen mode Exit fullscreen mode

Return new RedirectCommand(urlTree) instead of false — it redirects the user cleanly without requiring a separate router.navigate() call.

canDeactivate — guard against leaving

Useful for unsaved form changes. The guard receives the current component instance, so it can check its state:

import { CanDeactivateFn } from '@angular/router';
import { TaskEditComponent } from './task-edit.component';

export const unsavedChangesGuard: CanDeactivateFn<TaskEditComponent> = (component) => {
  if (component.hasUnsavedChanges()) {
    return confirm('You have unsaved changes. Leave anyway?');
  }
  return true;
};
Enter fullscreen mode Exit fullscreen mode
{
  path: 'tasks/:id/edit',
  component: TaskEditComponent,
  canDeactivate: [unsavedChangesGuard],
}
Enter fullscreen mode Exit fullscreen mode

canMatch — conditional route matching

Unlike canActivate (which blocks navigation), canMatch makes the route invisible to the router — if it returns false, Angular falls through to the next matching route. Useful for feature flags or role-based route variants:

import { CanMatchFn } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';

export const adminOnlyGuard: CanMatchFn = () => {
  return inject(AuthService).isAdmin();
};
Enter fullscreen mode Exit fullscreen mode
{ path: 'settings', component: AdminSettingsComponent, canMatch: [adminOnlyGuard] },
{ path: 'settings', component: UserSettingsComponent },  // fallback for non-admins
Enter fullscreen mode Exit fullscreen mode

Top comments (0)