Angular Router with Signals: Complete Guide

Did you know that the Angular Router already supports a complete signals-only approach to most of its features?

You might not even have noticed it, because most of it is based purely on opt-in configuration and naming conventions!

The fact is that you no longer have to use the old Observable-based APIs, such as ActivatedRoute, ActivatedRouteSnapshot, etc. that can feel a bit cumbersome.

With the signal integration, everything works in a much more seamless and intuitive way.

The problem is that the old and new APIs exist side by side, so it's very easy to mix them up accidentally.

To really highlight the new APIs of the Angular Router, we are going to explore it from a signals perspective only.

Table of Contents

  • Router setup for a signals-first approach
  • Path parameters as signal inputs
  • Query parameters as signal inputs
  • Route resources data as signal inputs
  • Active navigation with isActive
  • Navigation status with currentNavigation
  • Tracking the last successful navigation
  • Conclusions and Key Takeaways

So without further ado, let's get started with our Angular Router with Signals deep dive.

Router setup for a signals-first approach

The use of signals does not change the very basics of routing. We still need a route configuration, a registered router, an outlet, etc.

Most of the signal-based magic sauce comes from the use of the withComponentInputBinding() option in the router configuration, which is off by default:

export const appConfig: ApplicationConfig = {
    providers: [
      provideRouter(
        routes,
        withComponentInputBinding(),
        withRouterResources()
      )
    ]
};

Notice also the use of the optional withRouterResources(). This is another signal-based feature of the router that is off by default. It provides a signal-based alternative to resolvers.

The contents of the routes file follow the same format as usual.

For this guide, let's use a course edit route with a path parameter and a route resource.

The course resource is responsible for loading a course from the backend, based on the content of the url:

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 waits for the course resource to load before displaying the page.

We still render the component through a normal outlet as usual:

<main class="app-main">
  <router-outlet />
</main>

Let's use this URL throughout the examples:

/courses/edit-course/angular-router-course?mode=edit&couponCode=NEW_YEAR

It gives us a path parameter, two query parameters, and a resolved course. Let's see how all of them reach our component as signals.

Path parameters as signal inputs

To receive the :courseUrl path parameter, we declare an input with the same name in CourseEditComponent:

@Component(...)
export class CourseEditComponent {
  readonly courseUrl = input.required<string>();
}

And that's it!

With component input binding enabled, the router supplies the URL segment angular-router-course to this input.

It all happens purely through a naming convention: the name of the component input courseUrl matches the name of the path variable :courseUrl in the route configuration.

We read this input and use it like any other signal-based input.

It really is just a plain component input; there is nothing special about it:

<p>Course URL: {{ courseUrl() }}</p>

Notice that, unlike when using older router APIs, we didn't need to inject ActivatedRoute, subscribe to paramMap, or copy the value into a local field.

The input name establishes the connection.

The input is required because this route always includes :courseUrl.

That declaration expresses the component's expectation; it does not validate the URL or check whether the course exists.

Query parameters as signal inputs

Query parameters can also be injected into the component using exactly the same signal-based naming convention:

@Component(...)
export class CourseEditComponent {
  readonly mode = input<'edit' | 'create'>();
  readonly couponCode = input<string>();
}

For our example URL, mode() returns edit and couponCode() returns NEW_YEAR.

Notice that we don't declare query parameters in the route's path.

The router matches their names directly to the component inputs.

These inputs are optional because the page can be opened without either query parameter.

We can account for that in the template:

@if (couponCode(); as coupon) {
  <p>Coupon applied: {{ coupon }}</p>
}

Notice that input signals are read-only.

If a query parameter changes in the URL, the corresponding input signal will emit a new value.

Route resources data as signal inputs

Route resources load data before the router displays a component.

We can receive that data through signal inputs too, using the same naming convention.

Our route configuration already registers a a route resource:

  resources: ({ params }) => ({
    course: httpResource<Course>(() =>
      `/api/courses/${params()['courseUrl']}`
    )
  })

To receive the output of this route resource, we declare a component input named course:

@Component(...)
export class CourseEditComponent {
  readonly course = input.required<Course>();
}

The component receives the result through course() and can render it directly:

<h1>{{ course().description }}</h1>

So, as you can see, everything happens through naming conventions!

Active navigation with isActive

The standalone isActive(url, router) function returns a Signal<boolean>. It tells us whether the given URL is active and updates automatically as the route changes.

By default, matching includes child paths, so /courses/edit-course also matches /courses/edit-course/angular-router-course.

Here is how we use it:

@Component(...)
export class AppComponent {
  private readonly router = inject(Router);

  protected readonly editingCourse = isActive(
    '/courses/edit-course',
    this.router
  );
}

We can read the signal directly in a class binding:

<a routerLink="/" [class.editing]="editingCourse()">
  Angular Router In Depth
</a>

The Router's currentNavigation is a Signal<Navigation | null>. It holds the navigation in progress right now and goes back to null once that navigation succeeds, fails, or is cancelled and the router becomes idle.

Calling router.currentNavigation() reads the current Navigation object, or null if no navigation is running.

For example, when we click a course link, the object is available while the router processes the navigation, including waiting for the course data to load via the route resource.

The Navigation object describes a navigation operation. Its most useful properties include:

  • id: the unique identifier of this navigation.
  • initialUrl: the requested destination before redirects, represented as a UrlTree.
  • finalUrl: the destination after redirects, also a UrlTree. This can be undefined early in navigation.
  • trigger: how navigation started: imperative for an application navigation, popstate for a browser history event, or hashchange for a hash-change event.
  • extras: the navigation options, such as replaceUrl, skipLocationChange, or custom state.
  • previousNavigation: the previous successful navigation, or null if there wasn't one.

The URL properties contain Angular UrlTree objects. Use router.serializeUrl() when you need their string representation. Also, finalUrl is only guaranteed to be available after routes have been recognized.

So we can inspect the navigation details or simply check whether the value is non-null to know that navigation is in progress.

In 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
  );
}

Then display an indicator alongside the outlet:

@if (navigating()) {
  <p role="status">Loading page...</p>
}

<router-outlet />

This provides feedback while our course resolver is loading, before the target component appears.

The flag follows the router's state automatically.

Router.events remains available when we need individual event details, but a basic navigation indicator can use this signal directly.

Tracking the last successful navigation

The Router's lastSuccessfulNavigation is a Signal<Navigation | null>. It holds the most recent navigation that completed successfully, or null if no navigation has succeeded yet.

Calling router.lastSuccessfulNavigation() reads that object. It has the same Navigation properties described above, and a later failed or cancelled navigation does not replace it.

For example, we can derive its final URL:

@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;
  });
}

This value remains available when the router becomes idle. It describes the latest completed success, while currentNavigation() describes work in progress.

Conclusions and Key Takeaways

As you can see, much of the Angular Router's integration with signals comes down to input binding and naming conventions. Unless you enable withComponentInputBinding(), this integration is easy to miss!

Once you adopt the signals approach, everyday routing becomes much more straightforward. Parameters and resolved data arrive as component inputs, and your component reacts to them using the signal APIs you already know.

For these common tasks, you no longer need to subscribe to the Router's Observable-based APIs.

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: