Back to the homepage
Angular

Angular Router – everything you need to know about

Routing in Angular is a critical feature that enables the creation of dynamic, single-page applications (SPA). This mechanism allows seamless navigation between different views within the application without requiring a full page reload. As a modern framework for building front-end applications, Angular provides powerful and flexible tools for managing URL paths, empowering developers to create intuitive and responsive user interfaces.

A single-page application (SPA) works by loading a single HTML page and dynamically updating the content as the user interacts with the app. Instead of sending a new request to the server for each new page, SPAs fetch data asynchronously and update the current page, which results in faster and more fluid user experiences. This is achieved through a combination of JavaScript, HTML, and CSS, with Angular handling the dynamic aspects and state management efficiently.

In this article we are going to explain in detail route definition, matching and protection, extensions and configuration options for Router itself and utilizing the navigation process.

Routing Configuration

To enable routing in your application the very first thing you need to do is using provideRouter function to set up necessary providers:

provideRouter(routes: ROUTES, ...features: RouterFeatures[]): EnvironmentProviders

in your configuration:

bootstrapApplication(AppComponent, { providers: [provideRouter(ROUTES)] });

This way is still relatively new as it was introduced with standalone components. In module-based applications it’s been done using static method forRoot of RouterModule:

static forRoot(routes: Routes, config?: ExtraOptions):
	ModuleWithProviders<RouterModule>

and importing RouterModule itself in AppModule:

@NgModule({
   imports: [RouterModule.forRoot(ROUTES)]
})
export class AppModule { }

Routes definition

Router is ready to use but we need to define Routes so an array of Route objects. It’s a recipe for Angular how to navigate through the application. The two most important ingredients are: a path and a component associated with.

Path is a piece of text which builds a URL displayed in the browser’s address bar. It can be static as well as dynamic. Dynamic path (param) starts with colon which indicates this piece of URL can be replaced with some value. Text after the colon is param’s name which can be used to retrieve that value.

Paths in the URL are separated by slashes which creates a hierarchical structure. This characteristic is reflected in route configuration by children parameter. It accepts nested routes array (Routes) creating a tree of routes. 

const ROUTES: Routes = [
   { path: ‘dashboard’, component: DashboardComponent },
   { path: ‘products’, component: ProductsComponent, children: [
      { path: ‘top’, component: TopProductsComponent },
	{ path: ‘:id’, component: ProductDetailsComponent }
   ]},
   { path: ‘’, redirectTo: ‘/dashboard’, pathMatch: ‘full’ },
   { path: ‘**’, component: PageNotFoundComponent }
]

Order of the routes matters. Angular uses a first-match wins strategy and takes the first route it’s satisfied with. That’s why more specific routes should be declared before less specific ones. This is a reason why route “top” is defined before route “:id”. Otherwise “top” would be recognized as the value of id param and Angular would open a page with ProductDetailsComponent component.

As you can see the empty route defines pathMatch parameter. It affects matching process and accepts two values:

  • “prefix” (default) – this option matches the route when the configured path is a prefix of the entire URL. Route “admin” would match all URLs like “/admin”, “admin/settings”, “admin/users”: 
{ path: ‘admin’, component: AdminComponent, pathMatch: ‘prefix’ }
  • “full” – This option matches the route only if the entire URL matches the configured path. If the route with empty path in the example above didn’t have pathMatch set to “full” user would never see a page with PageNotFoundComponent component after providing invalid URL. Instead they would be always redirected to dashboard page as empty string can be matched as a beginning of every URL

Two asterisks (**) is a wildcard and matches any URL so it should be always defined as the last route. Router selects this route if it isn’t able to match any other which means provided URL points to the page that doesn’t exist so we display PageNotFoundComponent component.

Sometimes you may need to redirect user to another page. Like in this example if the URL is empty user is redirected to the dashboard page. To do this use the redirectTo property. It accepts static path user should be redirected to or function to handle more complicated cases:

type RedirectFunction = (
  redirectData: Pick<
    ActivatedRouteSnapshot,
    'routeConfig' | 'url' | 'params' | 'queryParams' | 'fragment' | 'data' | 'outlet' | 'title'
  >,
) => string | UrlTree

{
   path: ‘old-dashboard-page’,
   redirectTo: ({ url }) => {
      if (url.contains(‘v2’)) return ‘/dashboard’
      else {
         inject(NotifictionService).open(‘Page no longer available’, Theme.ERROR);
         return ‘/not-found’
      }
   }
}

Lazy loading

Lazy loading is a basic but quite effective optimization technique. You can use it in your routing configuration to defer fetching components (or modules) until user navigates to a specific route instead of loading all of the code when application starts. It breaks your application code into smaller bundles (chunks) and loads them asynchronously which reduces initial bundle size improving performance. It’s an essential technique for building scalable and performant applications as the larger the application, the greater the improvement.

You can notice it in your network tab so let’s create a small experiment. A simple application will serve as the test object. It has two routes which display a table from Angular Material. In the first scenario we don’t use lazy loading:

All files have been loaded initially and after navigating through routes nothing happens here. Pay attention to the size of main.js. Now let’s use lazy loading and check out results:

As you can see, the initial size of main.js decreased and chunks with table components were loaded asynchronously after navigating to route.

We are sure it works so how to use this pattern? Just passing a function to loadComponent property which asynchronously imports the component:

const ROUTES: Routes = [
  {
    path: 'user',
    loadComponent: () =>
      import('./user.component').then((c) => c.UserComponent),
  },
];

It’s very important to use the import function inside the loadComponent function. This way we don’t have an import statement (import {UserComponent} from ‘./user.component’) on top of the file which would make the component eagerly loaded anyway.

In module-based applications it’s been done by using loadChildren function to load NgModule which imports RouterModule defining routes using forChild static method:

