We use cookies to enhance your experience on the site
CodeWorlds

The Tōkaidō Trails - routing in Angular

Your application has grown, @name. There is a warrior roster in it, there is a dojo screen, there is an armoury, and you switch between them the way you learned when you took a component apart: one field on the class, a few

@if
blocks in the template, one click and the view changes. At first glance everything works.

Now try sending someone the armoury screen. You copy the address out of the browser bar, paste it into a message, your friend opens it - and lands on the start screen, because the bar held one and the same address the entire time. Press the back arrow: instead of returning to the previous view you drop out of the application altogether. Refresh the page: you are back at the very beginning, because that field on the class only ever lived in memory. The one thing a user bookmarks and passes on, the address, knows nothing at all about your application.

At the Academy nobody travels by guesswork. Between Edo and Kyoto runs the Tōkaidō - a great road cut into post stations, and at every station stands a board with its name. Give a traveller the name of a station and he arrives exactly where you stood. Routing builds you that same road out of the address bar: you assign one screen to every address, and Angular sees to it that the address and the view always say the same thing. Back begins stepping through your application, a refresh returns you where you were, and a copied address leads your friend to the right gate.

What you will learn

  • lay out the map of the road as an array of routes
  • hand that map to Angular with a single function in the config file
  • mark the gate in the template where the current route's screen appears
  • lead the user with links and light up the trail he is standing on
  • set a default station, and a station for those who lose their way
  • mark a changing stretch inside a path, a warrior's number for instance
  • send a screen off into a separate bundle, fetched only on demand

The map of the road - the routes array

A route is a pair: at which address, and what to show when you get there. Angular keeps those pairs in an ordinary array, and every element of it is an ordinary object. We give the array the type

Routes
- a name published by the
@angular/router
package, and a promise to the compiler that nothing but properly built routes will be found inside. Two fields matter from the very first minute. The
path
field is a fragment of the address, written without a leading slash, because the router puts the slashes in itself. The
component
field is the class of the component to be shown when the address matches. Let us settle on a file named
app.routes.ts
and begin with the two simplest stations.

1// app.routes.ts
2import { Routes } from '@angular/router';
3import { HomeComponent } from './home.component';
4import { SamuraiComponent } from './samurai.component';
5
6export const routes: Routes = [
7  { path: 'home', component: HomeComponent },
8  { path: 'samurai', component: SamuraiComponent }
9];

The file is finished and for the moment does absolutely nothing - that is the most important sentence in this paragraph. What came into being is a plain array of two objects, which you could print to the console and inspect like any other piece of data. No component was changed, no screen was repainted, and the browser still knows nothing about any of it. Notice three details. The name

Routes
has to come from
@angular/router
, because that is the only package exporting it. There is no slash in front of
home
, and there should not be one. Matching runs over whole segments of the address rather than over regular expressions, so the address
/homepage
will never reach the
home
route - those are two different segments, for all their shared beginning.

Handing over the map - provideRouter

A map lying in a drawer has never led anyone anywhere. It has to be handed to Angular at the moment the application starts, and you already know that spot from the first module: the file

main.ts
calls
bootstrapApplication
and passes it the configuration object from
app.config.ts
. That object has the type
ApplicationConfig
and one field that concerns us here - the
providers
array, the register of things the application is to have at its disposal for as long as it runs. You add the router to that register with the function
provideRouter
, imported from
@angular/router
. You give it your routes array, and it hands back a ready set of entries for
providers
.

Commit that name to memory exactly, because three others sound entirely believable and not one of them works. The functions

registerRoutes
and
useRouter
simply do not exist - the
@angular/router
package exports nothing of the kind, and the compiler rejects such an import with a message about the missing export. I checked this with the compiler, so do not go hoping for a typo in the documentation. The third name is the sneakier one:
RouterModule.forRoot(routes)
genuinely exists and you saw it back in the module about clans, except that it belongs to the old world of
NgModule
. Its home is the
imports
array of the
@NgModule
decorator, not the
providers
of an
ApplicationConfig
- the types do not line up and the compiler catches it. In a standalone application, the kind you have been building since the first module, registering the router has exactly one shape.

1// app.config.ts
2import { ApplicationConfig } from '@angular/core';
3import { provideRouter } from '@angular/router';
4import { routes } from './app.routes';
5
6export const appConfig: ApplicationConfig = {
7  providers: [
8    provideRouter(routes)
9  ]
10};

From this moment the router is alive: it listens to the address bar, reads it after every change and hunts through your array for a matching route. What has not changed is what shows on screen - still nothing. The router already knows which component ought to appear, but nobody has told it where to draw it. Notice too that

app.routes.ts
was left untouched: the routes array knows nothing of the configuration, and the configuration merely imports it. That division of duties is deliberate and very convenient, because the map can be swapped out without laying a finger on how the application starts.

The castle gate - router-outlet

The missing link is the gate: a place in the template through which the current route's screen walks in. Angular hands you a directive named

RouterOutlet
for exactly this. A directive is neither a component nor a service, but an addition you write into a template - here the tag
<router-outlet></router-outlet>
. The router finds that tag and puts the matched route's component right beside it. That is the whole of its job: it is the place where Angular renders components for the current path, and nothing besides.

Three similar-sounding ideas are worth cutting off straight away, because each one reads as sensible and each one is untrue. First,

<router-outlet>
is not there to display routing errors - a "not found" screen is an ordinary component of yours wired to a route, which we come to shortly. Second, it is not a directive for redirects - a redirect is a field on an entry in the routes array, not a tag in a template. Third, it is not a service for navigation - the service named
Router
, the one you give the order to move from inside your code, is waiting for you at the next station. The gate is visible in the template, so
RouterOutlet
has to be added to the
imports
array of a standalone component, exactly like every other thing used in a template.

1// app.component.ts
2import { Component } from '@angular/core';
3import { RouterOutlet } from '@angular/router';
4
5@Component({
6  selector: 'app-root',
7  standalone: true,
8  imports: [RouterOutlet],
9  template: `
10    <main>
11      <router-outlet></router-outlet>
12    </main>
13  `
14})
15export class AppComponent { }

Now visiting the address

/home
really does show
HomeComponent
, and visiting
/samurai
swaps it for
SamuraiComponent
. The most interesting part, though, is what does not happen along the way.
AppComponent
itself is not created afresh - it lives through the whole business, so the
<main>
tag, and with it any header or footer you might write around the gate, stays on screen untouched. The browser does not reload the page, there is no white flash, no fetching the application all over again. The only thing exchanged is that one fragment beside the gate.

Signposts along the road - routerLink

Now that the addresses work, it would be good to give the user something to click. Instinct suggests an ordinary link with an

href
attribute, and it will even work - catastrophically. The browser treats
href
as a demand for an entirely new document: it abandons your whole application, downloads it again, starts it up once more and wipes out everything you were keeping in memory. Instead of a smooth transition you get a full reload. That is why links inside an application use the
routerLink
directive, which intercepts the click, holds the browser back and hands the matter over to the router. It too has to find its way into the
imports
array. There is a second road - the
navigate
method of the
Router
service, called from your class code - but that is a method, not a template directive, and we take it up at the next station.

1// app.component.ts
2import { Component } from '@angular/core';
3import { RouterLink, RouterOutlet } from '@angular/router';
4
5@Component({
6  selector: 'app-root',
7  standalone: true,
8  imports: [RouterOutlet, RouterLink],
9  template: `
10    <nav>
11      <a routerLink="/home">Home</a>
12      <a routerLink="/samurai">Samurai</a>
13    </nav>
14
15    <main>
16      <router-outlet></router-outlet>
17    </main>
18  `
19})
20export class AppComponent { }

Clicking "Home" now changes the address in the bar to

/home
and swaps out the contents of the gate, and the back arrow carries you to the route before it. Notice how such a link is put together, because you assemble it every time from the same five pieces in the same order: the opening of the tag
<a
, then the attribute
routerLink="/home"
, then the
>
that closes the opening tag, then the visible label, and at the end
</a>
. Nothing changed in
app.routes.ts
or in the configuration while you did this - the map of the road is the same one as before, and all you added were the boards pointing at it.

The lit signpost - routerLinkActive

Navigation works, but it has a flaw visible to the naked eye: all the boards look identical and the user has no idea which station he is standing at. Angular settles this with the

