Routing in Angular

The Angular Router, route guards with a complete auth example, and lazy-loaded routes.

The Angular Router

Unlike React or Vue, Angular ships its router (@angular/router) as a built-in part of the framework rather than a separate library you choose yourself. Routes are declared as an array mapping a URL path to a component, then registered with the application during bootstrap:

Typescript
// app.routes.ts
import { Routes } from "@angular/router";
import { HomeComponent } from "./home.component";
import { AboutComponent } from "./about.component";
import { ProductComponent } from "./product.component";

export const routes: Routes = [
  { path: "", component: HomeComponent },
  { path: "about", component: AboutComponent },
  { path: "products/:id", component: ProductComponent }, // :id is a dynamic segment
];
Typescript
// app.config.ts
import { ApplicationConfig } from "@angular/core";
import { provideRouter } from "@angular/router";
import { routes } from "./app.routes";

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

provideRouter(routes) is the standalone-component-era way to wire routing into the app — the older, still-common alternative registers a RouterModule.forRoot(routes) inside a root AppModule instead, but provideRouter is the current default for a project scaffolded without NgModules. The root component then needs a <router-outlet>, marking where the matched route's component should render:

Typescript
import { Component } from "@angular/core";
import { RouterOutlet, RouterLink } from "@angular/router";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [RouterOutlet, RouterLink],
  template: `
    <nav>
      <a routerLink="/">Home</a>
      <a routerLink="/about">About</a>
    </nav>
    <router-outlet />
  `,
})
export class AppComponent {}

routerLink (used instead of a plain href) enables Angular's client-side navigation — updating the URL and swapping the rendered component without a full page reload, the same role <Link>/<NuxtLink>/<RouterLink> play in every other framework covered in this app. A standalone component using routing directives needs RouterOutlet/RouterLink in its own imports array, exactly like any other dependency a standalone component declares.

Reading route parameters

:id in products/:id is a dynamic segment, read inside the matched component through Angular's ActivatedRoute service:

Typescript
import { Component, inject } from "@angular/core";
import { ActivatedRoute } from "@angular/router";

@Component({
  selector: "app-product",
  standalone: true,
  template: `<h1>Product #{{ productId }}</h1>`,
})
export class ProductComponent {
  private route = inject(ActivatedRoute);
  productId = this.route.snapshot.paramMap.get("id"); // e.g. "42" for /products/42
}

route.snapshot.paramMap.get("id") reads the parameter once, at the moment the component was created — fine for a component that gets destroyed and recreated on every navigation to a different id (Angular's default behavior when the whole route, not just a parameter, changes). For a component that stays alive while only its route parameter changes, route.paramMap (an Observable, not a snapshot) is the correct choice instead, subscribed to (or read via the async pipe) so the component reacts to the parameter actually changing without being torn down and recreated.

Route guards: a complete example

A route guard is a function that runs before a route activates, deciding whether navigation should proceed, redirect elsewhere, or be blocked outright — Angular's equivalent of a Next.js middleware check or a Vue Router navigation guard. The modern, function-based guard style (CanActivateFn) replaces the older class-based guard interface:

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

export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);
  const router = inject(Router);

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

  return router.createUrlTree(["/login"], {
    queryParams: { redirect: state.url }, // remember where they were headed
  });
};
Typescript
// app.routes.ts
import { Routes } from "@angular/router";
import { authGuard } from "./auth.guard";
import { DashboardComponent } from "./dashboard.component";
import { LoginComponent } from "./login.component";

export const routes: Routes = [
  { path: "login", component: LoginComponent },
  {
    path: "dashboard",
    component: DashboardComponent,
    canActivate: [authGuard], // this route only activates if the guard allows it
  },
];

inject() works inside a function-based guard exactly the way it does inside a component — this is intentional; Angular's dependency injection isn't limited to classes. Returning true lets the navigation proceed; returning a UrlTree (built with router.createUrlTree(...)) redirects there instead, which is the modern recommended way to redirect from a guard rather than calling router.navigate(...) imperatively and returning false separately.

Typescript
// login.component.ts
import { Component, inject } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { AuthService } from "./auth.service";

@Component({
  selector: "app-login",
  standalone: true,
  template: `<button (click)="login()">Log in</button>`,
})
export class LoginComponent {
  private route = inject(ActivatedRoute);
  private router = inject(Router);
  private authService = inject(AuthService);

  async login() {
    await this.authService.login();
    const redirect = this.route.snapshot.queryParamMap.get("redirect");
    this.router.navigateByUrl(redirect || "/dashboard"); // send them back where they were headed
  }
}

Lazy-loaded routes

By default, every component a route references is included in the app's initial JavaScript bundle. Lazy loading defers a route's component (and everything it imports) into a separate chunk, downloaded only when a visitor actually navigates to that route:

Typescript
export const routes: Routes = [
  { path: "", component: HomeComponent },
  {
    path: "dashboard",
    canActivate: [authGuard],
    loadComponent: () => import("./dashboard.component").then(m => m.DashboardComponent),
  },
];

loadComponent (the standalone-component equivalent of the older loadChildren, used for lazily loading an entire feature module) takes a function returning a dynamic import() — Angular's build tooling automatically splits whatever that import pulls in into its own chunk. This is worth doing specifically for routes that are large (a rarely-visited admin section, a heavy charting dashboard) or gated behind a guard most visitors will never pass (like dashboard here) — there's no benefit shipping that code to every visitor upfront if only a fraction of them ever reach it.

Common mistakes

  • Using a plain <a href="/about"> instead of routerLink="/about" — it still navigates, but forces a full page reload and loses Angular's client-side routing entirely.
  • Reading a route parameter with route.snapshot.paramMap inside a component that can stay alive across navigations to the same route with a different parameter — the snapshot only reflects the parameter's value at creation time; use the observable route.paramMap instead when the component needs to react to the parameter itself changing.
  • Forgetting to add canActivate: [authGuard] to a route that should be protected — an easy omission on a newly added route, unlike a global check that can't be skipped by accident.
  • Returning false from a guard instead of a UrlTree when a redirect is actually intended — false alone blocks the navigation but leaves the visitor on whatever page they were already on, with no redirect to where they should go instead.