const ROUTES: Routes = [
  {
    path: 'user',
    loadChildren: () => import('./user.module).then(m => m.UserModule),
  },
];

@NgModule({ imports: [RouterModule.forChild(USER_FEATURE_ROUTES)] })
export class UserModule {}

In this example USER_FEATURE_ROUTES contains user-related subroutes so UserModule serves as an entry point to the “user” feature. It’s a very convenient pattern to structure your files by domains. But we don’t use NgModules anymore…

Having one huge constant with all routes in application definitely sounds like an antipattern. We want to keep them separated in files on the feature level. But we can’t just import a file because it would be eagerly loaded. Fortunately there is a simple and effective solution. Function assigned to loadChildren can lazy load not only a module but also a file with your routes:

const ROUTES: Routes = [
  {
    path: 'user',
    loadChildren: () =>
      import('./user.routes).then((m) => m.USER_FEATURE_ROUTES),
  },
];

For the sake of simplicity in this article I don’t use lazy loading in my examples – eagerly loaded component syntax is much shorter. But in your application you definitely should always use lazy loading. 

Route Matcher

Route matcher is a function that allows you to create your custom logic for matching routes. It’s useful if you need more complex matching than default matcher offers. It returns UrlMatchResult, so consumed segments and an object holding resolved values for path parameters, or null If the URL doesn’t meet your requirements.

type UrlMatcher = (
  segments: UrlSegment[],
  group: UrlSegmentGroup,
  route: Route,
) => UrlMatchResult | null

type UrlMatchResult = {
  consumed: UrlSegment[];
  posParams?: {[name: string]: UrlSegment};
}

Let’s implement an example matcher which is supposed to match the route if the username param is its X (Twitter) name (so it starts with “@”) and it should trim “@” out of the value passed as path param:

export const ROUTES: Routes = [
  {
    path: 'users',
    component: UsersListComponent,
    children: [
      {
        matcher: nameMatcher,
        component: UserComponent,
        children: [{ path: 'details', component: UserDetailsComponent }],
      },
    ],
  },
];

const nameMatcher: UrlMatcher = (
  url: UrlSegment[],
): UrlMatchResult | null => {
  const usernameSegment = url[0];

  if (usernameSegment.path.match(/^@[\w]+$/gm))
    return {
      consumed: [usernameSegment],
      posParams: {
        username: new UrlSegment(usernameSegment.path.slice(1), {}),
      },
    };
  else return null;
};

If the user provides the URL “users/@angularlove/details” parameter url has value: [“@angularlove”,”details”] as segment “users” has been already consumed by the UserListComponent route. The first segment is the username we are interested in. We match that value against the given regex checking for a name which starts with “@”. If the username is valid we return UrlMatchResult object. 

Property consumed contains URL segments we have used (so “consumed”) to match the route as every segment can be used only once for matching – in this case it’s “@angularlove” segment. If we also consumed the “details” segment, Router would have matched UserComponent route but it wouldn’t have matched UserDetailsComponent route as there would have been no more segments to consume.  

Property posParams is an object defining path params. We create a param with the name “username” and assign to it a value trimmed out of “@” from the beginning. To avoid type error we need to create a new UrlSegment with that value. The second parameter of the UrlSegment constructor is matrix params as they are also part of a URL segment – but we don’t use matrix params in this case so we just pass an empty object. Now we can retrieve path param in component:


@Component({ ... })
export class UserComponent implements OnInit {
  readonly usernameParam$ = inject(ActivatedRoute).paramMap.pipe(
    map((paramMap) => paramMap.get('username')),
  );

  ngOnInit() {
    this.usernameParam$.subscribe(console.log); // ‘angularlove’
  }
}

Route Guards

Route guards serve as checkpoints for navigation, allowing you to control whether a user can navigate to or leave a particular route. They are essential for implementing security measures, managing authentication, and enforcing access control within your Angular application.

CanActivate

This guard determines whether a route can be activated or not. It’s commonly used for implementing authentication or authorization checks before allowing access to protected routes.

type CanActivateFn = (
 route: ActivatedRouteSnapshot,
 state: RouterStateSnapshot
) => MaybeAsync<GuardResult>

Firstly let’s explain those types:

  • The first argument – route is a snapshot with parameters of the route you are trying to navigate to like params, queryParams, data, etc. 
  • Argument state contains URL and parameters of root route.
  • Type MaybeAsync is a following union type:
    MaybeAsync<T> = T | Observable<T> | Promise<T>
  • Type GuardResult describes types of value returned from guard function:
    GuardResult = boolean | UrlTree | RedirectCommand

The RedirectCommand was introduced in Angular 18 and it allows you to redirect, just like using UrlTree, specifying navigation behavior:

RedirectCommand.constructor( 
   redirectTo: UrlTree, 
   navigationBehaviorOptions?: NavigationBehaviorOptions | undefined
)

One route can be protected by many guards so:

  • if all of them return true navigation continues
  • if any of them return false navigation is canceled
  • if any of them return a UrlTree or RedirectCommand, the current navigation is canceled and user is redirected to route described by one

Now let’s see how it works in practice and create an authentication guard:

const authGuard: CanActivateFn = (): boolean | UrlTree => {
  const authService = inject(AuthService);
  const router = inject(Router);
  return authService.isAuthenticated() || router.createUrlTree([‘/login’])
}

Logic in our guard needs only access to AuthService so we can omit route and state parameters. If a user is authenticated, navigation continues. In other case the user is redirected to the login page. Notice that guard functions are executed inside injection context so we can safely use inject function to get required dependencies. 

To protect a component from not authenticated users we need to pass our guard to canActivate array in Route object:

const ROUTES: Routes = [
  { 
    path: ‘some-path’,
    component: MyComponent, 
    canActivate: [authGuard] 
  }
];

Functional guards were introduced in Angular 14. By this time developers had been defining guards as services implementing adequate interface. This solution is marked as deprecated but it’s quite a chance you can still encounter it in some projects. Our authGuard as a service would look like:

@Injectable({ providedIn: 'root' })
class AuthGuard implements CanActivate {
  constructor(
    private readonly authService: AuthService,
    private readonly router: Router
  ) {}

  canActivate(): boolean | UrlTree {
    return (
      this.authService.isAuthenticated() ||
      this.router.createUrlTree(['/login'])
    );
  }
}

CanActivateChild

Let’s imagine we have a welcome page with many subpages with features which require a user to be logged in to open them. Here we can use the already created authGuard. Routes configuration could look like this:

const ROUTES: Routes = [
  {
    path: '',
    component: WelcomeComponent,
    children: [
      {
        path: 'feature-1',
        component: Feature1Component,
        canActivate: [authGuard],
      },
      {
        path: 'feature-2',
        component: Feature2Component,
        canActivate: [authGuard],
      },
	...
      {
        path: 'feature-10',
        component: Feature10Component,
        canActivate: [authGuard],
      }
    ],
  },
];

It doesn’t look good. Definitely too much repetition which should be avoided. Here CanActivateChild comes to the rescue. It allows control of whether child routes can be activated at a parent level.

type CanActivateChildFn = (
 childRoute: ActivatedRouteSnapshot,
 state: RouterStateSnapshot,
) => MaybeAsync<GuardResult>

As you can see it’s almost the same as CanActivate. Here we can still use authGuard as it satisfies both types:

const ROUTES: Routes = [
  {
    path: '',
    component: WelcomeComponent,
    canActivateChild: [authGuard],
    children: [
      {
        path: 'feature-1',
        component: Feature1Component,
      },
      {
        path: 'feature-2',
        component: Feature2Component,
      },
	...
      {
        path: 'feature-10',
        component: Feature10Component,
      }
    ],
  },
];

Much better! But let’s modify that scenario. We need to add another child route with a contact page – but this one shouldn’t require being authenticated to open it. Does it mean we need to come back to the previous solution and define guards for each child independently? Not necessarily.

We can use the Component-Less Route pattern and create a “middleware” route with an empty path and no component assigned. It doesn’t change the way routing works and allows to group certain routes together:

const ROUTES: Routes = [
  {
    path: '',
    component: WelcomeComponent,
    children: [
      {
        path: 'contact',
        component: ContactComponent
      },
      {
        path: '',
        canActivateChild: [authGuard],
        children: [
          {
            path: 'feature-1',
            component: Feature1Component,
          },
          {
            path: 'feature-2',
            component: Feature2Component,
          },
	    ...
          {
            path: 'feature-10',
            component: Feature10Component,
          }
        ]
      }
    ],
  },
];

Applying CanActivateChild to component-less route causes the guard function to be called every time a feature page is opened. You can also use CanActivate guard – this way authGuard is called only if a user navigates to one of feature pages from outside of the group and it isn’t called while navigating between features. This technique might be useful especially with resolvers to reduce redundant http requests.

It’s also worth mentioning that since component-less route doesn’t have any assigned component, it doesn’t affect the way by which you could retrieve routing parameters in feature components using ActivatedRoute.

CanDeactivate

This guard checks if a route can be deactivated or not. It’s often utilized for prompting users with confirmation dialogs when they attempt to leave a page with unsaved changes.

type CanDeactivateFn<T> = (
 component: T,
 currentRoute: ActivatedRouteSnapshot,
 currentState: RouterStateSnapshot,
 nextState: RouterStateSnapshot,
) => MaybeAsync<GuardResult>

This guard as well as previous ones accepts parameters related to router parameters – so the current state (currentRoute and currentState) and the state after navigating away (nextState). Moreover it accepts component parameter because logic inside a guard often relies on the internal state of a component assigned to the route.

Let’s create such a type of guard. We want it to be reusable so it’s a good practice to define an interface that every component assigned to route protected by CanDeactivate should implement:

interface SafeDeactivate {
  get canBeDeactivated(): boolean
}

It contains a getter function that determines whether a user can safely navigate away. For example, a component containing a form can define this getter to return whether the form is in pristine state.

const canDeactivate: CanDeactivateFn<SafeDeactivate> = (
  component: SafeDeactivate
): boolean | Observable<boolean> =>
  component.canBeDeactivated ||
  inject(MatDialog)
    .open(ConfirmationDialog)
    .afterClosed()
    .pipe(map(response => !!response));

Our guard checks if a user can safely navigate away and if not it opens a confirmation dialog to let the user know that unsaved data will be lost and make sure they still want to change a page.

const routes: Routes = [
  { 
    path: ‘form’,
    component: MassiveFormComponent, 
    canActivate: [canDeactivate] 
  }
];

CanMatch

To better understand how this guard works let’s discuss the navigation process:

  1. It all begins with some user interaction – so clicking on button or link or even manually changing the URL. 
  2. Then Angular tries to match the new URL with the provided routing configuration. As it uses first-match wins strategy it takes the first route it’s satisfied with. 
  3. Now it can perform lazy-loading if it was defined and load appropriate chunk
  4. Route is recognized but Angular needs to make sure the user is allowed to navigate. As we know this job is done by guards we’ve already discussed. So let’s say the user tries to navigate from route A to route B and guards are executed:
    1. Angular checks if route A can be deactivated (CanDeactivate)
    2. if route B is a child route Angular executes CanActivate guard on its parent and CanActivateChild guard – which refers to route B
    3. and finally checks if the user can navigate to component B itself (CanActivate)
  5. At this time route can be activated so Angular resolves data and instantiate assigned component

As you can see, described guards come to the party at point 4. when the route is already matched. So how CanMatch guard works? It’s called at the stage 2 – when Angular tries to match url with route configuration and makes it skippable. 

type CanMatchFn = 
(route: Route, segments: UrlSegment[]) => MaybeAsync<GuardResult>

If any of CanMatch guards return false the route is skipped for matching and further route configurations are processed instead. This is especially useful if you want to associate two different components with the same path segment. Suppose you want to create a route “dashboard” for users and admin. Obviously they should have different functionalities. Instead of creating a shell component which would conditionally display appropriate dashboard (creating unnecessary layer of logic) you can use CanMatch:

const canMatchAdmin: CanMatchFn = (): boolean =>
	inject(AuthService).isAdmin();

const canMatchUser: CanMatchFn = (): boolean =>
	inject(AuthService).isUser();

const routes: Routes = [
  { 
    path: ‘dashboard’,
    component: AdminDashboardComponent, 
    canMatch: [canMatchAdmin] 
  },
  { 
    path: ‘dashboard’,
    component: UserDashboardComponent, 
    canMatch: [canMatchUser] 
  }
];

CanLoad

CanLoad guard has been deprecated and replaced with CanMatch. It’s behavior is very similar. It controls whether a lazy-loaded module associated with loadChildren parameter in route configuration can be fetched. The goal is to prevent unnecessary loading of a module if a user doesn’t have access to it anyway.  

type CanLoadFn = (route: Route, segments: UrlSegment[]) => 
	MaybeAsync<GuardResult>

The motivation to deprecate it was that lazy loading should serve solely as an optimization technique and should not function as an architectural feature controlling whether to lazy load a module based on some internal condition. Besides that it isn’t as universal as CanMatch. CanLoad works only if you use lazy loading and you try to lazy load a module – it doesn’t work for lazy-loaded standalone components.

Resolver

While not being strictly a guard, resolver is often grouped with route guards as it allows you to pre-fetch some of the data when the user navigates from one route to another. The router waits for the data to be resolved before the route is finally activated. If you encounter an error while resolving the data you can redirect the user to another page using RedirectCommand.

type ResolveFn<T> = (
  route: ActivatedRouteSnapshot,
  state: RouterStateSnapshot,
) => MaybeAsync<T | RedirectCommand>

Let’s create a user resolver:

const userResolver: ResolveFn<User> = (route: ActivatedRouteSnapshot) => {
  const router = inject(Router);
  return inject(UserService)
    .getUser(route.paramMap.get('id') ?? '')
    .pipe(
      catchError(() =>
        of(
          new RedirectCommand(router.createUrlTree(['/not-found']), {
            skipLocationChange: true,
          }),
        ),
      ),
    );
};

Resolvers for the route aren’t defined in array, like guards, but in the object assigned to resolve property:

{ 
   path: ‘user/:id’,
   component: UserDetailsComponent, 
   resolve: { user: userResolver } 
}

The key associated with resolver can be used to retrieve resolved data in component from ActivatedRoute:

@Component({ ... })
export class UserDetailsComponent {
   readonly user$: = inject(ActivatedRoute).data.pipe(
      map(data => data?.user)
   );
}

Sometimes we have to show data from many sources, usually as separate components, on one page. Displaying loading indicators for all of them may distract user especially as each piece of data can arrive at the other point of time. Instead we may want to preload all of the data and then smoothly navigate to the page. It sounds like a perfect job for resolvers:

{
   path: dashboard,
   component: DashboardComponent,
   resolve: {
      user: userResolver,
      orders: ordersResolver,
      payments: paymantsResolver
   }
}

This way the dashboard page opens as soon as all the data is available. But until then a user waits on the current page. To increase user experience even more we can let them know that the navigation is being processed. Here router events come handy so let’s create a utility function that indicates if resolvers are running:

// Use only inside the injection context
export function isResolveInProgress(): Observable<boolean> => 
   inject(Router).events.pipe(
      filter(e => e instanceof ResolveStart || e instanceof ResolveEnd),
      map(e => e instanceof ResolveStart)
   )

Don’t worry about using many resolvers, like three in this case. These events fire only once (per navigation) – ResolveStart when a user tries to activate the route and ResolveEnd when all resolvers are completed.

Rerunning guards and resolvers

Guards and resolvers always run when a route is activated or deactivated. When a route is unchanged you can define a policy whether they rerun or not using runGuardsAndResolvers property. Possible values are combinations of cases when a path param, matrix param (params separated by semicolons), query param or fragment change should cause rerunning guards and resolvers and when it can be ignored:

If you need even more flexibility you can also define a function which accepts snapshots of routes you navigate from and to as parameters and returns boolean:

type RunGuardsAndResolversFn = (
  from: ActivatedRouteSnapshot,
  to: ActivatedRouteSnapshot
) => boolean

This functionality is used rather rarely but it might be useful when you want to synchronize changes from UI with your URL (like changing sort order in table) without unnecessary running guards and resolvers.

Setting up page title

Page title is a piece of text you can see on a tab in your browser next to favicon. Setting meaningful titles for your routes increases user experience and positively affects SEO.

The most basic way is just to pass a value to title property in route definition. However, passing static strings isn’t a convenient way and we often need to create title based on some dynamic value. As always we are given tools to precisely configure anything we need. In this case Angular allows us to pass a resolver to dynamically create a title. Let’s create a title resolver to display product name as a title on details page:

const productNameTitleResolver: ResolveFn<string> = (
  route: ActivatedRouteSnapshot,
): string => {
  const productId = route.paramMap.get('id');
  return productId ? inject(ProductsService).getById(productId).name : '';
};

export const ROUTES: Routes = [
  {
    path: 'products',
    component: ProductsComponent,
    title: 'Products',
    children: [
      {
        path: ':id',
        component: ProductDetailsComponent,
        title: productNameTitleResolver
      },
    ],
  },
  {
    path: 'cart',
    component: CartSummaryComponent,
    title: 'Cart Summary'
  },
];

If you have a generic part of the title, like your application name, which should be included in many (or all) routes, you can define a custom title strategy. All you have to do is to create service which extends TitleStrategy class:

abstract class TitleStrategy {
  abstract updateTitle(snapshot: RouterStateSnapshot): void;
  buildTitle(snapshot: RouterStateSnapshot): string;
  getResolvedTitleForRoute(snapshot: ActivatedRouteSnapshot): any;
}

Our strategy is to add the application name before the existing route title. If the route doesn’t have a title defined it sets just the application name. To get the title provided for the activated route (static or resolved) we use the buildTitle method. Setting a new title is possible using Title service.

@Injectable()
export class AppNameTitleStrategy extends TitleStrategy {
  private readonly titleService = inject(Title);

  override updateTitle(snapshot: RouterStateSnapshot) {
    const routeTitle = this.buildTitle(snapshot);
    this.titleService.setTitle(routeTitle ? `MyApp | ${routeTitle}` : 'MyApp');
  }
}

The only thing left is to register a new strategy as a provider. We want to apply it for all routes so global configuration is a good place to go:

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(ROUTES),
    { provide: TitleStrategy, useClass: AppNameTitleStrategy },
  ],
});

Defining provider for route

Since Angular 14 it’s possible to define providers in route configuration. This way dependency is provided in Environment Injector. This type of injector is created along with creation of dynamically loaded components. Angular looks into it right after traversing Element Injector’s tree. To make a long story short this provider is available for route’s component and its descendants – but if you want to learn more about Dependency Injection you are welcome to check out our other article.  

Router Features

As you may remember, function provideRouter accepts as parameter not only routes but also router features. These can be enabled by calling special functions but if your application is still module-based these features can be configured using an options object (second parameter of RouterModule.forRoot method). Let’s review what possibilities they offer.

Component Input Binding

It’s a very useful feature introduced in version 16 which allows to bind routing-related properties, such as path params, query params, resolvers results and data (additional developer-defined data provided in route by data property), directly to inputs of component associated with route without the necessity of injecting ActivatedRoute service. 

To use it call withComponentInputBinding function in provideRouter:

provideRouter(ROUTES, withComponentInputBinding())

Now let’s take example route definition:

{
  path: ':id',
  data: { description: 'Customer profile page' },
  resolve: { customer: customerResolver },
  component: CustomerProfileComponent,
}

and let’s assume that CustomerProfileComponent displays a table with customer orders so we can expect “page” and “size” query parameters in the url to handle pagination for that table. 

Without input binding we would have to create all properties manually:

@Component { … }
export class CustomerIdComponent {
  private readonly route = inject(ActivatedRoute);

  readonly customerId$ = this.route.paramMap.pipe(
    map((paramMap) => paramMap.get('id')),
  );

  readonly customer$ = this.route.data.pipe(
    map((data) => data['customer']),
    map((customer) => (isCustomerType(customer) ? customer : null)),
  );

  readonly page$ = this.route.queryParamMap.pipe(
    map((queryParamMap) => queryParamMap.get('page')),
    map((page) => (page ? parseInt(page) : null)),
  );

  readonly size$ = this.route.queryParamMap.pipe(
    map((queryParamMap) => queryParamMap.get('size')),
    map((size) => (size ? parseInt(size) : null)),
  );

  readonly description$ = this.route.data.pipe(
    map(data => data['description']),
  );
}

Using input binding saves a lot of boilerplate:

@Component { … }
export class CustomerIdComponent {
  customerId = input<string | undefined>(undefined, { alias: 'id' });
  customer = input<Customer | undefined, unknown>(undefined, {
    transform: (customer: unknown) =>
      isCustomerType(customer) ? customer : undefined,
  });
  page = input(undefined, { transform: numberAttribute });
  size = input(undefined, { transform: numberAttribute });
  description = input<string>();
}

As we don’t bind values to those inputs manually in template but Angular does it automatically, not minding what type is declared, it’s worth paying attention to type safety using transform functions to avoid errors in runtime. 

Router configuration options

The withRouterConfig function is a wrapper to pass a configuration object without creating a separate feature function for each property. 

canceledNavigationResolution

Configure how the router should restore state when a navigation is cancelled:

  • “replace” (default) – sets browser state to the router state before navigation start – so the router replaces the item in history rather than trying to restore to the previous location in history. 
  • “computed” – router attempts to return to the index in browser history that corresponds to its state when the navigation gets cancelled

urlUpdateStrategy

Defines when the router should update the browser URL:

  • “deffered” (default) – after successful navigation
  • “eager” – at the beginning of navigation. This approach allows to handle navigation error using URL that failed

onSameUrlNavigation

Determines how to handle navigation request to current URL:

  • “ignore” (default) – router ignores the request
  • “reload” – router processes the URL event if it isn’t different from the current state. It might be useful if your trigger redirection, guards or resolvers depending on internal state which may have changed. Remember that even if you set that option to “reload” the route reuses a component instance by default

paramsInheritanceStrategy

Defines how the router merges parameters, data and resolved values from parent to child routes:

  • “emptyOnly” (default) – child route inherits param only if its path is empty string or its parent doesn’t have a component attached (component-less route)
  • “always” – child route inherits all parameters from ancestors

resolveNavigationPromiseOnError

If set to true and navigation error occurs the navigation Promise will resolve with value false (like for other navigation fails e.g. guard rejection) instead of being rejected.

Preloading strategy

We have already discussed how lazy loading works and why it’s such an important concept. However, with some core features, you can know in advance that a user will lazy load a component (module). In that case it might be useful to preload the component (module) and avoid unnecessary latency – it isn’t included in the initial bundle yet ready to use (almost) at the start.

To customize preloading strategy use withPreloading function. It accepts a class that extends PreloadingStrategy abstract class:

abstract class PreloadingStrategy {
  abstract preload(route: Route, fn: () => Observable<any>): Observable<any>
} 

Angular offers two predefined preloading strategies:

  • NoPreloading (default) – doesn’t preload any chunk
  • PreloadAllModules – preloads all chunks as quickly as possible

Of course you can create your custom preloading strategy like one based on the flag passed to the data parameter:

@Injectable({ providedIn: 'root' })
export class FlagBasedPreloadingStrategy extends PreloadingStrategy {
  override preload(
    route: Route,
    preload: () => Observable<any>,
  ): Observable<any> {
    return route.data?.['preload'] === true ? preload() : of(null);
  }
}

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(
      [
	  ...,
        {
          path: 'dashboard',
          loadComponent: () =>
            import('./dashboard.component').then((m) => m.DashboardComponent),
          data: { preload: true },
        },
      ],
      withPreloading(FlagBasedPreloadingStrategy),
    ),
  ],
});

Scrolling Memory

Let’s imagine a user scrolling through a list and clicking on an item to open a page with its details. It would create a nice user experience to restore the scrolling position after navigating back. This is a feature the withInMemoryScrolling function offers. The function accepts as parameter a configuration object with two properties:

  • anchorScrolling – if set to “enabled” and URL has a fragment page scrolls to the anchor element 
  • scrollPositionRestoration – configures the scroll position when navigating back. Three options are available:
    • “disabled” – changes nothing
    • “top” – sets the scroll position to top of the page 
    • “enabled” – restores the previous scroll position after navigating back

Scroll restoration happens because of emitting a Scroll router event which stores the scroll position. If your list is fetched from a server (usually it is) you might be confused why scrolling restoration doesn’t work. It’s because the Scroll event is emitted right after the NavigationEnd event and data from a server arrives with some delay. To fix it you can create a service that stores a position to restore and do that job with the right timing.

type ScrollPosition = [number, number];

@Injectable({ providedIn: 'root' })
export class ScrollRestorationService {
  private readonly viewportScroller = inject(ViewportScroller);

  private readonly position = toSignal(
    inject(Router).events.pipe(
      filter((event): event is Scroll => event instanceof Scroll),
      map((scrollEvent): Scroll => scrollEvent.position ?? [0, 0]),
    ),
    { initialValue: [0, 0] },
  );

  restoreScrollingPosition() {
    this.viewportScroller.scrollToPosition(this.position());
  }
}

Now all you have to do is call the restoreScrollingPosition method as soon as the list is rendered (using directive or viewChild query might be helpful).

View Transition

The View Transitions API is another great tool to improve user experience. It allows you to create nice element transitions navigating between pages. Using the withViewTransitions function you don’t have to configure the API manually and you can focus only on defining great animations for your transitions.

You can pass additional configuration using a configuration object with properties:

  • skipInitialTransition – allows to disable animation on page load
  • onViewTransitionCreated – callback with ViewTransitionInfo object as parameter to create more customization like skipping transition if only query params have changed 

Navigation Error Handler

To handle navigation errors better use withNavigationErrorHandler feature which provides a function called when navigation error occurs. This function runs inside the injection context so you can safely use your services for handling. Additionally you can convert errors to redirect by returning RedirectCommand. Returning value other than RedirectCommand is ignored to let Router handle the error like default.

provideRouter(
  ROUTES,
  withNavigationErrorHandler((error: NavigationError) => {
    inject(LoggerService).log(error.toString());
    return new RedirectCommand(inject(Router).parseUrl('/error'), {
      skipLocationChange: true,
    });
  }),
)

Initial navigation

Sometimes you need to configure when the router performs the initial navigation. Default behavior (“enabledNonBlocking”) is to start initial navigation after the root component has been created. The bootstrap is not blocked on the completion of the initial navigation.

You can block initial navigation (“enabledBlocking”), by calling a withEnabledBlockingInitialNavigation function, until the root component is created. The bootstrap is blocked until the initial navigation is complete. This feature is required for server-side rendering to work to avoid double loading or page flickering.

Another option is to block initial navigation (“disabled”) by calling withDisabledInitialNavigation which you can use to have more control due to some complex initialization logic.

Location strategy

In SPA applications we allow the client (browser) to handle routing instead of doing traditional requests to the server after URL changes to get results from there. To handle routing logic Angular Router can use two location strategies.

PathLocationStrategy is the default strategy in Angular. It takes advantage of History API which requires browsers to support HTML 5. By using pushState it can change URL without sending requests for a page to the server. This way we get a URL looking just like any other so it can be shared or bookmarked and handling routing on the client side.

Unfortunately it has a big downside: if we reload a page with address e.g. “my-app/users/123/orders” browser makes a request to the server with that address so using the PathLocationStrategy server needs to be able to return main application code (so index.html with app-root tag) for every URL, not just the root one. 

Moreover we need to tell the browser what should be prefixed to the requested path to generate the URL. It can be done by specifying base href in head section of index.html:

<base href=’your/prefix’ />

or by providing a value for the APP_BASE_HREF token.

Another option is HashLocationStrategy. To enable it use withHashLocation function. This strategy uses hash fragments so part of the URL prepended with hash (#) character so URL would look like: “my-app/#/users/123/orders”. 

For a number of years it was the primary way of handling client-side routing because a hash fragment never gets sent to the server and it stores the state of your client side application. In this example there is only ever one URL (“my-app”) according to the server and the hash fragment (“users/123/orders”) is used only by Router.

Nowadays you should use HasLocationStrategy only if you need to support older browsers.

Debug tracing   

Calling function withDebugTracing causes logging all router events to the console which might be helpful during debugging.

Utilizing routing in components

We have got through a lot of configuration stuff. Now it’s time to use it in action. 

RouterLink

Firstly, user should have the possibility to conveniently navigate through the application. Apply routerLink directive to an element in template to make that element a link initiating navigation to a route. Path is definitely the most commonly used parameter. You can defined it by:

  • creating a static link to the route:
<a routerLink=’/users/123’> Link to user page </a>
  • using dynamic values to generate a link passing an array of path segments followed by params:

    <a [routerLink]=”[‘/users’, userId]”> Link to user page </a>
  • merging static segments into one term and combining with params

    <a [routerLink]=”[‘/settings/users’, userId]”> Link to user page </a>

As matrix params are part of segment these can be also defined in this array:

<a [routerLink]=”[‘/users’, userId, {details: true}]”> Link to user page </a>

Such a defined router link (assuming userId is “123”) navigates to “/users/123;details=true”.

Defining route links it’s very useful to define relative paths. This can be achieved by using the prefix. So if the first segment starts with:

  • / – router looks up the route from the root
  • ./ or without prefix – router looks up in the children of current route
  • ../ – router goes up one level in route tree

Query params can be defined providing params object to queryParams input:

<a routerLink=”/users” [queryParams]=”{showInactive: showInactiveUsers, sortBy: ‘name’}”> Users </a>

That link navigates to the URL: “/users?showInactive=true&sortBy=name”. When you need to navigate between two URLs with some query parameters you can specify a way these should be handled using queryParamsHandling input:

  • empty string or not provided (default) – replace current parameters with new ones
  • “merge” – merge new parameters with current parameters
  • “preserve” – preserve current parameters

Another part of URL is fragment which can be defined using fragment input:

<a [routerLink]=”[‘/products’, productId]” [queryParams]=”{currency: ‘EUR’}” fragment=”pricing”> Product </a>

That link navigates to the URL: “/products/123?currency=EUR#pricing”. By default fragments are replaced during navigation. If you want to preserve a current fragment use preserveFragment input. 

Besides URL creation options you can also use other inputs related to navigation behavior:

  • relativeTo (ActivatedRoute) – specifies a root for relative navigation
  • skipLocationChange (boolean) – navigates without pushing a new state into history (so the URL remains unchanged)
  • replaceUrl (boolean) – navigates replacing the current state in history
  • state (object) – value to be persisted to browser’s History.state property. It can be retrieved from extras object returned by getCurrentNavigation method
  • info (unknown) – used to convey transient information about particular navigation. This is assigned to current Navigation so it’s different from the persisted state value

RouterLinkActive

For better UX user should be aware which link is associated with the active route. The routerLinkActive directive allows to specify CSS classes applying styles to element when linked route is active:

<a routerLink=”/users” routerLinkActive=”class1 class2”>Users</a>
<a routerLink=”/orders” [routerLinkActive]=”[‘class1’, ‘class2’]”>Orders</a>

To directly check status of the link you can assign RouterLinkActive instance to template variable:

<a routerLink=”/users” routerLinkActive #active=”routerLinkActive”>Users {{ active.isActive ? ‘(active)’ : ‘’ }}</a>

It also exposes an output to notify when link becomes active or inactive:

<a routerLink=”/users” routerLinkActive=”active-link” (isActiveChange)=”onLinkActiveChange($event)”>Users</a>

To configure how to determine if the router link is active use routerLinkActiveOptions input which accepts configuration object: 

interface IsActiveMatchOptions {
  matrixParams: "exact" | "subset" | "ignored";
  queryParams: "exact" | "subset" | "ignored";
  paths: "exact" | "subset";
  fragment: "exact" | "ignored";
}

As you may notice properties can be one of followed values:

  • “exact” – parameters must match exactly
  • “subset” – parameters may contain extra elements but must much ones existing in current URL
  • “ignored” – parameter is ignored

Of course you don’t have to explicitly define all parameters every time. If you pass an object {exact: true} the equivalent is:

const exactMatchOptions: IsActiveMatchOptions = {
  paths: 'exact',
  fragment: 'ignored',
  matrixParams: 'ignored',
  queryParams: 'exact',
}

If you pass an object {exact: false} or doesn’t pass anything to the input it results in following shape of configuration object:

const subsetMatchOptions: IsActiveMatchOptions = {
  paths: 'subset',
  fragment: 'ignored',
  matrixParams: 'ignored',
  queryParams: 'subset',
}

RouterOutlet

Now user can navigate through the application using links so we need to display the chosen page. The RouterOutlet directive inserts the component matched from the URL. 

<nav>
  <ul>
    <li><a routerLink="/users" routerLinkActive="active">Users</a></li>
    <li><a routerLink="/orders" routerLinkActive="active">Orders</a></li>
  </ul>
</nav>

<!-- Displays UserComponent if “/users” matches the URL –>
<!-- Displays OrdersComponent if “/orders” matches the URL –>
<router-outlet></router-outlet>

The RouterOutlet exposes four outputs:

  • activate – emits when a new component is instantiated
  • deactivate – emits when a component is destroyed
  • attach – emits a attached component instance when reuse strategy instructs to re-attach a previously detached subtree
  • detach – emits a detached component instance when reuse strategy instructs to detach a subtree

Multiple outlets navigation using named outlets

Each outlet can have a unique name defined by name input. The name needs to be a static value. If not set, the default name is “primary”. For most of the cases we don’t care about a name as we use only one outlet in a component. 

However you can define many outlets in a single component, give them names and create separate branches in a navigation tree as each outlet is independent from another. Let’s create a simple example and assume we split the viewport in half:

<div class=”users-container”>
   <!-- You need to have one primary outlet –>
   <router-outlet></router-outlet>
</div>
<div class=”orders-container”>
   <router-outlet name=”orders”></router-outlet>
</div>

Then assign outlet name to Route property called outlet to differentiate between them:

const ROUTES: Routes = [
  {
    path: 'users',
    component: UsersSectionComponent,
    children: [
      { path: '', component: UsersListComponent },
      { path: ':id', component: UserDetailsComponent },
    ],
  },
  {
    path: 'orders',
    component: OrdersSectionComponent,
    outlet: 'orders',
    children: [
      { path: '', component: OrdersListComponent },
      { path: ':id', component: OrderDetailsComponent },
    ],
  }]

As the users section is displayed by the primary outlet navigation work as usual. But for the orders section RouterLinks need some adjustments. To define a link for navigation through the named outlet branch use a configuration object that has outlets property. This property defines key-value pairs with outlet name and path array (just like typically using RouterLink):

<a [routerLink]="['', {outlets: { orders: ['orders',order.id] } }]">#{{order.id}}</a>

This link is supposed to open order details in the orders section without changing the page in the users section.

Another interesting thing here is URL shape. Part related to primary outlet doesn’t differ but part of the URL related to named outlet is inside the brackets preceded by outlet name. In this example opening a user details on the left section and order details on the right section would result in URL looking like: my-app/users/123(orders:orders/345)

Router Service

Described directives are useful to handle navigation using template elements but sometimes we need to perform more complex logic or utilize navigation in service or function. To interact with the router in such cases we use Router service. 

The most useful functionality is of course performing navigation. This can be done using two methods. The first one is the navigate which configuration is very similar to the RouterLink directive. The first argument is an array of commands (URL segments) – like routerLink input of the directive. The second argument is NavigationExtras object containing all parameters that can be defined by other inputs of RouterLink like query params, fragment, params handling, etc.

@Component( { … } )
export class SomeComponent {
  private readonly router = inject(Router);

  navigateToUserOrders(userId: string) {
    this.router.navigate(['users', userId, 'orders'], {
      queryParams: { showCompletedOrders: true },
    });
  }
}

The second method is navigateByUrl which accepts URL and NavigationBehaviorOptions object. The URL needs to be string or UrlTree so it contains all informations about the destination

@Component( { … } )
export class SomeComponent {
  private readonly router = inject(Router);

  navigateToUserOrders(userId: string) {
    const url = `/users/${userId}/orders?showCompletedOrders=true`;
    this.router.navigateByUrl(url)
  }
}

The Router service gives us also very handy utility methods to transform URL:

  • serializeUrl transforms UrlTree into string
  • parseUrl transforms string into UrlTree
  • createUrlTree creates UrlTree from an array of segments

 

As you may have already noticed, navigation is a complex process and a lot is going on. Each step of this process is represented by the RouterEvent. If you need to keep track of these events to react to them the Router service gives access to the stream of them by events property. 

 

Router events in order of being triggered:

  • NavigationStart – navigation is initiated. It contains information about what triggered navigation (navigationTrigger):
      • “imperative”- triggered using Router methods (as Router always navigates forward)
      • “popstate” – triggered by browser specific action like clicking browser back button, using window.history object or Location service (navigating to previous item in history)
      • “hashchange” – triggered on fragment change
  • RouteConfigLoadStart – just before lazy loading of route configuration
  • RouteConfigLoadEnd – route configuration has been lazy loaded
  • RoutesRecognized – Router has matched a route with URL (see CanMatch guard). If the matched path requires a lazy loaded route configurationconfiguration or module, it will be loaded at this point
  • GuardsCheckStart – guards begin checking if route can be navigated to
  • ChildActivationStart – activating route’s children
  • ActivationStart – activating a route
  • GuardsCheckEnd – all guards have given access to the route
  • ResolveStart – just before resolving data by resolvers
  • ResolveEnd – all resolvers successfully completed their job
  • ChildActivationEnd – triggered when the router finishes activating a route
  • ActivationEnd – triggered when the router finishes activating the child routes for a given route
  • NavigationEnd – navigation ends successfully
  • Scroll – restoring scroll position (see Scrolling Memory chapter)

Of course during navigation something could have gone wrong so we have two other events for that purpose:

  • NavigationCancel – navigation is canceled because a guard didn’t let in (returned false) or guard or resolver decided to redirect (returning UrlTree or RedirectCommand)
  • NavigationError – navigation fails due to an unexpected error

Router events have already been used in few examples in this article but the most iconic one is implementing loading indicator:

function showNavigationLoadingIndicator(): Signal<boolean> {
  return toSignal(
    inject(Router).events.pipe(
      filter(
        (e) =>
          e instanceof NavigationStart ||
          e instanceof NavigationEnd ||
          e instanceof NavigationCancel ||
          e instanceof NavigationError
      ),
      map((e) => e instanceof NavigationStart),
      debounceTime(200),
      distinctUntilChanged(),
    ),
    { initialValue: false },
  );
}

Another very meaningful property is routerState. The state of the router is represented as a tree where every node is an ActivatedRoute instance. Using its properties you can traverse the tree from any node. Speaking of which…

ActivatedRoute Service

The ActivatedRoute service is a real treasury of knowledge about a route associated with component loaded in an outlet. It provides all information about URL (url, params and paramMap, queryParams and queryParamsMap, fragment, data) route configuration (title, routeConfig) and its position in router tree (root, parent, firstChild, children, pathFromRoot).

As you may know, properties related to the URL are Observables as they can change over time. If you need a static value use snapshot property. The ActivatedRouteSnaptshot class has the same shape and holds the latest values from these Observables.

Configuration Injection Tokens

It’s a common pattern in Angular that we pass a configuration by providing it as an Injection Token. Dependency Injection offers a lot of flexibility and it’s definitely worth taking advantage of. We have already used that pattern to provide custom title strategy but it’s not the only token we can use.

UrlSerializer

Serializing and deserializing the URL can be easily customizable using UrlSerializer. Let’s take a simple example. We want to replace space character code (%20) into plus character (+). All we need to do is provide custom serializer:

class CustomUrlSerializer implements UrlSerializer {
  private readonly defaultUrlSerializer = new DefaultUrlSerializer();

  parse(url: string): UrlTree {
    return this.defaultUrlSerializer.parse(url.replace(/\+/g, '%20'));
  }

  serialize(tree: UrlTree): string {
    return this.defaultUrlSerializer.serialize(tree).replace(/%20/g, '+');
  }
}

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(ROUTES),
    { provide: UrlSerializer, useClass: CustomUrlSerializer },
  ],
});

