Angular Routing Essentials: All You Need to Know in One Post
This is the only post about Angular Routing that you need in order to fully master it. I will cover all the essential concepts of Angular routing, starting with the basics like defining routes, links, and outlets, and finishing with more advanced topics like data resolvers and route protection.
Topics that are covered:
- Why do we need routing
- Defining routes
- Router outlet
- Router links
- Routes order
- Dynamic params
- Accessing params
- Query params
- Redirects
- Not found route
- Redirect function
- Nested routes
- Active routes
- Exact route match
- Programmatic navigation
- Lazy loading
- Route guard
- Route resolver
Why do we need routing?
An Angular application is a tree of components that are rendered from top to bottom, starting with the AppComponent
. There are no exceptions.
But most applications also need to render dynamic content on different pages. When we navigate to /dashboard
, we should see the dashboard of our application, and by accessing /posts
, it should render a list of posts.
It is not possible to achieve this with just a tree of components.
Instead, we use a router
to specify which component should be rendered on which page. You can think of it as a child component of AppComponent
that switches based on the URL being accessed.
It allows us to define which components we want to render for each route. Let's look at how to do that.
Defining routes
To achieve that, we must register our components as routes. If you generated a new Angular application and selected that you need routing, your app.routes.ts
file will look like this.
// src/app/app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = []
The routes
is an array where we define our routes. But the question is, where do we use this configuration? There’s no magic; it must be used somewhere.
// src/app/app.config.ts
import { routes } from './app.routes';
...
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideAnimationsAsync(),
],
};
Here we have our app configuration, where we import and use our routes. We pass them to provideRouter
to register a router in our application.
To define a route, we must add an object to our array of routes.
import { DashboardComponent } from './dashboard/dashboard.component';
export const routes: Routes = [
{
path: 'dashboard',
component: DashboardComponent,
}
]
This object contains a path
field, which can be any string (in our case, "dashboard"). It must be a unique string and not a URL, and it should not start with a slash. We also pass a component
field to our object. DashboardComponent
is the component that we want to render for this path.
// app/dashboard/dashboard.component.ts
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.css',
})
export class DashboardComponent {}
This is just a standalone component.
Now, inside the browser, we can type /dashboard
and we won't get an error. Yes, we only see an empty layout for now, but that's fine.
If we try to type a URL that doesn't exist, we will get an error.
This confirms that our /dashboard
URL was successfully registered in the Angular application.
Router outlet
But we don't see any content — just the word "layout." Where is this coming from? It's the markup in AppComponent
.
<div class="container">
<div class="headline">Layout</div>
<div class="links"></div>
<div class="main">
</div>
</div>
In the Angular world, AppComponent
is essentially the layout. This is the only component being rendered on the screen right now. However, we don’t see the content of our DashboardComponent
. For every route, the content should be different, and for /dashboard
, it should be the DashboardComponent
.
To do that, we must use a feature called an outlet
.
Inside the AppComponent
, we rendered a router-outlet
component, which comes from Angular Router. However, we get an error stating that router-outlet
is not a known element. This is correct because we haven't imported it as a dependency in our AppComponent
.
import { RouterOutlet } from '@angular/router';
...
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.css',
})
...
Now, this component is properly registered in our AppComponent
.
When we reload the page, the DashboardComponent
is properly rendered inside our layout.
We use the router-outlet tag to render dynamic content when we change routes.
Let's do exactly the same for several other components.
// src/app/app.routes.ts
import { ProductsComponent } from './products/products.component';
import { SettingsComponent } from './settings/settings.component';
export const routes: Routes = [
...
{
path: 'products',
component: ProductsComponent
},
{
path: 'settings',
component: SettingsComponent,
}
];
Now we can navigate to /settings
, and the SettingsComponent
is rendered there.
Router links
But what about links to all these pages? Here’s how we do that.
<div class="links">
<a routerLink="/dashboard">dashboard</a>
<a routerLink="/products">products</a>
<a routerLink="/settings">settings</a>
</div>
Instead of using <a href>
like we do in regular HTML, we use routerLink
instead of href
because <a>
here is a special component of Angular Router. However, this code won't work by itself.
import { ..., RouterLink } from '@angular/router';
...
@Component({
...
imports: [..., RouterLink]
})
We must inject the RouterLink
into our AppComponent
. Only then will our links work properly.
Now all these links are working, and we can navigate between pages without reloading, with the content changing accordingly.
Routes order
Here is an extremely important point for you: If you don't remember it, you will encounter bugs later.
The order of routes is extremely important.
export const routes: Routes = [
{
path: 'dashboard',
component: DashboardComponent
},
{
path: 'dashboard',
component: ProductsComponent
}
];
Angular checks routes one by one, starting from the first one. If the path is /dashboard
, it picks the first route that matches. It doesn't matter if you have other routes that could also fit /dashboard
. While it's true that with simple strings you usually don't encounter collisions, routes can be defined in various ways and can match different patterns. Therefore, URL collisions are not uncommon.
Dynamic params
We learned how to render routes, but what about dynamic parameters? What are dynamic parameters? They are values that can vary within your URL.
/pages/1
In this URL, the part after the slash is a dynamic parameter that can change. We typically use dynamic parameters for URLs where we don’t have a static string. Examples of dynamic URLs include a page ID like /pages/5
or a slug like /products/a-dish-washer
.
export const routes: Routes = [
...
{
path: 'pages/:pageId',
component: PageComponent
}
];
To define a dynamic parameter, we use a similar construction with path
and component
. The only difference is that we include a :pageId
, which is our dynamic parameter. It can be any string you want, but it must start with a colon.
Accessing params
But now you probably want to know how we can access these parameters inside our components. It doesn't make sense to render the same content for different parameters. Typically, we want to render one article with ID 1
and another article with ID 2
.
There are different ways to access parameters inside components but I want to show you the newest and the best because it is extremely simple and declarative. And we will use signals for that.
There are different ways to access parameters inside components, but I want to show you the newest and best method because it is extremely simple and declarative. We will use signals for that.
// src/app/page/page.component.ts
import { Component, input } from '@angular/core';
@Component({
selector: 'app-pages',
standalone: true,
imports: [],
templateUrl: './page.component.html',
styleUrl: './page.component.css',
})
export class PageComponent {
pageId = input.required<string>();
}
Inside our PageComponent
, I defined a pageId
input. Just to remind you, this is the dynamic parameter specified with a colon in our routes array. So, our input MUST have the same name as the dynamic parameter to be properly bound. If you don’t know what this construction input.required
is, it’s an Angular signal, and pageId
in this case is an InputSignal
. But most importantly, this pageId
value will come automatically via Angular Router without any additional code from our side.
<!-- src/app/page/page.component.ts -->
<h2>Page - {{pageId()}}</h2>
We can switch to the HTML and render our signal.
But in the browser, we see that our component was not rendered, and we get an error: Input is required but no value is available yet
. This means that we didn't provide an input to the component. While the input is indeed provided automatically by the router, it is not done by default.
// src/app/app.config.ts
import { provideRouter, withComponentInputBinding } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
...
provideRouter(routes, withComponentInputBinding()),
],
};
We need to enable this feature by adding withComponentInputBinding
to the configuration. This additional feature forces Angular Router to provide all the fields inside our component.
Now it is working; we get Page - 2
, and we implemented it with just a single line of code. Previously, we needed to write much more code in Angular, but as you can see, it has been significantly improved with signals.
Query params
In exactly the same way that we read parameters from the URL, we can also read query parameters.
// src/app/page/page.component.ts
export class PageComponent {
...
limit = input.required<string>();
}
Here, we added a limit
as a parameter to our PageComponent
, but we didn’t specify that limit
is a query parameter. In fact, we didn’t specify that pageId
is a parameter either. Angular Router will simply take whatever it has inside the data.
<!-- src/app/page/page.component.ts -->
<h2>Page - {{pageId() - {{limit()}}}}</h2>
Let’s render it in our HTML now.
After we provided a limit
as a query parameter, it was successfully rendered on our page. This means Angular Router reads the query parameter and provides it as an input to our component. In this way, you can read various router data through Angular signals.
Redirects
Another important feature of Angular Router is the ability to implement redirect logic.
// src/app/app.routes.ts
export const routes: Routes = [
{
path: '',
redirectTo: 'dashboard',
pathMatch: 'full',
},
...
]
Here, we created a new route, but we didn’t provide a path
and a component
. Instead, we used an empty string as the path
, which represents our home route, a redirectTo
property that defines where we want to redirect the user, and a pathMatch
, which ensures that the path must be fully matched. This allows us to specify how to redirect one URL to another. In this case, we want to redirect from the home URL directly to the dashboard URL.
Now, when we navigate to the home page, we are directly redirected to /dashboard
.
Not found route
We should also remember to handle "not found" pages correctly. Right now, when we access a page that we haven't registered, we get an error: Cannot match any routes
.
To fix this problem, we need to register a route that will match any undefined route.
// src/app/app.routes.ts
import { NotFoundComponent } from './not-found/not-found.component';
export const routes: Routes = [
...
{
path: '**',
component: NotFoundComponent,
},
]
In this case, our path must be **
, which means this component is rendered for all undefined routes. This is why it’s extremely important that this route is placed last. If you put it at the beginning, it will be used for every route, even those we have registered. This is a perfect example of why route order is important.
Now, when we navigate to a non-existent route like /foo
, our NotFoundComponent
is rendered.
Redirect function
Sometimes you may encounter a complex use case for redirection in your application where a simple redirect isn't enough. In such cases, you can create a redirect as a function with custom logic inside.
Let’s imagine that previously we had URLs like /old-pages/1
in our application with a dynamic parameter. Now we've moved our routes to /pages/1
, and we want to redirect from old pages to new pages, because there may still be old URLs across the internet that need to work.
// src/app/app.routes.ts
export const routes: Routes = [
...
{
path: 'old-pages/:pageId',
redirectTo: (route) => {
return `/pages/${route.params['pageId']}`;
},
},
]
For this, we can define redirectTo
not as a string but as a function. The function receives route
as an argument, so we can access dynamic parameters or the URL if needed. In this case, we read pageId
from the route parameters and return a new URL to which the user will be redirected.
When we access /old-pages/1
, Angular redirects us to /pages/1
.
Nested routes
We learned how to create routes, but what about nested routes? Sometimes we want to nest our routes to access different parts of our application. Let’s create a nested route inside /settings
.
// src/app/app.routes.ts
import { SettingsProfileComponent } from './settings/profile/profile.component';
export const routes: Routes = [
...
{
path: 'settings',
component: SettingsComponent,
children: [
{
path: 'profile',
component: SettingsProfileComponent,
}
]
},
]
To do that, we add a children
property to our settings
route. The children
property is an array containing different routes. It’s important to remember that the child route is not settings/profile
; it is just profile
. Angular will correctly interpret this declaration and create the route /settings/profile
automatically.
In the browser, we can access the /settings/profile
route, but only the content of the settings page is visible. We don’t see our SettingsProfileComponent
. To render it, we must use the <router-outlet>
component, just like we did before.
<!-- src/app/settings/settings.component.html -->
<h2>Settings</h2>
<div class="main">
<router-outlet />
</div>
We added <router-outlet>
here, but we should also import it into our component.
// src/app/settings/settings.component.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-settings',
standalone: true,
imports: [RouterOutlet],
templateUrl: './settings.component.html',
styleUrl: './settings.component.css',
})
export class SettingsComponent {}
Now it renders not only the settings page but also the profile component inside. It’s also important to remember that we can still access the /settings
URL separately if needed.
Active routes
Let’s add some links to our markup to check if there is a problem.
<!-- src/app/app/app.component.html -->
<div class="links">
<a routerLink="/dashboard">dashboard</a>
<a routerLink="/products">products</a>
<a routerLink="/settings">settings</a>
<a routerLink="/settings/profile">profile</a>
<a routerLink="/pages/1">pages/1</a>
</div>
None of the links are highlighted on the screen as active. Typically, we want to highlight the active link to indicate which page we are on. How can we do that?
<!-- src/app/app/app.component.html -->
<div class="links">
<a routerLink="/dashboard" routerLinkActive="active">dashboard</a>
<a routerLink="/products" routerLinkActive="active">products</a>
<a routerLink="/settings" routerLinkActive="active">settings</a>
<a routerLink="/settings/profile" routerLinkActive="active">profile</a>
<a routerLink="/pages/1" routerLinkActive="active">pages/1</a>
</div>
We added a routerLinkActive
attribute which will add an active
class when we are on this link. But in order for it to work we must inject a dependency to our component.
We added a routerLinkActive
attribute, which will add an active
class when the link is active. However, for it to work, we must import a dependency into our component.
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
@Component({
...
imports: [RouterOutlet, RouterLink, RouterLinkActive]
})
Now, our active link is highlighted. We are on /settings
, and it is red.
Exact route match
But there’s a problem: When we navigate to /settings/profile
, both links are highlighted.
Why does this happen? By default, Angular doesn’t do a full match for the URL. Both /settings
and /settings/profile
are partially matched with the route, so Angular highlights them both.
<!-- src/app/app/app.component.html -->
<div class="links">
<a routerLink="/dashboard" routerLinkActive="active">dashboard</a>
<a routerLink="/products" routerLinkActive="active">products</a>
<a
routerLink="/settings"
routerLinkActive="active"
[routerLinkActiveOptions]="{exact: true}"
>settings</a>
<a routerLink="/settings/profile" routerLinkActive="active">profile</a>
<a routerLink="/pages/1" routerLinkActive="active">pages/1</a>
</div>
This is not ideal for us, so we must provide routerLinkActiveOptions
with exact: true
. This will perform a full match for the URL and avoid the problem of highlighting multiple links.
Now, when we are on the profile page, the settings link is not highlighted.
Programmatic navigation
One more important feature is programmatic navigation. This is when you have an event and want to trigger a router change. How can we do that?
<!-- src/app/dashboard/dashboard.component.html -->
<h2>Dashboard</h2>
<div>
<button class="goToProductsBtn" (click)="goToProductsPage()">
Go to products
</button>
</div>
Inside DashboardComponent
, I have a button where I want to add a click event called goToProductsPage
. When the user clicks on the button, we want to redirect them to the /products
page.
// src/app/dashboard/dashboard.component.ts
export class DashboardComponent {
router = inject(Router);
goToProductsPage(): void {
this.router.navigateByUrl('/products');
}
}
To perform navigation, we need to inject the Router
into our component. Inside goToProductsPage
, we can call navigateByUrl
, which will change the page. It’s important to remember that we pass a full URL starting with a slash, not just a path like in the routes.
We clicked on our button on the /dashboard
page and were redirected to the /products
page.
However, navigateByUrl
is not the best option when you want to concatenate strings with variables.
// This will generate /categories/1/products/2 URL
this.router.navigate(['categories', categoryId, 'products', productId]);
In this case, using navigate
is easier, as Angular will concatenate our parameters for us.
Lazy loading
Another important functionality is lazy loading of routes. Lazy loading allows us to load a component only when we access that route, instead of bundling all components into a single file.
export const routes: Routes = [
...
{
path: 'products',
loadComponent: () =>
import('./products/products.component').then((c) => c.ProductsComponent),
}
]
For the /products
route, we used loadComponent
with an import inside the component
property. This is how we implement lazy loading, in contrast to normal bundling.
When we navigate from /dashboard
to /products
, we can see in the Network tab that a chunk of JavaScript was loaded. This chunk contains our ProductsComponent
, indicating that it was loaded only after we accessed this page. It is loaded separately and is not included in the main bundle.
Lazy loading is extremely important if you want to make your bundle smaller and your application faster.
Route guard
Now we need to understand how to restrict access to some pages in Angular. To do this, we use guards. What is a guard? It's a special function that checks your access before you can access the route. Here’s how it looks:
// src/app/auth.guard.ts
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { filter, map } from 'rxjs';
import { CurrentUserService } from './currentUser.service';
export const authGuard = () => {
const currentUserService = inject(CurrentUserService);
const router = inject(Router);
return currentUserService.currentUser$.pipe(
filter((currentUser) => currentUser !== undefined),
map((currentUser) => {
if (!currentUser) {
router.navigateByUrl('/');
return false;
}
return true;
}),
);
};
It is just a function which returns an Observable
or boolean
. We inject inside CurrentUserService
and check the data of current user. If we have correct data inside then return true, which allows access to this page. In other case we redirect a user to the homepage.
import { authGuard } from './auth.guard';
export const routes: Routes = [
...
{
path: 'settings',
component: SettingsComponent,
children: [
{
path: 'profile',
component: SettingsProfileComponent,
},
],
canActivate: [authGuard],
},
]
To use this guard, we must provide it in the canActivate
field of a route. In our case, it’s the settings route. Now, when we are not logged in, we cannot access this page.
Route resolver
The last feature you need to know about Angular Router is a resolver. What is a resolver? It can resolve some data or make an API call before you access a route. Here’s how it looks:
// src/app/data.resolver.ts
import { ResolveFn } from '@angular/router';
import { of } from 'rxjs';
export const pageResolver: ResolveFn<Object> = (route, state) => {
const pageId = route.paramMap.get('pageId');
return of({
pageId,
name: 'Foo',
});
};
It is just a function that returns some data. Typically, you will return an Observable
if you want to make an API call. In this case, we read a parameter from the URL and return an object.
import { pageResolver } from './data.resolver';
export const routes: Routes = [
...
{
path: 'pages/:pageId',
component: PageComponent,
resolve: {
page: pageResolver,
},
},
]
Now, inside our routes, we can add a resolve
property to our page route and specify that the result of pageResolver
must be available in the page
property.
// src/app/page/page.component.ts
export class PageComponent {
pageId = input.required<string>();
limit = input.required<string>();
page = input.required<{ pageId: string; name: string }>();
}
In the same way we did with pageId
and limit
, we get the page
data through the resolver, which we can then use in the component.
<h2>Page - {{ pageId() }} - {{ limit() }} - {{ page().name }}</h2>
We successfully rendered the name
property from our resolver on the screen.
Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!
📚 Source code of what we've done