Supplying the Routing Setup

When bootstrapping a standalone component, you can supply services at the root level—the same services you'd previously register in an AppModule. The Router now offers a provideRouter function that returns all the necessary providers for this registration:

// main.ts

import { importProvidersFrom } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { 
    PreloadAllModules, 
    provideRouter, 
    withDebugTracing, 
    withPreloading, 
    withRouterConfig 
} 
from '@angular/router';

import { APP_ROUTES } from './app/app.routes';
[...]

bootstrapApplication(AppComponent, {
  providers: [
    importProvidersFrom(HttpClientModule),
    provideRouter(APP_ROUTES, 
      withPreloading(PreloadAllModules),
      withDebugTracing(),
    ),

    [...]

    importProvidersFrom(TicketsModule),
    provideAnimations(),
    importProvidersFrom(LayoutModule),
  ]
});

The provideRouter function accepts both the root routes and the implementations of additional router features. These features are passed using functions following the withXYZ naming pattern, such as withPreloading or withDebugTracing. Since functions can be easily tree-shaken, this design decision makes the router as a whole more tree-shakable.

With these functions, the Angular team also establishes a naming convention that library authors should adopt. When adding a new library, you only need to look for a provideXYZ function along with any optional withXYZ functions.

Since not every library currently provides a provideXYZ function, Angular includes the bridging utility importProvidersFrom. This function lets you retrieve all providers defined in existing NgModules, making it the key to using them with Standalone Components.

I anticipate that the use of importProvidersFrom will diminish over time as more libraries introduce functions for directly configuring their providers. For instance, NGRX recently added provideStore and provideEffects functions.

Employing Router Directives

After configuring the routes, you also need to define a placeholder where the Router renders the activated component, along with links to switch between views. To obtain the required directives, you could import RouterModule directly into your Standalone Component. However, a cleaner approach is to import only the specific directives you need:

@Component({
  standalone: true,
  selector: 'app-root',
  imports: [
    // Just import the RouterModule:
    // RouterModule,

    // Better: Just import what you need:
    RouterOutlet,
    RouterLinkWithHref,

    NavbarComponent,
    SidebarComponent,
  ],
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
    [...]
}

Importing just the necessary directives is possible because the router exposes them as Standalone Directives. Note that RouterLinkWithHref is required when using routerLink on an a-tag; in all other cases, you should import RouterLink instead. While this distinction might seem confusing, it won't be a concern in the near future once IDEs offer auto-imports for Standalone Components.

Load-on-Demand with Standalone Components

Previously, a lazy route pointed to an NgModule containing child routes. Since NgModules are no longer necessary, loadChildren can now directly target a lazy routing configuration:

// app.routes.ts

import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';

export const APP_ROUTES: Routes = [
    {
        path: '',
        pathMatch: 'full',
        redirectTo: 'home'
    },
    {
        path: 'home',
        component: HomeComponent
    },

    // Option 1: Lazy Loading another Routing Config
    {
        path: 'flight-booking',
        loadChildren: () =>
            import('./booking/flight-booking.routes')
                .then(m => m.FLIGHT_BOOKING_ROUTES)
    },

    // Option 2: Directly Lazy Loading a Standalone Component
    {
        path: 'next-flight',
        loadComponent: () => 
            import('./next-flight/next-flight.component')
                .then(m => m.NextFlightComponent)
    },
    [...]
];

This eliminates the extra layer of an NgModule, making the code more explicit. Alternatively, a lazy route can directly reference a Standalone Component using the loadComponent property, as shown above.

I suspect most teams will prefer the first option, since applications typically need to lazy load several related routes together.

Environment Injectors: Scoped Services for Routes

With NgModules, each lazy module introduced a new injector and thus a new injection scope, which was used to provide services needed only by that particular lazy chunk.

To address this need, the Router now allows you to add providers per route. These services are accessible to the route in question and its child routes:

// booking/flight-booking.routes.ts

export const FLIGHT_BOOKING_ROUTES: Routes = [{
    path: '',
    component: FlightBookingComponent,
    providers: [
        provideBookingDomain(config)
    ],
    children: [
        {
            path: '',
            pathMatch: 'full',
            redirectTo: 'flight-search'
        },
        {
            path: 'flight-search',
            component: FlightSearchComponent
        },
        {
            path: 'passenger-search',
            component: PassengerSearchComponent
        },
        {
            path: 'flight-edit/:id',
            component: FlightEditComponent
        }
    ]
}];

As illustrated, you can provide services for multiple routes by grouping them as child routes. In this case, a component-less parent route with an empty path (path: '') is used—a pattern long employed to assign guards to a set of routes.

Technically, adding a providers array to a route configuration creates a new injector at that route's level. This injector is referred to as an Environment Injector and replaces the former (Ng)Module Injectors. The root injector and the platform injector are also Environment Injectors.

Interestingly, this also decouples lazy loading from creating additional injection scopes. In the past, each lazy NgModule introduced a new scope, while non-lazy NgModules never did. Now, lazy loading itself has no impact on scopes. Instead, you define new scopes by adding a providers array to your routes, regardless of whether the route is lazy.

The Angular team advises using this providers array sparingly and preferring providedIn: 'root' where possible. As noted in an earlier article in this series, providedIn: 'root' also supports lazy loading. If you only use a service with providedIn: 'root' in lazy parts of your application, it will only load alongside them.

However, there is a scenario where providedIn: 'root' falls short and the providers array becomes necessary: when you need to pass configuration to a library. The example above hinted at this by passing a config object to the custom provideBookingDomain. The next section offers a more detailed example using NGRX.

Configuring NGRX and Feature Slices

To demonstrate how to use libraries adapted for Standalone Components with lazy loading, let's examine an NGRX setup. First, we provide the necessary global services:

import { bootstrapApplication } from '@angular/platform-browser';

import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';

import { reducer } from './app/+state';

[...]

bootstrapApplication(AppComponent, {
  providers: [
    importProvidersFrom(HttpClientModule),
    provideRouter(APP_ROUTES, 
      withPreloading(PreloadAllModules),
      withDebugTracing(),
    ),

    // Setup NGRX:
    provideStore(reducer),
    provideEffects([]),
    provideStoreDevtools(),

    importProvidersFrom(TicketsModule),
    provideAnimations(),
    importProvidersFrom(LayoutModule),
  ]
});

For this, we use the provideStore, provideEffects, and provideStoreDevtools functions that NGRX has offered since version 14.3.

To allow lazy parts of the application to have their own feature slices, we call provideState and provideEffects within the respective routing configuration:

import { provideEffects } from "@ngrx/effects";
import { provideState } from "@ngrx/store";

export const FLIGHT_BOOKING_ROUTES: Routes = [{
    path: '',
    component: FlightBookingComponent,
    providers: [
        provideState(bookingFeature),
        provideEffects([BookingEffects])
    ],
    children: [
        {
            path: 'flight-search',
            component: FlightSearchComponent
        },
        {
            path: 'passenger-search',
            component: PassengerSearchComponent
        },
        {
            path: 'flight-edit/:id',
            component: FlightEditComponent
        }
    ]
}];

While provideStore initializes the store at the root level, provideState adds additional feature slices. You can provide a full feature or just a branch name with a reducer. Notably, provideEffects is used both at the root level and within lazy sections, supplying the initial effects as well as those needed for a specific feature slice.

Preparing Your Environment: ENVIRONMENT_INITIALIZER

Some libraries previously relied on the constructor of lazy NgModules for initialization. To support this approach without NgModules, the concept of an ENVIRONMENT_INITIALIZER has been introduced:

export const FLIGHT_BOOKING_ROUTES: Routes = [{
    path: '',
    component: FlightBookingComponent,
    providers: [
        importProvidersFrom(StoreModule.forFeature(bookingFeature)),
        importProvidersFrom(EffectsModule.forFeature([BookingEffects])),
        {
            provide: ENVIRONMENT_INITIALIZER,
            multi: true,
            useValue: () => inject(InitService).init()
        }
    ],
    children: [
        [...]
    ]
}

Essentially, the ENVIRONMENT_INITIALIZER supplies a function that runs when the Environment Injector is initialized. The multi: true flag indicates that multiple such initializers can be registered per scope.

Looking Ahead: Architecture Considerations

So far, we've explored how to break down a large client into multiple libraries using Standalone Components. However, for enterprise-scale frontends, additional questions arise:

  • What criteria should be used to divide a large application into sub-domains?
  • How can we ensure the solution remains maintainable for years or even decades?
  • What options does Module Federation offer for Micro Frontends?

Our free eBook (roughly 120 pages) addresses all these topics and more:

free ebook

Feel free to download it here now!