Why the Router's signals API is easy to overlook
The Angular Router has offered a signals-only way to handle most of its features for a while now.
Many developers never notice, since it works through opt-in configuration plus naming conventions rather than through new imports you would stumble onto.
There is no longer any need to reach for the older Observable-based surface — ActivatedRoute, ActivatedRouteSnapshot and friends — which often feels heavier than it should.
With the signal integration, the workflow turns out considerably more fluid and readable.
The catch: both APIs ship side by side, and mixing them up happens without you realizing it.
To bring the router's newer APIs into focus, the walkthrough below treats routing strictly from the signals angle.
What this guide covers
- Configuring the router for a signals-first workflow
- Path parameters arriving as signal inputs
- Query parameters arriving as signal inputs
- Route resource data arriving as signal inputs
- Checking active navigation through isActive
- Reading navigation status through currentNavigation
- Keeping track of the last successful navigation
- Conclusions and Key Takeaways
Let's begin the deep dive into the Angular Router with Signals.
Router setup for a signals-first approach
Signals change none of routing's fundamentals. A route configuration, a registered router and an outlet are still required.
The bulk of the signal-based behavior comes from switching on withComponentInputBinding() in the router configuration, since that option is disabled by default:
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withComponentInputBinding(),
withRouterResources()
)
]
};
Note the use of the optional withRouterResources() as well. That is a further signal-based router capability, also off by default, and it supplies a signal-based replacement for resolvers.
The routes file keeps its usual format.
For this guide, let's work with a course edit route that has a path parameter and a route resource.
Loading a course from the backend according to the URL content is the job of the course resource:
export const routes: Routes = [
{
path: 'courses/edit-course/:courseUrl',
loadComponent: () => import('./course-edit/course-edit.component')
.then(m => m.CourseEditComponent),
resources: ({ params }) => ({
course: httpResource<Course>(() =>
`/api/courses/${params()['courseUrl']}`
)
})
}
];
The component is lazy-loaded, and the router holds off on displaying the page until the course resource has loaded.
Rendering still goes through an ordinary outlet, exactly as before:
<main class="app-main">
<router-outlet />
</main>
Every example below uses this URL:
/courses/edit-course/angular-router-course?mode=edit&couponCode=NEW_YEAR
From it we get one path parameter, two query parameters and a resolved course. Let's look at how each of those lands in the component as a signal.
Path parameters as signal inputs
Receiving the :courseUrl path parameter means declaring an input of the same name inside CourseEditComponent:
@Component(...)
export class CourseEditComponent {
readonly courseUrl = input.required<string>();
}
That is all it takes.
Once component input binding is enabled, the router hands the URL segment angular-router-course to that input.
The whole mechanism is a naming convention: the component input courseUrl carries the same name as the path variable :courseUrl in the route configuration.
Reading it works like reading any other signal-based input.
There is nothing special about it — it is simply a plain component input:
<p>Course URL: {{ courseUrl() }}</p>
Unlike the older router APIs, no injection of ActivatedRoute is needed, no subscription to paramMap, and no copying the value into a local field.
The connection is established by the input name.
Marking the input as required makes sense because this route always carries :courseUrl.
Such a declaration only states what the component expects; it neither validates the URL nor verifies that the course exists.
Query parameters as signal inputs
The same signal-based naming convention routes query parameters into the component as well:
@Component(...)
export class CourseEditComponent {
readonly mode = input<'edit' | 'create'>();
readonly couponCode = input<string>();
}
Against our example URL, mode() yields edit while couponCode() yields NEW_YEAR.
Note that query parameters are never declared in a route's path.
The router maps their names straight onto component inputs.
Both inputs are optional here, because the page can be loaded with neither query parameter present.
The template can reflect that:
@if (couponCode(); as coupon) {
<p>Coupon applied: {{ coupon }}</p>
}
Remember that input signals are read-only.
When a query parameter changes in the URL, the matching input signal emits the new value.
Route resources data as signal inputs
Route resources fetch data before the router shows a component.
That data can arrive through signal inputs too, via the same naming convention.
A route resource is already registered in our route configuration:
resources: ({ params }) => ({
course: httpResource<Course>(() =>
`/api/courses/${params()['courseUrl']}`
)
})
Receiving its output requires declaring a component input named course:
@Component(...)
export class CourseEditComponent {
readonly course = input.required<Course>();
}
The result reaches the component through course() and can be rendered as-is:
<h1>{{ course().description }}</h1>
So once again, naming conventions do all the work.
Active navigation with isActive
The standalone isActive(url, router) function hands back a Signal<boolean>. It reports whether the given URL is currently active and refreshes itself whenever the route changes.
Matching includes child paths by default, meaning /courses/edit-course also matches /courses/edit-course/angular-router-course.
Usage looks like this:
@Component(...)
export class AppComponent {
private readonly router = inject(Router);
protected readonly editingCourse = isActive(
'/courses/edit-course',
this.router
);
}
Reading the signal inside a class binding is straightforward:
<a routerLink="/" [class.editing]="editingCourse()">
Angular Router In Depth
</a>
Navigation status with the signal-based API currentNavigation()
The Router's currentNavigation is a Signal<Navigation | null>. It holds the navigation in progress right now, and it returns to null once that navigation succeeds, fails or is cancelled and the router goes idle.
Reading router.currentNavigation() gives you the current Navigation object, or null when nothing is navigating.
Click a course link, for instance, and the object exists while the router processes the navigation — including the wait for course data to load through the route resource.
A Navigation object describes one navigation operation. Among its properties, the most useful are:
id: the unique identifier of this navigation.initialUrl: the requested destination before redirects, represented as aUrlTree.finalUrl: the destination after redirects, also aUrlTree. This can beundefinedearly in navigation.trigger: how navigation started:imperativefor an application navigation,popstatefor a browser history event, orhashchangefor a hash-change event.extras: the navigation options, such asreplaceUrl,skipLocationChange, or customstate.previousNavigation: the previous successful navigation, ornullif there wasn't one.
The URL properties hold Angular UrlTree objects. Call router.serializeUrl() whenever you need their string form. Note as well that finalUrl is only guaranteed to be set after routes have been recognized.
That lets us either inspect navigation details or simply test whether the value is non-null, which tells us a navigation is underway.
Inside the application component we can derive a loading flag:
@Component(...)
export class AppComponent {
private readonly router = inject(Router);
protected readonly navigating = computed(() =>
this.router.currentNavigation() !== null
);
}
And then show an indicator next to the outlet:
@if (navigating()) {
<p role="status">Loading page...</p>
}
<router-outlet />
This gives feedback while our course resolver is loading, ahead of the target component appearing.
The flag tracks the router's state on its own.
Router.events is still there when you need granular event details, but a simple navigation indicator can read this signal directly.
Tracking the last successful navigation
The Router's lastSuccessfulNavigation is a Signal<Navigation | null>. It stores the most recent navigation that completed successfully, or null when nothing has succeeded yet.
Reading router.lastSuccessfulNavigation() returns that object. It exposes the same Navigation properties listed above, and a subsequent failed or cancelled navigation leaves it untouched.
We could derive its final URL like this:
@Component(...)
export class AppComponent {
private readonly router = inject(Router);
protected readonly lastSuccessfulUrl = computed(() => {
const navigation = this.router.lastSuccessfulNavigation();
const url = navigation?.finalUrl;
return url ? this.router.serializeUrl(url) : null;
});
}
The value stays available once the router goes idle. It describes the latest completed success, whereas currentNavigation() describes work still in progress.
Conclusions and Key Takeaways
Much of the Angular Router's signals integration boils down to input binding and naming conventions. Without enabling withComponentInputBinding(), that integration is easy to miss entirely.
Adopt the signals approach and everyday routing gets noticeably simpler. Parameters and resolved data show up as component inputs, and the component reacts using the signal APIs you already know.
For these common tasks, subscribing to the Router's Observable-based APIs is no longer necessary.
Let me know in the comments below if you have any questions. I'll be happy to help!
I hope you found this article helpful. Subscribe to my newsletter if you want me to send my next article straight to your inbox:
