Angular Router Configuration

The Angular Router is the most opinionated of the mainstream client-side routers: it ships as a first-party package, models routes as a declarative tree of configuration objects, and wires that tree to the browser through a swappable location strategy. Where a React or Vue application assembles routing from smaller primitives, Angular hands you a complete resolution pipeline — matching, guards, resolvers, lazy loading, and outlet rendering — governed by a single Routes array. This page explains how that configuration works end to end, how the standalone provideRouter API relates to the older RouterModule.forRoot, and how the whole machine sits on top of the browser’s History API.

← Back to Framework-Specific Routing Patterns

The Problem

Angular’s router is powerful, but its surface area is large enough that teams routinely misconfigure it in ways that only surface in production. The recurring failures fall into a few shapes.

First, strategy mismatch. The router defaults to PathLocationStrategy, which writes clean URLs through history.pushState. That requires the server to rewrite unknown paths back to index.html; deploy to static hosting that does not, and every hard refresh of /dashboard/reports returns a 404. The escape hatch — HashLocationStrategy — trades clean URLs for host-agnostic deployment, and choosing between them after the fact means rewriting links and breaking bookmarks.

Second, eager everything. A newcomer wires every feature area into the root Routes array with a direct component reference. The whole application then compiles into the initial bundle, and first paint drags because the router pulled in an admin console the visitor may never open. Angular’s answer — loadChildren and loadComponent — is opt-in, so the slow default persists until someone deliberately splits the tree.

Third, guard and resolver confusion. Angular offers CanActivate, CanActivateChild, CanMatch, CanDeactivate, and resolvers, and the difference between “block the route from activating” and “block the route from even matching” is subtle but decisive for lazy loading — a CanActivate guard still downloads the lazy bundle before rejecting, whereas CanMatch refuses before a single byte is fetched.

Underneath all of this is the same primitive every SPA router leans on: the History API. Angular abstracts it behind the Location service and LocationStrategy, but understanding that pushState and the popstate event drive the entire router lets you reason about why refreshes 404, why back-navigation re-runs guards, and where to intervene. The broader trade-offs live on the Framework-Specific Routing Patterns overview; this page is the Angular-specific working guide.

Core API & Primitives

The router is configured with an ordered array of Route objects. The type is worth internalising, because nearly every feature is a field on it.

// Angular 17+ (@angular/router)
import { Routes, Route } from '@angular/router';

// A minimal, representative shape of what a Route can hold:
const routes: Routes = [
  { path: '', component: HomeComponent },                    // static match
  { path: 'users/:id', component: UserDetailComponent },     // param match
  { path: 'admin', canMatch: [adminGuard],                   // gated + lazy
    loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES) },
  { path: 'profile', canActivate: [authGuard],
    resolve: { user: userResolver }, component: ProfileComponent },
  { path: '', redirectTo: 'home', pathMatch: 'full' },       // redirect
  { path: '**', component: NotFoundComponent },              // wildcard fallback
];

The fields that matter most:

  • path — matched segment-by-segment against the URL. :id captures a dynamic segment into ActivatedRoute.paramMap; ** is the catch-all that must sit last.
  • component vs loadComponent vs loadChildren — render eagerly, lazily load a single standalone component, or lazily load a nested child Routes array respectively.
  • canActivate / canMatch — arrays of guard functions. canMatch decides whether the route participates in matching at all (and therefore whether its lazy bundle downloads); canActivate runs after a match is found.
  • resolve — a map of data resolvers run before activation, so the component renders with its data already present.
  • pathMatch'prefix' (default) or 'full'; redirects almost always need 'full'.

Two rendering primitives complete the picture. <router-outlet> is the placeholder the router fills with the matched component; nesting outlets renders nested routes. routerLink is the declarative navigation directive that produces a real anchor and calls the router instead of triggering a full document load:

// Angular 17+ (@angular/router)
// In a standalone component template:
// <a routerLink="/users/42" routerLinkActive="active">User 42</a>
// <router-outlet></router-outlet>
How an Angular navigation flows from a link to a rendered outlet A left-to-right pipeline: routerLink triggers the Router, which matches the Routes config, runs CanMatch and CanActivate guards and resolvers, calls the History API, and renders the component into router-outlet. routerLink click / navigate() Router match Routes Guards + resolvers History API pushState / popstate router-outlet renders component
A single navigation runs matching, then guards and resolvers, then commits the URL through the History API before the component appears in the outlet.

Step-by-Step Implementation

The steps below build a modern standalone Angular application (no NgModule) and then note the equivalent RouterModule.forRoot wiring for codebases that still use modules.

Step 1: Declare the route tree

Keep routes in a dedicated file so both the standalone and module paths can import the same array. Order matters only for redirects and the wildcard — matching itself is specificity-aware, but ** must be last.

// Angular 17+ (@angular/router) — app.routes.ts
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';

export const routes: Routes = [
  { path: '', component: HomeComponent, title: 'Home' },
  {
    path: 'users/:id',
    loadComponent: () => import('./users/user-detail.component')
      .then(m => m.UserDetailComponent),
  },
  {
    path: 'admin',
    canMatch: [() => import('./admin/admin.guard').then(m => m.canEnterAdmin)],
    loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
  },
  { path: '**', loadComponent: () => import('./not-found.component')
      .then(m => m.NotFoundComponent) },
];

Step 2: Provide the router at bootstrap

The standalone API replaces RouterModule.forRoot(routes) with provideRouter(routes, ...features). Bootstrap the application root and pass router features as needed.

// Angular 17+ (@angular/router) — main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(
      routes,
      withComponentInputBinding(),                          // bind :id to @Input()
      withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
    ),
  ],
});

The NgModule equivalent, for older codebases, is RouterModule.forRoot(routes, { bindToComponentInputs: true }) imported into AppModule. Feature modules use RouterModule.forChild(childRoutes). Both compile to the same runtime Router; provideRouter is simply the tree-shakable, boilerplate-free form and is the recommended default for new work.

Step 3: Place the outlet and navigate

Add <router-outlet> where matched components should render, and use routerLink for declarative navigation. Standalone components import RouterOutlet and RouterLink directly.

// Angular 17+ (@angular/router) — app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, RouterLink, RouterLinkActive],
  template: `
    <nav>
      <a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Home</a>
      <a routerLink="/users/42" routerLinkActive="active">User 42</a>
      <a routerLink="/admin" routerLinkActive="active">Admin</a>
    </nav>
    <router-outlet />
  `,
})
export class AppComponent {}

For imperative navigation — after a form submit, say — inject Router and call router.navigate(['/users', id]), which under the hood calls history.pushState through the active LocationStrategy.

Step 4: Choose a location strategy

PathLocationStrategy is the default and produces clean URLs via the History API. It is the right choice whenever you control the server and can add a catch-all rewrite to index.html. Where you cannot — GitHub Pages, some CDNs, file-served builds — switch to HashLocationStrategy, which keeps all routing state after the # so the server only ever sees /index.html.

// Angular 17+ (@angular/router) — opting into hash URLs
import { provideRouter, withHashLocation } from '@angular/router';

provideRouter(routes, withHashLocation());
// URLs become /#/users/42 instead of /users/42 — no server rewrite needed.
// NgModule form: RouterModule.forRoot(routes, { useHash: true })

The trade-off is concrete: PathLocationStrategy gives canonical, indexable, share-friendly URLs but needs server cooperation; HashLocationStrategy deploys anywhere but hides the real path from the server, weakens SEO, and looks dated. Decide once, early. The wider framing lives on Hash Routing vs History Mode.

Step 5: Add guards and resolvers

Modern guards and resolvers are plain functions that use inject() to reach services. Return true, a UrlTree (to redirect), or an async equivalent. Prefer CanMatch over CanActivate on lazy routes so a rejected user never downloads the bundle.

// Angular 17+ (@angular/router) — functional guard + resolver
import { CanMatchFn, ResolveFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
import { UserService, User } from './user.service';

export const canEnterAdmin: CanMatchFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  return auth.isAdmin() ? true : router.parseUrl('/login');
};

export const userResolver: ResolveFn<User> = (route) => {
  const id = route.paramMap.get('id')!;
  return inject(UserService).getUser(id); // route waits for this to settle
};

Wire canEnterAdmin into the route’s canMatch array and userResolver into its resolve map; the resolved data arrives on ActivatedRoute.data, or is bound straight to an @Input() when withComponentInputBinding() is enabled.

The five Angular route guard types Five guard types with what each decides: canActivate and canActivateChild after matching, canMatch before matching completes, canDeactivate on leaving, and resolve which blocks activation until data arrives. Angular guard types and what each decides guard decides canActivate may this route render? after it has been matched canActivateChild may any child render? applied to a subtree canMatch should this route match at all? before matching completes canDeactivate may the user leave? unsaved-change confirmations resolve what data is needed first? blocks activation until it settles
canMatch is the useful one people miss: a route that does not match falls through to the next candidate instead of being blocked.

Verification & Testing

Drive real navigation with Playwright so you exercise the actual History API path, guard execution, and outlet rendering rather than mocking the router.

// @playwright/test v1.44
import { test, expect } from '@playwright/test';

test('lazy admin route is gated by CanMatch', async ({ page }) => {
  await page.goto('/');
  await page.click('a[href="/admin"]');
  // Unauthenticated users are redirected by the CanMatch guard's UrlTree.
  await expect(page).toHaveURL(/\/login/);
});

test('param route binds and back-navigation restores it', async ({ page }) => {
  await page.goto('/users/42');
  await expect(page.locator('[data-testid="user-id"]')).toHaveText('42');
  await page.goto('/');
  await page.goBack(); // fires popstate; router re-matches /users/42
  await expect(page).toHaveURL(/\/users\/42$/);
});

For a fast console check, run ng.getComponent($0) is unnecessary — instead inspect performance.getEntriesByType('resource') after clicking a lazy link and confirm a new chunk (for example admin-routes.js) was fetched only on first visit, proving the split worked.

Performance Tuning

  • Split at every feature boundary. Replace component with loadComponent, and group feature areas behind loadChildren, so the initial bundle carries only the shell and the landing route. This is the single highest-impact change for first paint.
  • Prefer CanMatch for authorization on lazy routes. A CanActivate guard downloads the lazy chunk before it can reject; CanMatch refuses before any fetch, saving bandwidth for users who will never see the route.
  • Add a preloading strategy. withPreloading(PreloadAllModules) fetches lazy chunks in the background after the app is interactive, so the first click into a feature is instant without inflating the initial download. A custom strategy can preload only routes flagged in data.
  • Enable component input binding. withComponentInputBinding() removes per-component ActivatedRoute subscriptions, cutting boilerplate and the change-detection churn those subscriptions cause.
  • Restore scroll natively. withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }) hands scroll handling to the router rather than ad-hoc listeners, aligning with Scroll Restoration Strategies.

Guards, Resolvers and Where Decisions Belong

Angular gives more places to intervene in a navigation than any other mainstream router, and the resulting question is not how to write a guard but which of the five hooks a given decision belongs in. Putting it in the wrong one produces behaviour that is subtly wrong rather than obviously broken.

canMatch decides whether a route participates at all. It runs before matching completes, which means a route that declines to match falls through to the next candidate rather than blocking the navigation. That property is what makes it the right hook for feature flags and role-based route variants: an administrator and an ordinary user can be given different components for the same path, with no redirect and no flash of the wrong screen. It is also the correct replacement for the old canLoad, and it is the hook most teams underuse.

canActivate decides whether an already-matched route may render. By the time it runs, matching is settled, so declining means redirecting or blocking rather than falling through. This is where authentication belongs — the question is not “does this route exist for you” but “may you see it right now”.

canActivateChild applies the same decision to a subtree, and is the difference between one guard on a parent and the same guard copied onto eight children. Copying is how a screen added next year ends up unprotected.

canDeactivate decides whether the user may leave. Its only common use is confirming unsaved changes, and its main pitfall is being too eager: a confirmation prompt that fires when nothing has actually changed trains users to dismiss it without reading, which defeats its purpose entirely.

resolve decides what must exist before the route renders. It blocks activation until the data settles, which is exactly right for the record a page is fundamentally about and exactly wrong for anything else. A resolver on a slow secondary call means the previous screen stays visible for the whole duration, with no indication that anything is happening. For those, return an observable the component subscribes to instead, so the shell paints immediately and the slow part fills in.

The ordering matters as much as the choice. Guards run outermost-first, so a parent’s canActivate completes before a child’s runs, and a resolver runs only after every guard on its route has allowed the navigation. Placing an expensive check high in the tree therefore costs every navigation into that subtree, which is the routing equivalent of a slow global middleware — easy to add, hard to notice, and paid on every click.

Most of the entries below share a property worth naming: they compile, they start, and they misbehave only at a specific point in a navigation. That is why a route table benefits from a small suite of navigation tests far more than it benefits from careful reading — the failures are behavioural rather than structural, and the compiler has nothing to say about any of them.

Gotchas & Failure Modes

  • Hard-refresh 404s under PathLocationStrategy. Clean URLs demand a server catch-all rewriting unknown paths to index.html. Without it, every deep-linked refresh 404s. Add the rewrite, or fall back to HashLocationStrategy.
  • Wildcard placed too early. A ** route matches everything after it, so any route registered below it is dead. Keep ** last in the array — and remember loadChildren arrays have their own local ordering.
  • CanActivate where CanMatch was meant. Guarding a lazy route with CanActivate still pays the download cost for rejected users. Use CanMatch when the guard’s job is to keep the bundle out of unauthorized hands.
  • Redirects without pathMatch: 'full'. A redirectTo on an empty path with the default 'prefix' matching can loop or fire unexpectedly. Empty-path redirects almost always need pathMatch: 'full'.
  • Reusing a component across param changes. Navigating /users/1/users/2 reuses the component instance by default, so a one-time ngOnInit fetch never re-runs. Subscribe to paramMap, use a resolver, or bind the input to react to the change.
  • Forgetting provideRouter entirely in tests. A component using routerLink throws without the router providers; use provideRouter([]) or RouterTestingHarness in specs.
Router features and when each is worth enabling Five provideRouter features with guidance on when to enable each: input binding and in-memory scrolling almost always, preloading for large lazy areas, hash location only on hosts without rewrites, and debug tracing never in production. Router features and when to enable each provideRouter feature enable when withComponentInputBinding() params and resolved data as inputs almost always withInMemoryScrolling() restore scroll and honour anchors almost always withPreloading() warm lazy chunks after the first paint large lazy areas withHashLocation() hash-based URLs a host without rewrites withDebugTracing() log every navigation event debugging only, never shipped
Because each feature is a separate function, the ones you do not pass are removed from the bundle entirely.

Two configuration choices are worth making deliberately rather than inheriting the defaults. The first is paramsInheritanceStrategy: setting it to always means a child route sees its ancestors’ parameters, which removes a great deal of prop threading in deeply nested areas and makes a child’s data requirements readable from its own definition. The second is onSameUrlNavigation: the default ignores a navigation to the URL already showing, which silently breaks a “refresh this view” control until it is set to reload.

Both defaults are defensible and both surprise people exactly once. Setting them explicitly in provideRouter, with a comment saying why, costs two lines and saves the next person a confusing afternoon.