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.:idcaptures a dynamic segment intoActivatedRoute.paramMap;**is the catch-all that must sit last.componentvsloadComponentvsloadChildren— render eagerly, lazily load a single standalone component, or lazily load a nested childRoutesarray respectively.canActivate/canMatch— arrays of guard functions.canMatchdecides whether the route participates in matching at all (and therefore whether its lazy bundle downloads);canActivateruns 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>
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.
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
componentwithloadComponent, and group feature areas behindloadChildren, so the initial bundle carries only the shell and the landing route. This is the single highest-impact change for first paint. - Prefer
CanMatchfor authorization on lazy routes. ACanActivateguard downloads the lazy chunk before it can reject;CanMatchrefuses 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 indata. - Enable component input binding.
withComponentInputBinding()removes per-componentActivatedRoutesubscriptions, 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.
Gotchas & Failure Modes
- Hard-refresh 404s under
PathLocationStrategy. Clean URLs demand a server catch-all rewriting unknown paths toindex.html. Without it, every deep-linked refresh 404s. Add the rewrite, or fall back toHashLocationStrategy. - Wildcard placed too early. A
**route matches everything after it, so any route registered below it is dead. Keep**last in the array — and rememberloadChildrenarrays have their own local ordering. CanActivatewhereCanMatchwas meant. Guarding a lazy route withCanActivatestill pays the download cost for rejected users. UseCanMatchwhen the guard’s job is to keep the bundle out of unauthorized hands.- Redirects without
pathMatch: 'full'. AredirectToon an empty path with the default'prefix'matching can loop or fire unexpectedly. Empty-path redirects almost always needpathMatch: 'full'. - Reusing a component across param changes. Navigating
/users/1→/users/2reuses the component instance by default, so a one-timengOnInitfetch never re-runs. Subscribe toparamMap, use a resolver, or bind the input to react to the change. - Forgetting
provideRouterentirely in tests. A component usingrouterLinkthrows without the router providers; useprovideRouter([])orRouterTestingHarnessin specs.
Go Deeper
- Lazy Loading Feature Modules with the Angular Router — a focused walkthrough of
loadChildren,loadComponent, and preloading strategies for splitting an Angular app by route.
Related
- Framework-Specific Routing Patterns — the parent overview comparing Angular, React, Vue, SvelteKit, and Next.js routing models.
- React Router Implementation — the same concerns — matching, guards, data loading, lazy routes — expressed in React Router’s very different, component-driven idiom.
- History API & State Management — the browser primitives that
PathLocationStrategyand the router’s back/forward handling are built on. - Hash Routing vs History Mode — the deployment and SEO trade-off behind Angular’s two location strategies.
- Route-Based Code Splitting — the framework-agnostic technique that
loadChildrenandloadComponentimplement.