routerLinkActive
directive. You give it the name of a CSS class, and it keeps adding that class to the link for as long as the route from
routerLink
is active, then takes it away when the route stops being active. There is one catch: by default the comparison runs over the beginning of the address, so a link to
/samurai
stays lit even while you stand on
/samurai/7
. Sometimes that is precisely what you want, sometimes it is not. You steer it with the
routerLinkActiveOptions
option and its
exact
field: the value
true
demands that the whole address agree. We write it inside square brackets, that is, as the property binding you met when the warriors were talking to one another - square brackets tell Angular that what sits inside the quotes is an expression to be evaluated, here an object, and not a plain piece of text.

Look closely at the order of the pieces in a highlighted link, because it is the one you will be reproducing from memory. First comes the opening of the tag

<a
, then the attribute
routerLink="/home"
, then the attribute
routerLinkActive="active">
- and note that the
>
closing the opening tag rides along at the end of that last attribute, since nothing else follows it. Only then comes the visible label, and
</a>
closes the whole thing. Written out on one line the link reads
<a routerLink="/home" routerLinkActive="active">Home</a>
, and the version below merely breaks it across several lines for legibility, which changes nothing for the browser.

1<nav>
2  <a routerLink="/home"
3     routerLinkActive="active"
4     [routerLinkActiveOptions]="{ exact: true }">
5    Home
6  </a>
7  <a routerLink="/samurai" routerLinkActive="active">
8    Samurai
9  </a>
10</nav>

After this change the link to the warrior roster picks up the

active
class on the
/samurai/7
route as well, while the home link lights up on
/home
alone. Watch out for two things that did not happen. Angular gave that class no appearance whatsoever - the word
active
is a name you invented yourself and have to describe in a stylesheet, otherwise you will see nothing at all. Nor did anything change in the routes array: the highlighting is purely a matter for the template. Remember as well to add
RouterLinkActive
to the component's
imports
array, alongside
RouterLink
and
RouterOutlet
.

The default station - the empty path

That leaves the question every user starts with: what does somebody see who typed in nothing but the domain? The address is empty then, and there is no empty path anywhere on your map. We write it as

path: ''
and have two ways out. You can pin a component to it, exactly as with any other route, that is, write
{ path: '', component: HomeComponent }
and have a start screen sitting under the bare domain. You can also send the user elsewhere with the
redirectTo
field, in which you name the destination address. A redirect from an empty path brings a third field into play,
pathMatch
, and that is where the whole subtlety hides. The default value is
'prefix'
, meaning "it is enough that the address begins this way" - and an empty piece of text begins every address in the world, so such a route would intercept literally everything. Angular knows this trap and will not let you set it: an empty path with
redirectTo
and no
pathMatch
is thrown out as an invalid configuration before the first navigation even happens, and the error message asks outright for that field to be supplied. The value
'full'
means "the whole remaining address must be empty" and it is the only one that makes sense here. You assemble the route from four pieces in order: the opening
{ path: '',
, then
redirectTo: '/home',
, then
pathMatch: 'full'
, and finally the closing
}
.

1// app.routes.ts, imports as above
2export const routes: Routes = [
3  { path: '', redirectTo: '/home', pathMatch: 'full' },
4  { path: 'home', component: HomeComponent },
5  { path: 'samurai', component: SamuraiComponent }
6];

Visiting the bare domain now leads to

/home
, and it is
/home
that stands in the bar afterwards. The whole thing counts as a single navigation, so no separate history entry is left behind for the empty address - I checked this, and after such a move exactly one entry is added, the destination one. The back arrow therefore has no chance of looping between the empty address and the target of the redirect. The
home
route did not change in the process: it is still an ordinary station with a component and knows nothing whatever about the redirect. Remember one more exception that will spare you needless typing: when an empty path leads straight to a component rather than to a redirect, the
pathMatch
field is not required - the router will not count the route as matched for as long as an unconsumed segment remains in the address.

Travellers who lose the road - the wildcard route

Sooner or later somebody types in an address that is not on the map: a typo in a link, an old bookmark, a curious user. Unprepared for it, you end up with a blank screen and an exception in the console - the router says outright that it cannot match any route, and breaks off the navigation. That is why the last station on the map is the one for the lost, called a wildcard in English. Its path is

