Angular 10 tends to keep a high popularity, even if since Angular 1, ReactJS stacks grabbed half of the popularity by its lighter stack. However, a feature is still not obvious: how to create routes dynamically at runtime.

You can think you just do a fetch() then bootstrap the application, something around:

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';

fetch('/my-routes') // 1
  .then(route => {
    window.globalRoutes = routes; // 2
    platformBrowserDynamic() // 3
      .bootstrapModule(AppModule)
      .catch(err => console.error(err));
  };
  1. We do a HTTP request to gather the routes to create from a backend endpoint
  2. We store the route in a place our routing component/module can read from (here in the window for example)
  3. We launch angular application

This works but it has some pitfalls:

  1. This delays the application bootstrap so it means you can't use angular until your routes are available, including angular services - which can help if you use HttpClient and a "mapper" service (backend model to angular route model),
  2. If you secure your backend, you can't use that mecanism and once logged you need to duplicate the fetch logic to reload the route the user has permissions to use/see (or to keep a split logic, one fully angular friendly and one in pure javascript),
  3. The application bootstrap is fully delayed (I'll come back on that later).

In terms of route loading, I abstracted the actual logic by fetch() function, but what we actually want is to write something like that:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

@Injectable({
    providedIn: 'root',
})
export class RouteLoader {
    constructor(private client: HttpClient) { }

    public load() {
        return this.client.get('/my-routes')
            .pipe(map(json => this.createRoutes(json)));
    }

    private createRoutes(json) {
        return ...;
    }
}

This flavor is angular native and enables to use angular IoC. When you start to have some conversion and use multiple services it is helping a lot and avoids to just create a spaghetti piece of code or reinvent a custom (light) IoC.

But at that stage we have one main question: how to update the route with the new routes we created? Luckily for us, Angular Router provides a resetConfig() method enabling us to do that:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { from } from 'rxjs';
import { switchMap, map } from 'rxjs/operators';

@Injectable({
    providedIn: 'root',
})
export class RouteLoader {
    constructor(
        private client: HttpClient,
        private router: Router) /* 1 */ { }

    public load() {
        return this.client.get('/my-routes')
            .pipe(switchMap(json => this.createRoutes(json)));
    }

    private createRoutes(json) {
        return from(json.routeSpecs).pipe( // 2
            map(spec => this.toRoutes(spec)), // 3
            map(routes => ([ // 4
                ...this.router.config,
                ...routes,
            ])),
            map(newRoutes => this.router.resetConfig(newRoutes)) // 5
        );
    }

}
  1. We inject the route in the service constructor,
  2. Once we retrieved our backend model we create an observable from it (makes the DSL easier and integrates better with load() function as we'll see soon but can be done with a plain loop if you prefer),
  3. We convert the backend route specification to actual routes (we'll explain that just after that),
  4. We flatten the existing/bootstrap routes with the new routes,
  5. Finally we update the router with the new routes. Note that this is also a place you can store the route you loaded and store them in the RouteLoader service. This is very very useful if you build dynamically a menu from what you fetched and it enables to inject the service in any component and dynamically create the menu (with a *ngFor for example) without having to use a global variable (as the original fetch option would have required).

The solution is almost working but we need to create the routes. This part is very dependent on your application and backend model your get but the key there is to have reusable components you can just reference from the backend and lookup in the createRoutes() method.

For instance, if you send a list of entities/tables from the backend and want to generate CRUD routes, we need to write CRUD components (one per potential routes) and we can implement the createRoutes() like that:

private toCrudRoutes(entities) {
    return Object.keys(entities)
        .map(it => ([
            {
              path: `${entities[it].name}s`,
              component: ListComponent,
            },
            {
              path: `${entities[it].name}`,
              component: CreateComponent,
            },
            {
              path: `${entities[it].name}/:id`,
              component: EditComponent,
            },
        ]).reduce((agg, item) => ([...agg, ...item]), []))
}

Here we see we just map the list of entities to 3 routes (list all, create, edit/view - and we assume list and edit views have a delete button), then each entity routes are flattened with others thanks the reduce() call. The key here is to have generic components (ListComponent, CreateComponent, EditComponent). In next blog post, I'll explain how to achieve it.

So now our application is functional, right? Actually not completely. If you plain a bit with the application, you will see it works...but not the refresh. Assuming with have an user entity, if you go on /user/1 and hit F5 (refresh the page), you will go back on the home page (if you set a default one in the bootstrap/initial routes). This is because the router will be active before the backend routes are fetched and update the router when doing a page refresh.

To solve that last issue, we need to ask Angular to wait for our router update. This is where having an Observable will help us a lot in the code we write previously. To request angular to wait for our processing, we will create a function returning a Promise representing our Observable returned by load(). Note, that it is important to convert the Observable to a Promise. When a Promise, angular will wait, but not when an Observable. All the trick is to return this Promise as an application initializer. To do that we register it in our application module:

import { APP_INITIALIZER, NgModule } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { AppComponent } from './app.component';
import { RouteLoader } from './service/route-loader.service';
import { ensureRoutesExist } from './ensureRoutesExist.initializer';

@NgModule({
  // ...
  providers: [ // 1
    {
      provide: APP_INITIALIZER,
      useFactory: ensureRoutesExist, // 2
      multi: true,
      deps: [HttpClient, RouteLoader], // 3
    }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }
  1. We register a custom provider with the token APP_INITIALIZER (ensure to set multi to true since you can get multiple initializers per application),
  2. We bind its factory to a custom method,
  3. We bind the factory parameters - which is a plain function in this case and requires this explicit injection binding.

The ensureRoutesExist initializer looks like:

import { HttpClient } from '@angular/common/http';
import { RouteLoader } from './service/route-loader.service';

export function ensureRoutesExist( // 1
    http: HttpClient,
    routeLoader: RouteLoader) {
  return () => routeLoader.load() // 2
    .toPromise(); // 3
}
  1. The factory is a plain function which returns a function returning a promise, it takes as parameters the services/providers we bound in the application module during the provider registration,
  2. We want to wait for the route loading so we call the load function to execute it during application initialization,
  3. We convert our observable to a promise to ensure angular waits for this logic before finishing to start.

This small trick will ensure the router is not active before routes were updated and therefore make the page refresh working as expected, even on our dynamic routes - at the cost of a small delay in the initialization so ensure to have a correct page loader/spinner/skeleton/whatever you like ;).

From the same author:

In the same category: