skip to content

In Angular, routerLink="edit" works inside a routed product page but targets /edit from the app header and from a service calling router.navigate(['edit']); why?

level: seniorimportance: should knowfreq 38%

answer

  1. relative to which route
  2. injected ActivatedRoute
  3. outside every outlet means root
  4. services have no route of their own

basics

~20 s

Relative commands resolve against an ActivatedRoute. routerLink uses the one injected where it sits, which is the root route outside any router-outlet, and Router.navigate() uses the root unless relativeTo is passed, so both land at /edit.

solid answer

~40 s

A relative command needs a starting route. `RouterLink` defaults `relativeTo` to the `ActivatedRoute` it injects. Inside a component rendered by a `router-outlet`, and that component's template children, that is the route which rendered it. The app header lives in the root component, outside every outlet, so its `ActivatedRoute` falls through to the router's root provider, which returns `routerState.root`. A service has the same problem, and `Router.navigate()` ignores the caller anyway: without `relativeTo`, or with `null`, it starts from the root. A third variant: a layout component routed at `products`, with child `:id`, resolves `edit` to `/products/edit`, because its route is the parent, not the child being shown. Fixes: absolute commands, or pass `relativeTo`, or have the routed component build a `UrlTree` and hand it over.

code

ts · 20 lines
ts
import {Component, inject} from '@angular/core';
import {ActivatedRoute, Router, UrlTree} from '@angular/router';

@Component({
  selector: 'app-product-detail',
  template: `<button (click)="openEditor()">Edit</button>`,
})
export class ProductDetail {
  private readonly router = inject(Router);
  private readonly route = inject(ActivatedRoute);

  // Built here, where the route is known; safe to pass to a header or service.
  editUrl(): UrlTree {
    return this.router.createUrlTree(['edit'], {relativeTo: this.route});
  }

  openEditor(): void {
    this.router.navigateByUrl(this.editUrl());
  }
}

go deeper

for a junior

Recall that links without a leading slash are relative, and that a link outside the routed page may resolve from the root.

for a middle

Explain that routerLink defaults relativeTo to its injected ActivatedRoute while Router.navigate defaults to the root, and how outlets provide that route.

for a senior

Diagnose wrong targets from the rendered href, and fix them structurally: absolute commands for global navigation, relativeTo or a passed UrlTree for local navigation.

for a principal

Set a convention for where destinations are computed, so shared shells and services never guess the active route and route refactors stay safe.

## Relative to what? In Angular's router, a command without a leading `/` is applied **relative to an `ActivatedRoute`**. `ActivatedRoute` is the router's object for one node of the matched route tree. The question "where does `edit` go" is really "which `ActivatedRoute` is the starting point", and it differs by API and by where the code runs. | Caller | Starting route when not told otherwise | |---|---| | `routerLink` in a routed component or its children | the route that rendered that component | | `routerLink` outside every `router-outlet` (app shell, header) | the root route | | `Router.navigate()` or `createUrlTree()` without `relativeTo` | the root route | | any of them with `relativeTo: someRoute` | `someRoute` | ## How the injected route is chosen `RouterOutlet` creates each routed component with an injector that provides **that route's** `ActivatedRoute`. Components declared in the routed component's template inherit it through the element injector tree, so a `routerLink` in a product card inside the product page resolves against the product route. The root component is not created by an outlet. When something in it injects `ActivatedRoute`, resolution falls through to the provider registered by `provideRouter()`, which returns `router.routerState.root`. A relative link in the header is therefore relative to the root: `edit` becomes `/edit`. The same happens to a component created with an injector that is not under the routed component, for example one created dynamically from a root-level service. ## Why services are worse `Router.navigate()` does not look at who called it. Without `relativeTo`, or with `relativeTo: null`, it builds the tree from the root. A `root`-provided service that injects `ActivatedRoute` gets the root route, so passing that does not help either. The service has no idea which screen is active unless it is told. ## The parent-layout variant With routes like: ```ts {path: 'products', component: ProductsLayout, children: [ {path: ':id', component: ProductDetail}, ]} ``` a `routerLink="edit"` in `ProductsLayout` resolves under `products`, giving `/products/edit`, even while the URL is `/products/42`. The layout's route is the parent; the child route belongs to `ProductDetail`. The link is correct from the layout's point of view and wrong from the user's. ## Fixes, in order of preference 1. **Use absolute commands where the destination is global.** Header links, menus and services should say `['/products', id, 'edit']`. 2. **Pass `relativeTo` explicitly** when the code runs in a routed component: `router.navigate(['edit'], {relativeTo: this.route})`, or `[relativeTo]` on the link. 3. **Let the routed component build the URL.** It calls `router.createUrlTree(['edit'], {relativeTo: this.route})` and hands the `UrlTree` to the header, dialog or service, which navigates with `navigateByUrl(tree)` or binds it to `[routerLink]`. 4. **Accept the route as a parameter** in service methods instead of injecting `ActivatedRoute` inside the service. Walking `router.routerState.root.firstChild` down to the deepest child works, but it couples the service to the route shape and follows only one branch, missing named outlets, so treat it as a last resort. ## Why the design is this way It is tempting to call the root fallback a bug, but it follows from dependency injection: `ActivatedRoute` is just an injectable, and the router can only give a component the route that created it. A header rendered once for the whole app has no single route, and a root service outlives every screen. Making the fallback the root route keeps relative commands deterministic instead of depending on whichever screen happened to be active. The practical rule for an interview answer: relative navigation is for code that belongs to a route; shared code navigates absolutely or receives the destination. ## How to diagnose quickly - Hover the link, or read its rendered `href`: `routerLink` on an anchor writes the resolved URL, so a wrong target is visible before anyone clicks. - Log `this.route.snapshot.url` or `this.route.routeConfig?.path` in the component: an empty path and no config usually means the root route. - Check whether the code calls `navigate()` without `relativeTo`; that alone explains a jump to the top level.

  • Why does injecting ActivatedRoute into a root-provided service not help?
    A root-provided service is created by the root environment injector, where ActivatedRoute resolves to the router's root route. It never sees the route of the component that happens to call it, so relative commands still start at the top.
  • How can you see where a routerLink resolves without clicking it?
    On an anchor, RouterLink writes the resolved URL into href, so inspecting the element or hovering shows the target. A wrong relative starting route shows up as an unexpected href.

Relative directions such as 'second door on the left' depend on where the listener is standing. A routed component stands in its own room; the app header stands in the lobby, so the same directions lead somewhere else.

saying these in an interview costs you the question

  • Believing relative links resolve against the URL shown in the address bar
  • Assuming Router.navigate uses the caller's route when relativeTo is omitted
  • Injecting ActivatedRoute into a root service to navigate relatively
  • Expecting a layout component's relative link to resolve under its active child
  • Fixing a wrong target by string-concatenating router.url with a segment