Technology  /  Angular

๐Ÿ…ฐ๏ธ Angular 15 guides ยท updated 2026

Standalone components, signals, RxJS, and dependency injection โ€” building maintainable frontends with the framework enterprises trust.

Angular Routing: Routes, Params as Inputs, Guards, and Navigation Patterns

A single-page app fakes multi-page behavior: one HTML document, with JavaScript swapping views as the URL changes. The router is the machinery that makes the fake honest โ€” URLs that deep-link, back buttons that work, refreshes that restore state. Treat the URL as application state the user can see, share, and bookmark, and routing decisions start making themselves.

This guide covers the modern router: configuration, links and programmatic navigation, route params flowing straight into component inputs, guards, and the URL-design judgment that separates apps that feel like the web from apps that fight it.


Route Configuration: URL โ†’ Component

Routes map URL shapes to components, defined once and provided at bootstrap:

app.routes.ts
export const routes: Routes = [
{ path: '', component: HomePage },
{ path: 'products', component: ProductListPage },
{ path: 'products/:id', component: ProductDetailPage }, // :id โ€” a parameter
{ path: 'checkout', component: CheckoutPage, canActivate: [authGuard] },
{ path: '**', component: NotFoundPage }, // wildcard: no match
];
app.config.ts
provideRouter(routes, withComponentInputBinding())

And the root template says where routed components render:

<app-header />
<router-outlet /> <!-- the active route's component appears here -->
<app-footer />

Reading the config: :id declares a route parameter โ€” /products/42 matches, carrying id = "42". Order matters โ€” first match wins, so specific paths precede parameterized ones and ** comes last. The wildcard route is your 404; every real app needs one.

(Legacy note: older configs used loadChildren with NgModules and class-based guards โ€” recognize them; new code uses component routes, functional guards, and loadComponent for lazy loading.)


Declarative navigation โ€” the default:

<a routerLink="/products">Products</a>
<a [routerLink]="['/products', product.id]">{{ product.name }}</a>
<a routerLink="/products" [queryParams]="{ sort: 'price' }">Cheapest first</a>
<a routerLink="/products" routerLinkActive="active">Products</a> <!-- nav highlighting -->

routerLink renders a real anchor โ€” middle-click opens a tab, right-click offers โ€œcopy linkโ€, crawlers see an href โ€” while intercepting normal clicks for instant SPA navigation. This is why routerLink beats (click)="goTo()" for anything link-shaped: you keep the webโ€™s native behaviors for free. The array form composes segments safely (no string concatenation of user data into URLs); routerLinkActive handles the perennial โ€œhighlight current nav itemโ€ without manual state.

Programmatic navigation โ€” for after logic, not instead of links:

export class CheckoutPage {
private router = inject(Router);
async placeOrder() {
const order = await this.api.submit(this.cart);
this.router.navigate(['/orders', order.id, 'confirmation']);
}
}

router.navigate(...) belongs where navigation is a consequence โ€” form submitted, login succeeded, item deleted. If a plain click should move the user, itโ€™s a link.


Reading Route State: Params as Inputs

The modern routerโ€™s best ergonomic: with withComponentInputBinding(), route params, query params, and route data bind directly to component inputs:

// matches path 'products/:id'
export class ProductDetailPage {
id = input.required<string>(); // โ† the :id param, as a signal input
product = resource({
params: () => ({ id: this.id() }),
loader: ({ params }) => this.api.getProduct(params.id),
});
}

No ActivatedRoute, no observable subscription โ€” the param arrives like any parent-supplied input, and because itโ€™s a signal, same-component param changes are handled automatically: navigating /products/42 โ†’ /products/43 reuses the component instance and just updates the input, and the resource reloads. This subsumes the classic bug where ngOnInit-based loading ignored param changes โ€” the reactive derivation is the fix, as the lifecycle guide argued.

Two supporting facts: params are always strings (convert deliberately โ€” Number(id) with validation, and design APIs to accept string ids where possible); and the legacy ActivatedRoute service (route.paramMap observables, snapshot) remains everywhere in older code โ€” the input binding is sugar over it, so read both fluently.