'**'
, that is, two asterisks, and it matches any number of remaining segments of the address. Three other spellings will not do the job, and it is worth knowing why. The paths
'404'
and
'notfound'
are perfectly ordinary routes that match only the addresses
/404
and
/notfound
- and those are hardly the addresses we come to grief on. A single asterisk
'*'
carries no magic meaning in Angular: the router treats it as a plain segment named
*
and matches only the literal address
/*
. The order is not free either, because the router reads the array from top to bottom and takes the first hit - a wildcard placed higher up would intercept everything beneath it.

1// app.routes.ts, imports as above plus NotFoundComponent
2export const routes: Routes = [
3  { path: '', redirectTo: '/home', pathMatch: 'full' },
4  { path: 'home', component: HomeComponent },
5  { path: 'samurai', component: SamuraiComponent },
6  { path: '**', component: NotFoundComponent }
7];

The address

/kioto
now shows your "not found" screen instead of an exception, and every earlier route works exactly as it did before, because the wildcard only gets its turn once nothing above it has matched. Notice as well what the wildcard does not do: it does not change the server's response code and it is not a real 404 in the sense of the protocol - it is an ordinary screen belonging to your application. If you would rather not build a separate component, the wildcard can simply send the user back to the start with
{ path: '**', redirectTo: '' }
, and then whoever lost the road ends up wherever the default route leads.

The changing stretch - a path parameter

Your clan numbers three hundred warriors and every one of them has his own profile screen. Nobody is going to write three hundred entries into an array, and had somebody done it, every new recruit would send them straight back to the file. The addresses

/samurai/7
and
/samurai/128
differ, after all, in nothing but the last segment. Angular lets you mark that segment as changing: it is enough to put a colon in front of its name. The spelling
samurai/:id
reads as "the segment
samurai
, and after it anything at all, and let us call that anything
id
". Beware of spellings from other ecosystems, because your fingers write them of their own accord. Braces
{id}
you know from servers of the Express sort, square brackets
[id]
from file-based routing in Next.js, and
$id
from text templates. In Angular not one of them is a parameter - the router takes such a spelling literally and goes looking for a segment named
{id}
or
[id]
.

1// app.routes.ts, imports as above plus SamuraiDetailComponent
2export const routes: Routes = [
3  { path: 'samurai', component: SamuraiComponent },
4  { path: 'samurai/:id', component: SamuraiDetailComponent },
5  { path: '**', component: NotFoundComponent }
6];

One route now serves all the warriors at once:

/samurai/7
and
/samurai/128
land on the same entry and show the same component. The
samurai
route without a number came to no harm - the roster still works, because an address missing that last segment matches it and nothing else. There is, however, one thing this code does not do, and it is the most important sentence in the section: the router does not put the number into the component.
SamuraiDetailComponent
has no idea as yet whether it was opened for the seventh warrior or the hundred and twenty-eighth. Reading that value out is the subject of the next lesson.

Troops on demand - loadComponent

Every component named on the map so far shares one trait: it is imported at the top of the file, so it lands in the first bundle the browser fetches at startup. With three screens that makes no difference; with thirty, the user waits for code he may never lay eyes on. In place of the

component
field you can supply a
loadComponent
field - a function that reaches for the file only once the route is actually entered, and returns the class out of it. The reaching is done by the
import()
expression with parentheses, which loads a file while the application is already running and hands back a promise, that is, a
Promise
. Out of that promise we pull the class with the
then
method, passing it a short function
c => c.ForgeComponent
. Two neighbouring names are traps. The field
lazyComponent
does not exist and the compiler throws such an entry out as an unknown field. The plain
component
field, meanwhile, does work, only in precisely the opposite manner - it pulls the code in at once. A third neighbour,
loadChildren
, is real and lazy as well, but it expects a whole array of routes rather than a single component class - and that one the compiler waves straight through, only for the navigation to break the moment somebody actually enters the route. We take it up in the next section.

1// app.routes.ts, a fragment of the routes array
2  {
3    path: 'forge',
4    loadComponent: () => import('./forge/forge.component')
5      .then(c => c.ForgeComponent)
6  },

From the user's point of view nothing changed: the address

/forge
leads to the same screen, the
routerLink
board works just as it did, the wildcard still catches those who lose their way. The only thing that changed is the moment the code is fetched - the forge bundle comes down off the server on the first entry to the route and not before, and the application's startup is that much lighter for it. Notice too that there is no import of
ForgeComponent
at the top of the file. Had you added one, the class would march straight back into the main bundle and the entire gain would evaporate.

A whole stretch on demand - loadChildren

The armoury is not one screen but a whole section: the register of katanas, the card of a single blade, the order form at the smith's. Sending them off one at a time would be laborious, and while you were at it you would rather keep their routes in a separate file, closer to the section itself. This is what the

loadChildren
field is for, the twin of
loadComponent
, differing in one respect: instead of a component class you pull an array of routes out of the file. So we begin with just such a file. Inside it the paths are counted relative to the parent, which is why an empty path here means arriving at the armoury itself.

1// weapons/weapons.routes.ts
2import { Routes } from '@angular/router';
3import { WeaponsListComponent } from './weapons-list.component';
4import { WeaponDetailComponent } from './weapon-detail.component';
5
6export const WEAPONS_ROUTES: Routes = [
7  { path: '', component: WeaponsListComponent },
8  { path: ':id', component: WeaponDetailComponent }
9];

A second, smaller map has come into being - entirely self-contained and, for now, joined to nothing. There is no

provideRouter
inside it, because that one is called once for the whole application, and there is no repeated
weapons
segment, because the parent's routes will glue it on. All that remains is to point at it from the main map. The address
/weapons
will then land on the empty path from the file you have just written, that is, on the register of blades, while
/weapons/12
lands on the card of a single katana.

1// app.routes.ts, a fragment of the routes array
2  {
3    path: 'weapons',
4    loadChildren: () => import('./weapons/weapons.routes')
5      .then(r => r.WEAPONS_ROUTES)
6  },

The gluing together of the segments happens on its own: the router strips

weapons
off the address and hands the remainder to the smaller map. Nothing changed inside the armoury's own file - that same array would work under a different segment if you altered a single word here, and that is the whole advantage of splitting things this way. So when should you reach for which? Here is a simple rule I recommend to you, @name: send a single screen with no sub-pages of its own through
loadComponent
, because the spelling is shorter and does not multiply files; send a whole section with its own division into screens through
loadChildren
, because then its routes live in the same place as its code.

The whole map together

Time to set it all down in one file, because only then does the most important thing become visible - the order. The router reads the array from top to bottom and stops at the first route that matches, so the specific entries have to stand ahead of the general ones, and the wildcard always at the very end. The redirect from the empty path goes at the beginning, so that it catches the eye as you read the file.

1// app.routes.ts
2import { Routes } from '@angular/router';
3import { HomeComponent } from './home.component';
4import { SamuraiComponent } from './samurai.component';
5import { SamuraiDetailComponent } from './samurai-detail.component';
6import { NotFoundComponent } from './not-found.component';
7
8export const routes: Routes = [
9  { path: '', redirectTo: '/home', pathMatch: 'full' },
10  { path: 'home', component: HomeComponent },
11  { path: 'samurai', component: SamuraiComponent },
12  { path: 'samurai/:id', component: SamuraiDetailComponent },
13  {
14    path: 'forge',
15    loadComponent: () => import('./forge/forge.component')
16      .then(c => c.ForgeComponent)
17  },
18  {
19    path: 'weapons',
20    loadChildren: () => import('./weapons/weapons.routes')
21      .then(r => r.WEAPONS_ROUTES)
22  },
23  { path: '**', component: NotFoundComponent }
24];

Read this map the way you would read a timetable of stations along the road: the empty address sends the traveller home, four segments have screens assigned to them, two more are fetched only on demand, and the last entry catches everyone who has gone astray. Notice what is not here. There is not a single

import
for the forge or the armoury, because those roll in lazily. Neither is there any mention of appearance, of the gate or of the boards - the map says one thing only, "this address, that screen", and the rest belongs to the template and to the configuration file.

Anatomy of an address

Finally, take a look at the address itself, because it carries more than one kind of information and in the lessons to come you will be reading things out of it. The segments separated by slashes are obligatory and take part in matching the route - they are what decides which screen opens. After the question mark come the optional additions, which take no part in matching and are good for sorting or filters. After the hash sits the fragment, a pointer to a spot inside a screen that is already open.

| Part of the address | Example | Role | |---|---|---| | Path segment | /samurai/17 | Obligatory, picks the route | | Addition after the question mark | /samurai?rank=hatamoto | Optional, does not change the route | | Fragment after the hash | /samurai#bushido | Points at a spot in the open screen |

Today you drew out the road and set up the gate - reading what the address itself carries is work for the next station. Remember, @name: the routes map says only "this address, that screen", and everything beyond that begins the moment you are standing at the right station.

Go to CodeWorlds