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:
// 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
];
// 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:
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:
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:
// 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
});
};
// 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.
// 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:
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 ofrouterLink="/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.paramMapinside 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 observableroute.paramMapinstead 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
falsefrom a guard instead of aUrlTreewhen a redirect is actually intended —falsealone blocks the navigation but leaves the visitor on whatever page they were already on, with no redirect to where they should go instead.