Nested & Child Routes
Most real applications are not flat. A dashboard has tabs, a settings page has sub-sections, and a products area has a list alongside a detail view. Angular models these hierarchies with child routes: routes nested inside a parent route via the children property. Each level of the hierarchy renders into its own <router-outlet>, so a parent component supplies the persistent shell (navigation, headers) while the active child fills the inner outlet. This is the foundation of master-detail and layout-driven UIs.
Declaring child routes
A child route is just a Route object placed in the children array of a parent route. The parent’s component must contain a <router-outlet> where the active child is rendered. Child paths are relative to the parent, so they do not begin with a slash.
// app.routes.ts
import { Routes } from '@angular/router';
import { ProductsComponent } from './products/products.component';
import { ProductListComponent } from './products/product-list.component';
import { ProductDetailComponent } from './products/product-detail.component';
export const routes: Routes = [
{
path: 'products',
component: ProductsComponent, // the shell / parent
children: [
{ path: '', component: ProductListComponent }, // /products
{ path: ':id', component: ProductDetailComponent }, // /products/42
],
},
];
Visiting /products matches the parent and the empty-path child, so ProductListComponent appears in the parent’s outlet. Navigating to /products/42 keeps ProductsComponent mounted and swaps the inner outlet to ProductDetailComponent.
The nested router outlet
The parent component owns the layout and exposes an outlet for its children. Because everything is standalone, remember to import RouterOutlet (and RouterLink if you link from here).
// products/products.component.ts
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-products',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<section class="products">
<aside>
<h2>Catalog</h2>
<nav>
<a routerLink="./" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }">All products</a>
</nav>
</aside>
<!-- Child routes render here -->
<main>
<router-outlet />
</main>
</section>
`,
})
export class ProductsComponent {}
The routerLink="./" is relative to the parent, navigating to the empty-path child. Using [routerLinkActiveOptions]="{ exact: true }" prevents the “All products” link from staying highlighted when a detail route is open.
A master-detail layout
The classic use case is showing a list and a selected item side by side. The list links to siblings, and the detail component reads the route parameter with the modern input binding (enable it with withComponentInputBinding()).
// products/product-list.component.ts
import { Component, signal } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-product-list',
standalone: true,
imports: [RouterLink, RouterLinkActive],
template: `
<ul>
@for (p of products(); track p.id) {
<li>
<a [routerLink]="[p.id]" routerLinkActive="active">{{ p.name }}</a>
</li>
}
</ul>
`,
})
export class ProductListComponent {
products = signal([
{ id: 1, name: 'Keyboard' },
{ id: 2, name: 'Mouse' },
]);
}
// products/product-detail.component.ts
import { Component, input } from '@angular/core';
@Component({
selector: 'app-product-detail',
standalone: true,
template: `<h3>Product #{{ id() }}</h3>`,
})
export class ProductDetailComponent {
// Bound from the ':id' route param via withComponentInputBinding()
id = input.required<string>();
}
Clicking a product navigates to [p.id], which is relative to the list’s parent (/products), producing /products/1. The parent shell never re-renders, only the inner outlet changes.
Default child routes and redirects
A parent often has no content of its own and should immediately show a default child. Use a pathMatch: 'full' redirect on the empty path.
{
path: 'settings',
component: SettingsComponent,
children: [
{ path: '', redirectTo: 'profile', pathMatch: 'full' },
{ path: 'profile', component: ProfileComponent },
{ path: 'security', component: SecurityComponent },
],
}
Use
pathMatch: 'full'on empty-path redirects. The default'prefix'matches the empty string against every URL and can cause an infinite redirect loop.
Component-less parent routes
You do not always need a parent component. A route with children but no component simply groups routes so they can share configuration such as a guard, resolver, or a lazy-loaded provider scope. The children render in the nearest ancestor outlet.
{
path: 'admin',
canActivate: [adminGuard], // a functional guard applied to all children
children: [
{ path: 'users', component: UsersComponent },
{ path: 'logs', component: LogsComponent },
],
}
How nesting affects parameters
Child components can read parameters declared at any level of the hierarchy. A child route inherits its ancestors’ params by default, so ProductDetailComponent can also access a :category param declared on the parent route.
| Concern | Where it lives | How the child reads it |
|---|---|---|
Parent param (:category) | parent route path | inherited into child route.snapshot.params |
Child param (:id) | child route path | child route.params / input() |
| Parent outlet | parent template | persists across child changes |
| Child outlet | parent template | swaps per active child |
Output: navigating from the list to a detail in the master-detail example logs:
[Router] /products -> ProductsComponent > ProductListComponent
[Router] /products/2 -> ProductsComponent > ProductDetailComponent (id=2)
Best practices
- Keep the parent component focused on layout and navigation; let children own their data and behavior.
- Add an empty-path
redirectTowithpathMatch: 'full'so a parent URL never lands on a blank outlet. - Use relative links (
./,[p.id],'../') inside nested areas so routes stay portable if the parent path changes. - Reach for component-less parent routes to share guards, resolvers, or lazy providers without an unnecessary wrapper component.
- Enable
withComponentInputBinding()and bind route params viainput()instead of manually subscribing in detail components. - Remember every level needs its own
<router-outlet>; a missing inner outlet is the most common reason a child “doesn’t render.”