Query params: state that belongs in the URL

Filters, sort orders, pagination, search terms โ€” if refreshing or sharing the page should preserve it, it belongs in query params, not component memory:

// /products?q=keyboard&sort=price&page=2
export class ProductListPage {
q = input(''); // query params bind as inputs too
sort = input<'price' | 'name'>('name');
page = input(1, { transform: numberAttribute });
}

Updating them navigates to the same page with merged params:

this.router.navigate([], { queryParams: { page: 3 }, queryParamsHandling: 'merge' });

The design test worth internalizing: would a user reasonably share or bookmark this view? If yes and your URL doesnโ€™t capture it, state is trapped in memory that the web platform wanted to manage for you.


Child Routes and Layouts

Nesting routes nests UI โ€” a parent component with its own <router-outlet> frames its children:

{
path: 'account',
component: AccountLayout, // sidebar + <router-outlet>
children: [
{ path: '', redirectTo: 'profile', pathMatch: 'full' },
{ path: 'profile', component: ProfilePage },
{ path: 'orders', component: OrderHistoryPage },
{ path: 'orders/:id', component: OrderDetailPage },
],
},

/account/orders/42 renders AccountLayout, whose outlet holds OrderDetailPage. The layout persists across child navigation โ€” sidebar state, scroll position, component-provided services all survive. This is how section-with-tabs, wizard-with-steps, and dashboard-with-panels UIs map to URLs โ€” the URL tree mirroring the component tree.


Guards: Rules at the Door

Guards are functions the router consults before completing navigation:

export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthStore);
const router = inject(Router);
return auth.isLoggedIn()
? true
: router.createUrlTree(['/login'], { queryParams: { returnTo: state.url } });
};

Attach with canActivate: [authGuard] โ€” returning true proceeds, false cancels, and a UrlTree redirects (the login-with-return-URL pattern above is the canonical one, and returning a UrlTree beats imperatively navigating inside a guard). Guards run in an injection context, so inject() works โ€” this is exactly the functions-need-DI case that motivated inject().

The family: canActivate (enter?), canActivateChild (any child?), canMatch (consider this route at all โ€” can hide entire lazy trees by role), and canDeactivate (leave? โ€” the unsaved-changes prompt, which receives the component to ask). Guards can return async results (promises/observables) โ€” the router waits.

One security note that belongs in every routing guide: guards are UX, not security. They shape client navigation; the API must enforce authorization independently, because the client is entirely under the userโ€™s control. A guard hiding the admin page means nothing if /api/admin answers to anyone.


Designing a Route Tree: A Worked Example

Route design is information architecture; hereโ€™s the thinking for a realistic commerce admin, annotated:

export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'login', component: LoginPage },
{
path: '',
component: AppShell, // header + sidebar + outlet
canActivateChild: [authGuard], // one guard covers the whole shell
children: [
{ path: 'dashboard', component: DashboardPage },
{
path: 'orders',
children: [
{ path: '', component: OrderListPage }, // /orders?status=pending&page=2
{ path: ':id', component: OrderDetailPage }, // /orders/8842
],
},
{
path: 'settings',
canMatch: [adminMatch],
loadChildren: () => import('./settings/settings.routes')
.then((m) => m.SETTINGS_ROUTES),
},
],
},
{ path: '**', component: NotFoundPage },
];

The decisions worth imitating: an empty-path layout route wraps everything post-login, so the shell renders once and canActivateChild guards every child in one line (versus repeating canActivate per route โ€” the DRY that matters most in route configs); the orders list state lives in query params (shareable โ€” โ€œhere are the pending orders, page 2โ€ is a URL a colleague can open); detail pages are :id children so breadcrumbs and back-navigation fall out of the tree shape; and the admin-only settings area combines canMatch with lazy loading so non-admins neither see nor download it. pathMatch: 'full' on the redirect is the small print that bites: an empty path prefix-matches everything, so redirect routes on '' need it or they loop.

The meta-skill: sketch the URL space before building pages โ€” every screen gets an address, every address answers โ€œwhat would a user expect to see, share, and bookmark here?โ€ Apps designed URL-first feel like the web; apps that bolt URLs on afterward fight it forever.

