Titles, breadcrumbs and nav
The document title, the breadcrumb trail, and the nav menu all depend on where you are in the route tree. Instead of recomputing them by hand on every navigation, declare them once on the routes and read them reactively from any component.
@mmstack/router-corenpm
These three helpers share one shape. createTitle, createBreadcrumb, and createNavItems attach a small piece of UI intent to a route, the page title, its breadcrumb label, the menu it should show. A matching reader, injectBreadcrumbs or injectNavItems (the title updates the document directly), gives a component the live, resolved result as a signal. You declare on the route; you read from wherever it makes sense. When the active route chain changes, everything recomputes on its own.
Nothing here blocks navigation or fetches on its own. A factory can pull a label from a store or an i18n service, but the helper's job is to collect intent from the route config and expose it reactively, not to gate the route. The sidebar of this very docs site is built with createNavItems.
#Document title with createTitle
createTitle is a resolver for the route's title property that sets the document title. Pass it a static string for a fixed page, or a factory for a dynamic one. The factory receives the route's ActivatedRouteSnapshot, and because it runs in an injection context you can inject a store and return a signal-driven title that keeps updating as data loads.
import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { createTitle } from '@mmstack/router-core';
export const routes: Routes = [
{
path: 'about',
title: createTitle('About Us'), // static
loadComponent: () => import('./about').then((m) => m.About),
},
{
path: 'users/:id',
title: createTitle((route) => `User ${route.params['id']}`), // per-snapshot
loadComponent: () => import('./user').then((m) => m.User),
},
{
path: 'products/:id',
// signal-driven, the inner function becomes a computed
title: createTitle(() => {
const products = inject(ProductStore);
return () => `Product: ${products.product().name ?? 'Loading...'}`;
}),
loadComponent: () => import('./product').then((m) => m.Product),
},
]; One safety detail: title, breadcrumb, and nav registrations made during a navigation are staged. They apply when the navigation commits (NavigationEnd) and are dropped if it is cancelled or errors, so a guard that rejects a navigation can never leave the wrong title in the browser tab.
Formatting and fallbacks live in provideTitleConfig. Give it a prefix (a string, or a full formatter function for total control), keepLastKnownTitle (on by default, so a route with no title holds the last one instead of blanking), and initialTitle for the fallback before any route title is active.
import { provideRouter } from '@angular/router';
import { provideTitleConfig } from '@mmstack/router-core';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideTitleConfig({
prefix: (title) => (title ? `${title} · MyApp` : 'MyApp'),
initialTitle: 'MyApp',
}),
],
};#Breadcrumbs
Breadcrumbs are generated from the route segments by default, so you often get a usable trail for free. When you need something better than the raw segment, override that route's crumb with createBreadcrumb. A component reads the whole trail as a signal with injectBreadcrumbs and renders it however it likes, the toolkit is headless, it gives you data, not markup.
import { Component } from '@angular/core';
import { injectBreadcrumbs } from '@mmstack/router-core';
@Component({ selector: 'app-breadcrumbs' /* template below */ })
export class BreadcrumbsComponent {
protected readonly breadcrumbs = injectBreadcrumbs();
} Each crumb exposes label(), link(), and ariaLabel() as signals. One thing to get right: crumb.link() is a serialized URL string, so bind it to [routerLink] (or [mmLink] if you want preloading). Binding it to [href] would trigger a full page reload.
To override a crumb, register it in the route's resolve map. The shorthand form takes a plain label; the factory form runs in an injection context and receives the route snapshot, so you can pull a dynamic label from a store, which is how a /users/:id crumb shows the user's name instead of the id. To opt a route out of auto-generation entirely, set data: { skipBreadcrumb: true }.
import { inject } from '@angular/core';
import { createBreadcrumb } from '@mmstack/router-core';
export const routes: Routes = [
{
path: 'home',
component: HomeComponent,
// shorthand for { label: 'Home' }
resolve: { breadcrumb: createBreadcrumb('Home') },
},
{
path: 'users/:userId',
component: UserProfileComponent,
resolve: {
breadcrumb: createBreadcrumb((route) => {
const users = inject(UserStore);
return {
label: () =>
users.user(route.params['userId'])()?.name ?? 'Loading...',
};
}),
},
},
]; Auto-generation itself is configurable through provideBreadcrumbConfig. Set generation: 'manual' to turn it off (only routes with an explicit createBreadcrumb produce a crumb), or pass a generator function to control how the default label is derived from each leaf route.
#Recipe: a breadcrumb trail
A working trail is the two halves put together. First, declare crumbs on the routes, a static label on the parent and a factory that reads the product name on the child.
import { createBreadcrumb } from '@mmstack/router-core';
export const routes: Routes = [
{
path: 'products',
resolve: { breadcrumb: createBreadcrumb('Products') },
children: [
{
path: ':id',
resolve: {
breadcrumb: createBreadcrumb((route) => {
const products = inject(ProductStore);
return { label: () => products.byId(route.params['id'])()?.name ?? '…' };
}),
},
component: ProductDetail,
},
],
},
];Then render the signal in a shared component, once, wherever your layout wants the trail.
<nav aria-label="breadcrumb">
<ol>
@for (crumb of breadcrumbs(); track crumb.id) {
<li>
<a [routerLink]="crumb.link()" [attr.aria-label]="crumb.ariaLabel()">
{{ crumb.label() }}
</a>
</li>
}
</ol>
</nav>Drop that component into your layout once and it stays correct on every navigation, including deep param routes, because the trail is a signal derived from the active route chain rather than something you maintain by hand.
#Nav menus
createNavItems declares a menu on a route; injectNavItems() reads it back as a Signal<NavItem[]>. The useful trick is that the menu follows the active route chain: when a deeper route in the chain registers items for the same scope, the deeper one wins, and navigating away restores the shallower one. So the app shell can show a global menu that a section quietly replaces while you are inside it.
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { injectNavItems } from '@mmstack/router-core';
@Component({
selector: 'app-top-bar',
imports: [RouterLink],
template: `
<nav>
@for (item of items(); track item.id()) {
<a [routerLink]="item.link()" [class.active]="item.active()">
{{ item.label() }}
</a>
}
</nav>
`,
})
export class TopBar {
protected readonly items = injectNavItems();
} As with breadcrumbs, item.link() is a serialized URL string, bind it to [routerLink] or [mmLink], not [href]. Items are declared in the route's resolve map, and links resolve relative to the route the resolver is attached to, exactly like Angular's routerLink convention. A leading slash makes a link absolute.
import { createNavItems } from '@mmstack/router-core';
export const routes: Routes = [
{
path: '',
resolve: {
// root menu, shown everywhere unless a deeper route overrides it
nav: createNavItems([
{ label: 'Home', link: '/' },
{ label: 'Products', link: '/products' },
{ label: 'About', link: '/about' },
]),
},
children: [
{
path: 'products',
loadComponent: () => import('./products').then((m) => m.Products),
resolve: {
// inside /products this shadows the root menu; relative links resolve here
nav: createNavItems([
{ label: 'All', link: '/products' },
{ label: 'Featured', link: 'featured' }, // → /products/featured
{ label: 'Categories', link: 'categories' }, // → /products/categories
]),
},
},
],
},
]; Relative-by-default is what makes a feature library portable. An nx library can export Routes with nav items using relative links and not care where the host app mounts it; the links resolve against the mount point wherever that turns out to be. The full resolution table (arrays, absolute escapes, UrlTree passthrough) is in the package README.
#Recipe: an active, reactive menu
The reason to use this over a hand-written menu is the active state and the reactivity. NavItem.active is a signal computed against the current URL, so a link highlights itself, and hidden can be signal-driven, so a menu item appears or disappears with a permission without any extra wiring.
createNavItems(() => [
{ label: 'Home', link: '/' },
{
label: 'Admin',
link: 'admin',
hidden: () => !permissions.isAdmin(), // signal-driven visibility
children: [
{ label: 'Users', link: 'admin/users' },
{ label: 'Settings', link: 'admin/settings' },
],
},
]);active() uses subset-match defaults (prefix-match paths, subset query params, ignore fragment), which is what you want for section highlighting; override it per item with activeMatch or globally with provideNavConfig({ activeMatch }) when you need exact matching.
Pass { name } to createNavItems and read with injectNavItems('name') to run more than one menu (a top bar and a side bar) independently.
#Nested children
An item can declare children for a nested menu, and each child inherits a couple of behaviors from its parent. By default a parent counts as active when its own link matches or any descendant is active, which is what you want for a grouping header that has no link of its own. Setting activeMatch on an item turns that OR off; pass matchesWhenChildActive: true to turn it back on.
hidden filters an item and its whole subtree out of the array, so a permission signal can make a branch appear or vanish. disabled keeps the item but cascades down to descendants, which is the shape for a permission-gated group you still want to show as unavailable rather than hide.
createNavItems(() => [
{
label: 'Reports', // grouping header, no own link
children: [
{ label: 'Sales', link: 'reports/sales' },
{ label: 'Traffic', link: 'reports/traffic' },
],
},
{
label: 'Admin',
link: 'admin',
disabled: () => !permissions.isAdmin(), // cascades to children
children: [
{ label: 'Users', link: 'admin/users' },
{ label: 'Settings', link: 'admin/settings' },
],
},
]);#Typed metadata
CreateNavItem and NavItem carry a TMeta generic, so you can attach an icon, a badge, or anything else per item without the library dictating a shape. Declare the type once on createNavItems<TMeta>, and read it back with injectNavItems<TMeta>(). Registration is untyped under the hood, so the reader's generic is a consumer-side assertion, and every field including meta comes back as a signal.
import { createNavItems, injectNavItems } from '@mmstack/router-core';
type NavMeta = { icon: string; badge?: number };
// on the route
createNavItems<NavMeta>([
{ label: 'Inbox', link: 'inbox', meta: { icon: 'mail', badge: 3 } },
]);
// in the component
readonly items = injectNavItems<NavMeta>();
// items()[0].meta().icon → 'mail'#Fallback items
Routes register their menu on demand, so any URL with no registration in its active chain renders an empty menu. To keep a few items visible everywhere, declare fallbacks with provideNavConfig({ defaults }), useful for landing pages, error routes, or a shell that always shows something. Relative links on defaults resolve from /, since there is no route to be relative to.
The array form fills the default (unnamed) scope. For named scopes, pass a record keyed by the name you gave createNavItems, where the empty-string key '' targets the default scope. A scope's entry can be a static array or a factory.
import { provideNavConfig } from '@mmstack/router-core';
// default scope, shown wherever no route registered a menu
provideNavConfig({
defaults: [
{ label: 'Home', link: '/' },
{ label: 'Docs', link: '/docs' },
],
});
// named scopes via the record form; '' targets the default scope
provideNavConfig({
defaults: {
'': [{ label: 'Home', link: '/' }],
main: [{ label: 'Home', link: '/' }], // injectNavItems('main')
side: () => [{ label: 'Settings', link: '/settings' }], // factory
},
}); Defaults are only a fallback. Any active route that calls createNavItems shadows them for its scope under the same deepest-wins rule as everything else, and a route that registers createNavItems([]) shadows them with an explicitly empty menu.