Angular Standalone Component Routing
After reading this you will be able to configure an Angular router with no NgModules at all — bootstrapping with provideRouter, lazy-loading single components and route arrays, and replacing class-based guards and resolvers with functions.
← Back to Angular Router Configuration
Prerequisites
Core Concept
Angular’s standalone components remove the NgModule from the picture, and the router changed shape to match. Four things replace their module-era equivalents.
provideRouter(routes) replaces RouterModule.forRoot(routes) in a bootstrapApplication call. Router features that used to be options on forRoot — in-memory scrolling, hash location, debug tracing — are now separate provider functions passed alongside, so the ones you do not use are tree-shaken away entirely.
loadComponent is new and has no module-era equivalent: it lazy-loads a single standalone component for one route. Previously the smallest lazy unit was a module; now it is a component, which makes route-level code splitting dramatically finer-grained.
loadChildren returning a route array replaces loadChildren returning a module. The lazy chunk exports Routes directly, so a feature area no longer needs a module whose only job was to declare a component and re-export the router.
Functional guards and resolvers replace the class-based CanActivate and Resolve interfaces. A guard is now a function that calls inject() for its dependencies and returns a boolean, a UrlTree, or a promise or observable of either. Class-based guards are deprecated.
The practical effect is that a route file becomes self-contained: the route, its component, its guard and its providers all live in one place, with no module ceremony between them.
Implementation
1. Bootstrap with provideRouter
// main.ts — Angular 17+
import { bootstrapApplication } from "@angular/platform-browser";
import {
provideRouter,
withComponentInputBinding,
withInMemoryScrolling,
withRouterConfig,
} from "@angular/router";
import { AppComponent } from "./app/app.component";
import { routes } from "./app/app.routes";
bootstrapApplication(AppComponent, {
providers: [
provideRouter(
routes,
// Route params and query params are bound to @Input()s automatically.
withComponentInputBinding(),
withInMemoryScrolling({ scrollPositionRestoration: "enabled", anchorScrolling: "enabled" }),
// Guards re-run when only params change — otherwise stale data survives.
withRouterConfig({ paramsInheritanceStrategy: "always" }),
),
],
});
2. Lazy-load components and route arrays
// app.routes.ts — Angular 17+
import type { Routes } from "@angular/router";
import { authGuard } from "./auth/auth.guard";
export const routes: Routes = [
{
path: "",
// A single standalone component — the finest lazy unit available.
loadComponent: () => import("./home/home.component").then((m) => m.HomeComponent),
},
{
path: "reports",
canActivate: [authGuard],
// A whole area: the chunk exports Routes, not a module.
loadChildren: () => import("./reports/reports.routes").then((m) => m.reportRoutes),
},
{
path: "**",
loadComponent: () => import("./not-found.component").then((m) => m.NotFoundComponent),
},
];
// reports/reports.routes.ts — a lazy area with its own shell and providers
import type { Routes } from "@angular/router";
import { ReportsShellComponent } from "./reports-shell.component";
import { ReportService } from "./report.service";
import { reportResolver } from "./report.resolver";
export const reportRoutes: Routes = [
{
path: "",
component: ReportsShellComponent, // renders <router-outlet /> beside its nav
// Scoped to this route subtree — created on entry, destroyed on exit.
providers: [ReportService],
children: [
// Without pathMatch: "full" this empty path matches every child prefix.
{ path: "", pathMatch: "full", loadComponent: () => import("./list.component").then((m) => m.ListComponent) },
{
path: ":id",
resolve: { report: reportResolver },
loadComponent: () => import("./detail.component").then((m) => m.DetailComponent),
},
],
},
];
3. Write guards and resolvers as functions
// auth/auth.guard.ts — Angular 17+
import { inject } from "@angular/core";
import { Router, type CanActivateFn } from "@angular/router";
import { AuthService } from "./auth.service";
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService); // inject() replaces constructor injection
const router = inject(Router);
if (auth.isLoggedIn()) return true;
// Returning a UrlTree redirects without an extra navigation cycle.
return router.createUrlTree(["/login"], { queryParams: { next: state.url } });
};
// reports/report.resolver.ts
import { inject } from "@angular/core";
import type { ResolveFn } from "@angular/router";
import { ReportService } from "./report.service";
import type { Report } from "./report";
export const reportResolver: ResolveFn<Report> = (route) => {
// The id param is a string; validate before handing it to the service.
const id = route.paramMap.get("id");
return inject(ReportService).load(id ?? "");
};
4. Bind route data straight to inputs
withComponentInputBinding removes most ActivatedRoute boilerplate — params, query params and resolved data arrive as inputs:
// reports/detail.component.ts
import { Component, Input } from "@angular/core";
import type { Report } from "./report";
@Component({
standalone: true,
template: `<h1></h1>`,
})
export class DetailComponent {
// Matches the :id path parameter.
@Input() id!: string;
// Matches the `report` key of the route's resolve map.
@Input() report!: Report;
}
loadComponent makes the route, not the feature module, the unit of code splitting.Migrating an existing table incrementally
The conversion does not have to be a single change, and doing it in one commit on a large application is how migrations stall. A workable order, one step per pull request:
Start by replacing the bootstrap — RouterModule.forRoot becomes provideRouter in a bootstrapApplication call. Everything else keeps working, because module-based lazy children are still valid targets for loadChildren. This step alone is usually a day and unblocks all the rest.
Next, convert guards and resolvers to functions. They are self-contained, easy to unit test in isolation, and each one is a small, reviewable change. Doing them before the components means the route table stops referencing classes that would otherwise have to survive the module deletions.
Then convert leaf routes to loadComponent, one area at a time, deleting each feature module as its last component becomes standalone. This is where the bundle-size improvement actually lands, so it is worth measuring before and after — a route that was pulling in a 240 KB feature chunk to render a 70 KB screen is common, and the difference shows up directly in the first navigation into that area.
Finish by moving feature-scoped services from module providers to route providers. Leaving them in root works but keeps them alive for the whole session, which quietly undoes some of the benefit of splitting the area out in the first place.
Verification
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("each route loads only its own chunk", async ({ page }) => {
const chunks: string[] = [];
page.on("request", (r) => { if (r.url().endsWith(".js")) chunks.push(r.url()); });
await page.goto("/");
expect(chunks.some((c) => c.includes("detail"))).toBe(false); // not downloaded yet
await page.getByRole("link", { name: "Reports" }).click();
await page.getByRole("link", { name: "First report" }).click();
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
expect(chunks.some((c) => c.includes("detail"))).toBe(true);
});
test("the functional guard redirects and preserves the destination", async ({ page }) => {
await page.goto("/reports"); // anonymous
await expect(page).toHaveURL(/\/login\?next=%2Freports/);
});
For unit tests, functional guards are far easier than the classes they replace — call them inside TestBed.runInInjectionContext and assert on the return value, with no fixture and no module setup.
Gotchas
- An empty path without
pathMatch: "full"matches every child prefix, so the index component renders on top of its siblings. It is Angular’s most-reported routing bug and it survives the move to standalone unchanged. loadComponentrequires a standalone component. Pointing it at a component still declared in an NgModule fails at runtime with a message that does not say so.- Class-based guards are deprecated, not removed. They still work, so a half-migrated table compiles — which means the migration can stall unnoticed.
- Per-route
providerscreate a new injector. A service provided on two sibling routes is two instances; provide it on the shared parent if it must be one. inject()only works in an injection context. Calling it inside a callback within a guard throws; capture the dependency at the top of the function.withComponentInputBindingis opt-in. Without it the inputs stay undefined and the failure looks like a data bug rather than a missing provider.**still needs to be last. Standalone changes nothing about wildcard precedence.
FAQ
Do I have to migrate everything at once? No. provideRouter and NgModule-based lazy children coexist, so you can convert one area at a time and leave the rest until later.
Is loadComponent better than loadChildren? For a single screen, yes — it is a smaller chunk and less code. For an area with several routes, shared providers and its own shell, loadChildren returning a route array is still the right shape.
How do I share a service across a lazy area? Put it in the area’s parent route providers. It is created when the area is entered and destroyed when it is left, which is usually what a feature-scoped service should do.
What replaced CanLoad? CanMatch, which is more capable: it can decide whether a route matches at all, so an unauthorised user falls through to a later route rather than being blocked.
Do resolvers still block navigation? Yes, by default — the route does not activate until the resolver settles. For anything slow, return an observable the component subscribes to instead, so the shell paints immediately.
Related
- Angular Router Configuration — the full route table, guards and resolvers in context.
- Lazy Loading Feature Modules with the Angular Router — the module-era pattern this replaces.
- Nested & Layout Routing — the framework-agnostic model behind
<router-outlet>and child routes.