Frequently Asked Questions

Where does route-level data loading belong โ€” component, resolver, or guard? Modern default: in the component via resource keyed on inputs (loading states stay local and visible). Resolvers (resolve: config) pre-fetch before navigation completes โ€” right when a page is meaningless without data and youโ€™d rather delay transition than show skeletons. Guards: never for loading โ€” they gate, not fetch.

Why does my app 404 on refresh when deployed? The server must serve index.html for all app paths (the SPA fallback) โ€” /products/42 exists only client-side. Every host has a rewrite setting for this; itโ€™s the deployment guideโ€™s first checklist item.

How do I scroll to top on navigation? provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })) โ€” also restores position on back navigation, matching browser behavior users expect.

Events for analytics/spinners? router.events (an Observable) emits the navigation lifecycle โ€” filter for NavigationEnd for page-view tracking, NavigationStart/End/Error pairs for progress indicators.

Named/secondary outlets? Parallel outlets (<router-outlet name="panel">) render auxiliary routes โ€” powerful for master-detail and modals-with-URLs, syntactically noisy (/products(panel:help)). Know they exist; reach for them when a secondary view genuinely deserves URL state.


Router State Beyond Params: data, Resolvers, and Navigation Extras

Three smaller router facilities that round out fluency. Static route data โ€” { path: 'orders', component: ..., data: { title: 'Orders', breadcrumb: 'Orders' } } โ€” binds to component inputs like params do (with input binding enabled) and powers cross-cutting concerns: a title service reading data.title on every NavigationEnd, breadcrumb builders walking the matched route tree. Resolvers in their modern functional form โ€” resolve: { order: (route) => inject(OrderApi).get(route.params['id']) } โ€” deliver the result as an order input; the trade stays as stated earlier (navigation waits on data โ€” good when a shell without data is worthless, bad when users prefer instant navigation with skeletons). Navigation extras youโ€™ll eventually want: state passes transient data through navigation without touching the URL (available once via history.state โ€” right for โ€œcame from search resultsโ€ hints, wrong for anything refresh-must-survive); replaceUrl: true swaps the history entry instead of pushing (post-login redirects that shouldnโ€™t be back-button stops); and relativeTo: this.route makes navigate(['../sibling']) work like filesystem paths inside deep trees. None of these are day-one tools; all of them are the difference between fighting the router and speaking it.

One testing note to close the loop: the router ships a test harness (provideRouter in TestBed plus RouterTestingHarness) that navigates to routes in tests and hands you the activated component โ€” which makes guard behavior, param binding, and redirect logic unit-testable rather than E2E-only. Guards being plain functions helps further: authGuard tests are โ€œfake the AuthStore, call the function, assert true/UrlTreeโ€ โ€” three lines each, and the redirect-with-returnTo behavior gets locked in by a test the first time someone breaks it.

Self-Check

  1. Routes products/:id and products/new โ€” which order, and why?
  2. A detail page loads in ngOnInit via route.snapshot. Navigating from item 42โ€™s page to item 43 (e.g., a โ€œrelated itemsโ€ link) shows stale data. Explain and give the modern fix.
  3. Which state belongs in query params vs component signals: (a) search filter text, (b) whether a dropdown is open, (c) current page number, (d) hovered row?
  4. Write the guard return that sends anonymous users to /login preserving their destination.
  5. Why does canActivate on /admin not make the admin API safe?

(Answers: products/new first โ€” first-match-wins would capture โ€œnewโ€ as an :id. Same-component navigation reuses the instance; ngOnInit wonโ€™t re-run and the snapshot is frozen โ€” bind the param as a signal input and derive the load (resource). (a) and (c) query params โ€” shareable view state; (b) and (d) signals โ€” ephemeral UI state. router.createUrlTree(['/login'], { queryParams: { returnTo: state.url } }). Guards run in the attacker-controlled client; authorization must be enforced server-side per request.)

Next: HttpClient and APIs โ€” Angularโ€™s HTTP layer: typed requests, interceptors for auth and errors, and wiring server data into signals.