Now using “my param” as a query param value results in “some-url?query=my+param” instead of “some-url?query=my%20param”.

RouteReuseStrategy

Navigating between pages means Angular needs to remove the currently displayed component from DOM, destroy its instance and then create a new component to be rendered in DOM. It’s an expensive process as it requires JS script to be executed. By default it happens with every navigation, except from navigating to the same route, and may lead to performance issues if component size is large.

To face that problem Angular allows to define custom reuse strategy to store a component instead of destroying it and reuse its instance when a route is accessed again. This behavior can be defined by a class implementing RouteReuseStrategy. Let’s implement a reuse strategy based on a flag in route definition.

It defines five methods:

  • shouldDetach – determines if this route (and its subtree) should be detached to be reused later
  • store – is responsible for storing detached tree
  • shouldAttach – determines if a route can be retrieved from storage
  • retrieve – returns instance to be reused
  • shouldReuseRoute – decides whether route should be reused (start the process at all)
export class FlagBasedReuseStrategy implements RouteReuseStrategy {
  private readonly storage = new Map<string, DetachedRouteHandle>();

  shouldDetach(route: ActivatedRouteSnapshot): boolean {
    return route.routeConfig?.data?.['reusable'];
  }

  store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null) {
    const routeComponentName = route.routeConfig?.component?.name;
    if (routeComponentName && handle)
      this.storage.set(routeComponentName, handle);
  }

  shouldAttach(route: ActivatedRouteSnapshot): boolean {
    return this.storage.has(route.routeConfig?.component?.name ?? '')
  }

  retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
    return this.storage.get(route.routeConfig?.component?.name ?? '') ?? null
  }

  shouldReuseRoute(
    future: ActivatedRouteSnapshot,
    curr: ActivatedRouteSnapshot,
  ): boolean {
    return (
      future.routeConfig === curr.routeConfig ||
      future.routeConfig?.data?.['reusable']
    );
  }
}

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(ROUTES),
    { provide: RouteReuseStrategy, useClass: FlagBasedReuseStrategy },
  ],
});

Conclusions

Routing in Angular is a cornerstone feature that underpins the development of dynamic, single-page applications. By mastering Angular’s routing capabilities, developers can create highly interactive and seamless user experiences, mimicking the performance and fluidity of native applications. Throughout this article, we’ve explored the essentials of Angular routing, from basic configurations to advanced techniques. 

Understanding and effectively implementing routing in Angular not only improves the performance and responsiveness of your applications but also enhances maintainability and scalability. With the robust tools provided by Angular, developers can build sophisticated routing solutions tailored to the specific needs of their projects.

As you continue to develop your skills in Angular, keep experimenting with routing configurations and advanced features to fully leverage the framework’s capabilities. A solid grasp of routing will empower you to build more complex and user-friendly applications, positioning you as a proficient and versatile developer in the ever-evolving landscape of web development.

About the author

Milosz Rutkowski

Angular Developer at House of Angular. Web application development enthusiast with a zeal to learn more and more about Angular. Fan of clean code, good practices and well composed architecture.

Don’t miss anything! Subscribe to our newsletter. Stay up-to-date with the latest trends, tips, meetups, courses and be a part of a thriving community. The job market appreciates community members.

Leave a Reply

Your email address will not be published. Required fields are marked *