Skip to main content

Angular Routing

Angular routing takes a configuration-based approach, unlike Next.js's file-based routing.

1. Basic Route Configuration​

In Angular, the mapping between URLs and components is written explicitly in TypeScript as route definitions (a Routes array). The directory structure does not automatically become the URL structure.

Defining Routes in app.routes.ts​

// app.routes.ts
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { UserListComponent } from './users/user-list.component';
import { NotFoundComponent } from './not-found.component';

export const routes: Routes = [
{ path: '', component: HomeComponent, title: 'Home' },
{ path: 'users', component: UserListComponent, title: 'Users' },
{ path: 'members', redirectTo: 'users', pathMatch: 'full' },
{ path: '**', component: NotFoundComponent }, // matches anything not matched above
];

Key points:

  • Routes are evaluated top to bottom, and the first match wins. Always put the wildcard ** last.
  • pathMatch: 'full' redirects only when the entire path matches. It is required for redirects on the empty path.
  • When title is specified, document.title is updated automatically on navigation.

Configuring provideRouter()​

In a standalone setup (Angular 15+), register provideRouter() in app.config.ts. The NgModule approach using RouterModule.forRoot() is no longer recommended.

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import {
provideRouter,
withComponentInputBinding,
withInMemoryScrolling,
} from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withComponentInputBinding(), // bind route params to input() (see below)
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
),
],
};
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, appConfig);

Router features are enabled individually through withXxx() functions, which is tree-shaking friendly.

The RouterOutlet Component​

<router-outlet> is a placeholder that marks where the component of the matched route is rendered. Import RouterOutlet into the root component as a standalone component.

// app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink } from '@angular/router';

@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink],
template: `
<header>
<a routerLink="/">Home</a>
<a routerLink="/users">Users</a>
</header>
<main>
<router-outlet />
</main>
`,
})
export class AppComponent {}

Comparison with Next.js​

AspectAngularNext.js (App Router)
Route definitionRoutes array (declared in code)app/ directory structure (convention)
Page bodyClass given to componentpage.tsx
Render location<router-outlet />children / layout.tsx
404path: '**'not-found.tsx
RedirectredirectToredirect() / next.config.js
Page titletitle propertymetadata / generateMetadata

Angular is explicit, so URL design and file layout can be decided independently. Next.js, on the other hand, lets you add a route just by adding a file.

2. Navigation​

Using routerLink instead of <a href> performs SPA navigation without reloading the whole page.

<!-- Static path -->
<a routerLink="/users">Users</a>

<!-- Build a path with path parameters using an array -->
<a [routerLink]="['/users', user.id]">{{ user.name }}</a>

<!-- With query parameters -->
<a routerLink="/users" [queryParams]="{ page: 2, sort: 'name' }">Page 2</a>

routerLinkActive​

Adds a CSS class to the link that matches the current URL. Use it to highlight navigation items.

<nav>
<a routerLink="/" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }">Home</a>
<a routerLink="/users" routerLinkActive="active">Users</a>
</nav>

A root link such as / is active for every URL unless you add exact: true. To use it, add RouterLinkActive to the component's imports.

Programmatic Navigation with the Router Service​

When navigating from code, for example after a form submission, obtain Router with inject().

import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';

@Component({
selector: 'app-user-form',
template: `<button (click)="save()">Save</button>`,
})
export class UserFormComponent {
private readonly router = inject(Router);

async save(): Promise<void> {
// ...save logic...
await this.router.navigate(['/users', 42], {
queryParams: { saved: true },
});
// To navigate with a URL string
// await this.router.navigateByUrl('/users/42?saved=true');
}
}

navigate() returns Promise<boolean>, which resolves to true when navigation succeeds (or false if a guard rejects it).

Comparison with Next.js​

PurposeAngularNext.js
LinkrouterLink<Link href>
Active detectionrouterLinkActiveManual check with usePathname()
Navigate from codeRouter.navigate()useRouter().push()
Replace navigationnavigate([...], { replaceUrl: true })router.replace()

In Next.js, <Link> prefetches automatically, whereas Angular's routerLink does not prefetch by default (see the Lazy Loading section for preloading strategies).

3. Route and Query Parameters​

Path Parameters (:id)​

export const routes: Routes = [
{ path: 'users/:id', component: UserDetailComponent },
];

When you access /users/42, id is available as the string '42'.

The ActivatedRoute Service​

import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';

@Component({
selector: 'app-user-detail',
template: `<h2>User ID: {{ id() }}</h2>`,
})
export class UserDetailComponent {
private readonly route = inject(ActivatedRoute);

// paramMap is an Observable, so it follows parameter changes within the same component
readonly id = toSignal(
this.route.paramMap.pipe(map((p) => p.get('id'))),
{ initialValue: null },
);
}

You can also read the value once with snapshot.paramMap.get('id'), but when navigating from /users/1 to /users/2, Angular reuses the same component instance, so the snapshot is not updated. To follow parameter changes, subscribe to the Observable (paramMap) or use the input() binding described next.

When you pass withComponentInputBinding() to provideRouter, path parameters, query parameters, and route data are bound directly to the component's input()s. You no longer need ActivatedRoute, and this is the most concise approach.

import { Component, input, computed } from '@angular/core';

@Component({
selector: 'app-user-detail',
template: `<h2>User {{ userId() }} / page {{ page() }}</h2>`,
})
export class UserDetailComponent {
id = input.required<string>(); // /users/:id
page = input<string>('1'); // ?page=2
userId = computed(() => Number(this.id()));
}

Reading Query Parameters​

// Using ActivatedRoute
const route = inject(ActivatedRoute);
const page$ = route.queryParamMap.pipe(map((q) => q.get('page') ?? '1'));

// Update only part of the query while keeping the rest
this.router.navigate([], {
relativeTo: this.route,
queryParams: { page: 3 },
queryParamsHandling: 'merge',
});

Comparison with Next.js​

PurposeAngularNext.js
Path parametersinput() / ActivatedRoute.paramMapuseParams() / params props
Query retrievalinput() / queryParamMapuseSearchParams() / searchParams props
Following changesObservable / SignalRe-render
TypesBasically string (convert yourself)Basically string

4. Nested Routes and Layouts​

Defining Child Routes​

With children, child routes are rendered in the <router-outlet> inside the parent component's template. This is ideal for placing a shared layout in the parent.

export const routes: Routes = [
{
path: 'admin',
component: AdminLayoutComponent, // shared UI such as a sidebar
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'users', component: AdminUsersComponent },
],
},
];
@Component({
selector: 'app-admin-layout',
imports: [RouterOutlet, RouterLink],
template: `
<aside>
<a routerLink="dashboard">Dashboard</a>
<a routerLink="users">User Management</a>
</aside>
<section>
<router-outlet /> <!-- child routes render here -->
</section>
`,
})
export class AdminLayoutComponent {}

You can also define componentless routes that have no component. They are handy for sharing a URL prefix or applying guards and resolvers to a group of routes.

{
path: 'account',
canActivate: [authGuard], // applies to everything underneath
children: [
{ path: 'profile', component: ProfileComponent },
{ path: 'settings', component: SettingsComponent },
],
}

Named RouterOutlet​

To place multiple outlets on one screen, specify an outlet name (auxiliary routes).

{ path: 'chat', component: ChatComponent, outlet: 'sidebar' }
<router-outlet /> <!-- primary -->
<router-outlet name="sidebar" /> <!-- named -->
this.router.navigate([{ outlets: { sidebar: ['chat'] } }]);

The URL looks like /home(sidebar:chat). This is not used very often and is intended for limited cases such as modals and side panels.

Comparison with Next.js layout.tsx​

AspectAngularNext.js
Shared layoutParent route component + <router-outlet />layout.tsx + children
Expressing nestingchildren arrayNested folders
Route groupsComponentless routes(group) folders
Parallel routesNamed outletsParallel Routes (@slot)

5. Route Guards​

Guards control whether navigation to a route is allowed, denied, or redirected.

GuardPurpose
canActivateWhether the route may be entered
canActivateChildWhether child routes may be entered
canDeactivateWhether the route may be left (e.g., unsaved-changes warning)
canMatchWhether the route definition itself should match (e.g., role-based variants)

Functional Guards (The New Approach)​

Since Angular 15.2, guards are recommended to be written as plain functions. Class-based guards (the CanActivate interface) are deprecated; use inject() to obtain the services you need.

The return value is a boolean, a UrlTree (redirect), or a Promise / Observable of either.

Authentication Check​

// 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 auth = inject(AuthService);
const router = inject(Router);

if (auth.isLoggedIn()) {
return true;
}
// Pass returnUrl so the user can be sent back after logging in
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};

Authorization Check (Guard Factory)​

To accept arguments, create a function that returns a guard.

// role.guard.ts
export const roleGuard = (requiredRole: string): CanActivateFn => {
return () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.hasRole(requiredRole)
? true
: router.createUrlTree(['/forbidden']);
};
};
export const routes: Routes = [
{
path: 'admin',
canActivate: [authGuard, roleGuard('admin')],
loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
},
];

canDeactivate (Unsaved Changes Warning)​

import { CanDeactivateFn } from '@angular/router';

export interface HasUnsavedChanges {
hasUnsavedChanges(): boolean;
}

export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) =>
component.hasUnsavedChanges()
? confirm('You have unsaved changes. Discard them and leave?')
: true;
{ path: 'edit/:id', component: EditComponent, canDeactivate: [unsavedChangesGuard] }

Conceptual Comparison with Next.js middleware.ts​

AspectAngular guardsNext.js middleware
Where it runsBrowser (client); also on the server during SSRServer / Edge (before the request)
ScopeSpecified per route definitionPaths specified with matcher
Resulttrue / false / UrlTreeNextResponse.redirect(), etc.
PurposeNavigation control for UXPer-request authentication and rewriting
warning

Client-side guards are UX controls, not a security boundary. Code in the browser can be tampered with, so always make the final authorization decision in the backend API.

6. Lazy Loading​

To keep the initial bundle small, code is loaded only when it is needed.

loadComponent (Single Component)​

export const routes: Routes = [
{
path: 'reports',
loadComponent: () =>
import('./reports/reports.component').then((m) => m.ReportsComponent),
},
];

If the component is a default export, you can write it even more concisely.

{ path: 'reports', loadComponent: () => import('./reports/reports.component') }

loadChildren (A Set of Routes)​

Lazy-load an entire array of child routes per feature.

// admin/admin.routes.ts
import { Routes } from '@angular/router';

export const ADMIN_ROUTES: Routes = [
{ path: '', component: AdminHomeComponent },
{ path: 'users', component: AdminUsersComponent },
];
// app.routes.ts
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
}

Chunks are split automatically at build time, with the dynamic import() as the boundary.

Preloading Strategies​

By default, a chunk is fetched only when navigation happens, so the first navigation incurs a wait. With withPreloading() you can prefetch during idle time.

import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';

provideRouter(routes, withPreloading(PreloadAllModules));

In large apps, also consider a custom strategy (implementing PreloadingStrategy) that preloads only the important routes.

Comparison with Next.js​

AspectAngularNext.js
Split unitExplicit by the developer via loadComponent / loadChildrenAutomatic per page (route)
PrefetchingConfigure a strategy (none by default)<Link> prefetches automatically in the viewport
Component level@defer blocksnext/dynamic / React.lazy

Since Angular 17, in addition to route-level splitting, you can lazy-load at the component level with @defer blocks in templates.

7. Resolvers​

A resolver fetches data before the route is activated. Because the data is ready when the component is displayed, you can avoid flickering loading indicators.

Prefetching Data​

// user.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { UserService, User } from './user.service';

export const userResolver: ResolveFn<User> = (route) => {
const id = Number(route.paramMap.get('id'));
return inject(UserService).getUserById(id); // Observable / Promise / value
};
{
path: 'users/:id',
component: UserDetailComponent,
resolve: { user: userResolver },
}

When withComponentInputBinding() is enabled, the resolved data can be received through an input() with the same name as the resolve key.

@Component({
selector: 'app-user-detail',
template: `<h2>{{ user().name }}</h2>`,
})
export class UserDetailComponent {
user = input.required<User>();
}

Note that navigation is held until the resolver completes. If the work is heavy, the user sees "nothing happening", so limit resolvers to data that loads quickly, or fetch in the component and show a skeleton instead. If a resolver fails (errors), navigation is cancelled, so decide how to handle failures with catchError.

Comparison with Next.js​

AspectAngular resolversNext.js
When it runsBefore navigation (client-centric)On the server during rendering
How data is passedresolve → input() / ActivatedRoute.dataServer Component awaits directly
Legacy approach-getServerSideProps (Pages Router)
LoadingNavigation is heldStreaming via loading.tsx / Suspense

In Next.js Server Components, the component itself fetches data asynchronously, so there is no dedicated concept equivalent to a resolver. In Angular too, calling a service from within the component without a resolver is a common pattern. As a rule of thumb, use a resolver when the screen cannot work without the data and you do not want to navigate on error; otherwise fetch inside the component.