If you’re just starting out with Angular, sooner or later you’ll come across the concept of routing. At first, the Router configuration may look like yet another array full of not-so-obvious properties such as path, children, loadChildren, canActivate or resolve.
In this article, we’ll take a look at the basics of routing in Angular. We’ll see what the Router is, how we define application paths, how we build more complex structures from them, and how regular route loading differs from lazy loading. We’ll also briefly cover guards and resolvers, so you know what they are, when they show up in the configuration and what they’re responsible for.
We won’t dive into every detail of the Angular Router. The main goal is to build a basic picture of how routing works and how to read its configuration in an existing application.
If you’d like to learn more, though, I recommend Miłosz’s article.
Routing
The vast majority of web applications consist of multiple screens. We might have a home page, a product list, the details of a specific product, a user profile or an admin panel. We usually want to tie each of these views to a specific URL:
/
/products
/products/42
/profile
/admin
In a traditional application, going to another address often means fetching an entirely new HTML page from the server. Angular, however, is a framework used, among other things, to build Single Page Applications, or SPAs. After the application loads for the first time, subsequent transitions between its views can happen without reloading the whole page.
And this is where routing comes in.
Routing lets us specify which part of the application should be displayed for a given URL. For this purpose, Angular provides the Angular Router – the framework’s official mechanism responsible for navigation.
Router
Put simply, the Router manages navigation within the application.
When the user wants to go to a given address, the router checks the routing configuration and looks for a path matching that address. If it finds one, it can display the component assigned to it.
Alongside the term Router, you may also come across the term Route. It’s easy to overlook, but these are two separate concepts:
Router – the mechanism that handles navigation
Route – a route or path: a single rule describing what should happen when the user enters a specific address
Routing configuration
We’ve just said that the router checks the routing configuration. So what is it?
With standalone components now in widespread use, you’ll find this configuration in the app.routes.ts file. In NgModule-based applications, on the other hand, routing is often defined in a separate module, for example app-routing.module.ts.
Regardless of the approach, though, paths are defined in the same way.
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { ProductsComponent } from './products/products.component';
import { ProductDetailsComponent } from './product-details/product-details.component';
export const routes: Routes = [
{
path: '',
component: HomeComponent,
},
{
path: 'products',
component: ProductsComponent,
},
{
path: 'products/:id',
component: ProductDetailsComponent,
},
];
Routes is an array containing the definitions of individual routes. Each object in it describes a single route or a fragment of the routing tree.
path – specifies the part of the address that should be matched by the Router
component – points to the component Angular should activate once this route is matched
So when we go to, for example, myApp.com/products, we activate ProductsComponent.
But an important question arises: where exactly on the page will we see this component?
RouterOutlet
In the application’s main template, we might find a code fragment like this:
<header>...</header>
<router-outlet />
<footer>...</footer>
RouterOutlet can be thought of as a spot reserved for the Router. This is where Angular can render the component corresponding to the currently active route.
Thanks to this, the header and footer can stay on screen, while the Router only changes the content between them.
Note that RouterOutlet doesn’t have to be in just one place in the application. There are situations where routes are nested, or where so-called named outlets appear, i.e. several outlets on the same level. But that’s a topic for a separate article. Just don’t be surprised, dear Reader, if you see multiple router outlets somewhere. That’s perfectly normal 🙂
Navigating between routes
To navigate between paths, e.g. from /products to /home, we can use Angular’s routerLink directive, which we apply to an HTML element.
<a routerLink="/">Home Page</a>
<a routerLink="/products">Products</a>
<a routerLink="/profile">Profile</a>
We can also navigate from a .ts file, using the Router mentioned earlier:
private readonly router = inject(Router);
openProducts(): void {
this.router.navigate(['/products']);
}
The first approach works well in templates, while programmatic navigation is useful when moving to another screen is the result of some application logic.
ActivatedRoute
ActivatedRoute represents the currently active route and lets a component read information related to the current URL.
We can use it, among other things, to get path parameters, query parameters or data assigned to the route.
Given the address /products/42, we can read the id parameter in the component:
private readonly activatedRoute = inject(ActivatedRoute);
ngOnInit(): void {
const id = this.activatedRoute.snapshot.paramMap.get('id');
}
ActivatedRoute is therefore an object through which a component can find out in what routing context it was started and what data was passed by the current route.
Since Angular 16, a URL parameter can also be received directly as a component input, with no need to inject ActivatedRoute. First, enable this feature in the router configuration, in app.config.ts:
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
],
};
Then, in the component, all you need to do is declare an input with the same name as the parameter in the path (products/:id):
import { Component, input } from '@angular/core';
@Component({
selector: 'app-product-details',
template: `<p>Product ID: {{ id() }}</p>`,
})
export class ProductDetails {
readonly id = input.required<string>();
}
Routing as a tree
At first, it’s easy to look at routing as a simple list of addresses:
/home
/products
/profile
/settings
In practice, it’s much better to think of routing as a tree.
Let’s assume our application also has an admin panel:
/admin
/admin/users
/admin/settings
In this case, we can represent the structure like this:
/
├── products
│ └── :id
├── profile
└── admin
├── users
└── settings
users and settings are routes that belong to the admin section here.
Angular lets us describe this relationship using children, which allows us to create further arrays of routes in a nested structure.
export const routes: Routes = [
{
path: 'products',
component: ProductsComponent,
},
{
path: 'admin',
component: AdminComponent,
children: [
{
path: 'users',
component: UsersComponent,
},
{
path: 'settings',
component: SettingsComponent,
},
],
},
];
This is exactly the case where we can use a second router outlet – in the admin route.
Naturally, we don’t have to keep the entire routing configuration in a single file. We can split it into many files and place them in different parts of the project.
app.routes.ts
products/
products.routes.ts
admin/
admin.routes.ts
profile/
profile.routes.ts
These topics – routing trees and splitting them into smaller segments – lead us smoothly to the concept of lazy loading, which comes up often in the Angular world.
Lazy loading – we don’t have to load everything at application startup
In the examples so far, the components used by routing were imported directly and referenced via the component property. This means their code is included in the code needed for the application’s initial load.
In a small application, this usually isn’t a big problem. As the project grows, though, we may end up with more and more independent parts, such as:
- the home page,
- a product catalog,
- a shopping cart,
- a user account,
- an admin panel,
- a reporting system.
A user may open the application just to browse a few products. In that case, do they need the code for the admin panel and reports right away, the first time the app opens?
Not necessarily.
In such a situation, we can use lazy loading, i.e. loading certain parts of the application only when they’re needed.
With simple routing, we could write:
import { AboutComponent } from './about/about.component';
export const routes: Routes = [
{
path: 'about',
component: AboutComponent,
},
];
AboutComponent is imported directly here and tied to the route.
With lazy loading, we use loadComponent:
export const routes: Routes = [
{
path: 'about',
loadComponent: () =>
import('./about/about.component')
.then(m => m.AboutComponent),
},
];
With this configuration, the component’s code will be downloaded only when the user wants to activate this route. This lets Angular split the application into separate chunks of code loaded on demand.
The same applies to the children approach:
- simple routing
export const routes: Routes = [
{
path: '',
component: HomeComponent,
},
{
path: 'admin',
component: AdminComponent,
children: [
{
path: 'users',
component: UsersComponent,
},
{
path: 'settings',
component: SettingsComponent,
},
],
},
];
- lazy loading
export const routes: Routes = [
{
path: '',
component: HomeComponent,
},
{
path: 'admin',
loadChildren: () =>
import('./admin/admin.routes')
.then(m => m.ADMIN_ROUTES),
},
];
In this case, the main configuration knows that an /admin branch exists, but the details of its routing can be loaded only when they’re needed.
This doesn’t mean, however, that every route has to be lazy loaded. Lazy loading reduces the amount of JavaScript needed for the initial load, but it also means another chunk of the application has to be downloaded during later navigation. The Angular documentation therefore presents lazy loading as a tool whose use should be tailored to the structure of your application.
Guards and resolvers
A routing configuration can contain more than just information about the path and the component. We can also specify additional mechanisms to run during navigation.
At this stage, the most important thing is to recognize them in the routing configuration. The implementation details of guards and resolvers can be treated as a separate, more advanced topic.
Guard
A guard lets you decide whether the user can go to a given route. It can be used, for example, to check whether the user is logged in or has the required permissions.
{
path: 'admin',
component: AdminComponent,
canActivate: [authGuard],
}
canActivate means here that before the Router activates AdminComponent, it should run the specified guard.
If the guard allows the navigation, the Router can continue activating the route. If the guard blocks it, the user won’t get to that view. A guard can also return information that redirects the user to a different route.
So we can think of it as a checkpoint:
user wants to go to /admin
↓
guard
/ \
allowed? not allowed?
↓
/admin
Angular has several kinds of guards intended for different situations. Among other things, we can control whether a route can be entered, whether it can be matched, or whether the current screen can be left.
Resolver
A resolver lets you prepare the data a given route needs before the route is activated.
Let’s assume the user goes to /products/42.
In the routing configuration we have:
{
path: 'products/:id',
component: ProductDetailsComponent,
resolve: {
product: productResolver,
},
}
The :id fragment is a route parameter. For the address /products/42, its value will be 42.
Before the Router activates ProductDetailsComponent, it will run the productResolver function. The resolver can read the value of the id parameter, use it to fetch the data of the product with ID 42, and then pass that data on to the route.
As a result, the component can start with its data already prepared, instead of only beginning to fetch it once the screen has been entered.
Summary
At first, a routing configuration can look very modest:
{
path: 'products',
component: ProductsComponent,
}
Once we learn a few more features, though, we may come across a route that looks more like this:
{
path: 'products/:id',
loadComponent: () =>
import('./product-details/product-details.component')
.then(m => m.ProductDetailsComponent),
canActivate: [authGuard],
resolve: {
product: productResolver,
},
}
And even though at first glance there are many more elements here, they all describe one thing – what should happen when the user navigates to a specific part of the application.
- The Router has to find a matching route.
- It may load the code needed for it.
- It may check guards.
- It may run resolvers.
Finally, it activates the appropriate part of the routing tree and displays it in the RouterOutlet.
The actual navigation process of the Angular Router is more complex and has its own stages and events, but at the beginning we don’t need to know all of its details.
Let’s briefly sum up the key concepts:
Router manages navigation.
Routes describes the configuration of available routes.
Route describes a single route or a fragment of the tree.
RouterOutlet marks the place where the activated component can appear.
children lets you build a nested routing tree.
loadComponent and loadChildren let you load parts of the application only when they’re needed.
Guards let you influence whether navigation can happen.
Resolvers let you prepare the data a route requires before it’s activated.
These are only the basics of the Angular Router, but knowing them is already enough to find your way around the routing configuration of an existing application much more easily. The next time we see app.routes.ts, instead of treating it as a list of mysterious objects, we can look at it as a map showing the structure of our application and how to navigate it.