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.

Module-era routing constructs and their standalone replacements A four-row mapping: RouterModule forRoot becomes provideRouter, a lazily loaded NgModule becomes loadComponent or a route array, class-based guards become functional guards, and module providers become per-route providers. NgModule era Standalone RouterModule.forRoot(routes) provideRouter(routes, withComponentInputBinding()) loadChildren: () => import(…).then(m => m.Module) loadComponent — one component or loadChildren returning Routes class AuthGuard implements CanActivate const authGuard: CanActivateFn = () => … providers on the feature module providers: [] on the route itself
Every module-era routing construct has a direct standalone replacement — and each replacement is smaller than what it replaces.

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;
}
Lazy-loading granularity: module versus component A comparison of chunk boundaries. With module-era lazy loading the smallest unit is a feature module containing several components. With loadComponent each route can be its own chunk, so visiting one screen downloads only that screen. Module-era — the chunk is a whole feature reports.module chunk — 240 KB list detail export visiting the list downloads detail and export too Standalone — the chunk can be one route three chunks, loaded on demand list · 70 KB detail · not yet export · not yet visiting the list downloads only the list Per-route providers keep the same granularity for services: created when the route is entered, destroyed when it is left.
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.
  • loadComponent requires 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 providers create 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.
  • withComponentInputBinding is 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.
Five standalone routing mistakes that compile cleanly Five Angular standalone routing errors that pass compilation: a missing pathMatch, loadComponent pointing at a non-standalone component, omitted input binding, a service provided twice, and inject called outside an injection context. Standalone routing pitfalls that still compile symptom fix empty path without pathMatch: "full" index renders over its siblings add pathMatch loadComponent on a declared component runtime error, unclear message make the component standalone withComponentInputBinding omitted inputs are undefined add the router feature a service in two route providers two instances, split state provide it on the shared parent inject() inside a callback throws at call time capture it at the top of the guard
All five compile, and four of the five look like data bugs from the outside.

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.