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:
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];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.)
Navigating: Links First, Code Second
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=2export 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
- Routes
products/:idandproducts/newโ which order, and why? - A detail page loads in
ngOnInitviaroute.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. - 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?
- Write the guard return that sends anonymous users to
/loginpreserving their destination. - Why does
canActivateon/adminnot 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.