Mert Özen Further With Every Line
Frontend Angular — #22

Standalone APIs in Angular 15: bootstrapApplication, provideRouter and Apps Without NgModule

Mert Özen Oct 31, 2026 7 min 2 views
Standalone APIs in Angular 15: bootstrapApplication, provideRouter and Apps Without NgModule

Angular 15 made the standalone APIs stable. We build an app without AppModule using bootstrapApplication, provideRouter, provideHttpClient and importProvidersFrom, then migrate step by step.

Version note: This post covers what Angular 15 introduced; the code targets Angular 15.

Standalone Is No Longer an Experiment

We first glanced at standalone components in Angular 14, back when they were in developer preview. With Angular 15, released on November 16, 2022, the standalone APIs became stable. So we are no longer saying "you can try this"; we are saying "you can use this in production with confidence".

Angular 15 brought so much that we split it into two posts. This one covers the backbone: standalone components, directives and pipes, bootstrapApplication for starting an app without an AppModule, and provideRouter and provideHttpClient for setting up the router and HttpClient without modules. At the end we will talk about the reasoning behind moving an existing NgModule project to standalone.

Moving from 14 to 15

git commit -am "chore: state before Angular 15 update"
ng update @angular/core@15 @angular/cli@15

Angular 15's official Node range is 14.20 or later, 16.13 or later, or 18.10 or later. If you are staying on Node 16, make sure you have at least 16.13; the latest Node 16 release we installed in the series already meets that. On the TypeScript side you need at least 4.8.

This time the migrations deal mostly with configuration files. In tsconfig.json they move the compilation target to ES2022 and set useDefineForClassFields to false. They delete a .browserslistrc file whose content matches the CLI default. In the router, they remove the no longer valid relativeLinkResolution option and turn usages of RouterLinkWithHref into RouterLink.

There is also a noticeable cleanup on the CLI side. Starting with Angular 15, ng new no longer generates the environments/ folder or the test.ts, polyfills.ts, karma.conf.js and .browserslistrc files. This only applies to new projects; the files in your project stay where they are. One detail worth stating up front: even though the standalone APIs are stable, ng new in Angular 15 still creates a project with an AppModule.

The Starting Point: AppModule in Angular 12

Let's recall the classic structure we saw earlier. A small app made of a product list, a directive and a pipe:

@NgModule({
  declarations: [AppComponent, ProductListComponent, HighlightDirective, PriceTagPipe],
  imports: [BrowserModule, HttpClientModule, AppRoutingModule],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));

Here AppModule works like a building's management office. It decides which component may use which directive and which services the app can reach. And main.ts does not start the app from a component directly but from this module. In the standalone world we will hand these responsibilities out to other places, one by one.

bootstrapApplication: Starting the App from a Component

In Angular 15, the main.ts file for the same app looks like this:

import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
    provideHttpClient()
  ]
}).catch(err => console.error(err));

bootstrapApplication starts the app directly from the root component. The app-wide settings that used to live in AppModule's imports and providers arrays now go into the providers list of the second argument. You do not need to add BrowserModule; bootstrapApplication takes care of it.

The root component has to be standalone too:

import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, RouterLink],
  template: `
    <nav>
      <a routerLink="/products">Ürünler</a>
    </nav>
    <router-outlet></router-outlet>
  `
})
export class AppComponent { }

Because the template uses router-outlet and routerLink, we added both to imports. In Angular 12 we got them indirectly through the RouterModule exported by AppRoutingModule. Now every component writes its own shopping list; whatever it needs, it takes explicitly.

Standalone Directives and Pipes

Not just components: directives and pipes can be standalone too. Here is the standalone version of the attribute directive example we saw earlier:

import { Directive, ElementRef, HostListener, inject } from '@angular/core';

@Directive({
  selector: '[appHighlight]',
  standalone: true
})
export class HighlightDirective {
  private readonly el: ElementRef<HTMLElement> = inject(ElementRef);

  @HostListener('mouseenter') onEnter(): void {
    this.el.nativeElement.style.backgroundColor = '#fff3b0';
  }

  @HostListener('mouseleave') onLeave(): void {
    this.el.nativeElement.style.backgroundColor = '';
  }
}

The same single line is enough for a pipe:

@Pipe({ name: 'priceTag', standalone: true })
export class PriceTagPipe implements PipeTransform {
  transform(value: number): string {
    return `${value.toLocaleString('tr-TR')} TL`;
  }
}

The component that uses them adds both to its own imports array:

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [NgFor, HighlightDirective, PriceTagPipe],
  template: `
    <div *ngFor="let p of products" appHighlight>
      {{ p.name }} - {{ p.price | priceTag }}
    </div>
  `
})
export class ProductListComponent {
  products = [
    { name: 'Mekanik Klavye', price: 1450 },
    { name: 'Kablosuz Mouse', price: 620 }
  ];
}

Notice that we took only NgFor instead of the whole CommonModule. Since Angular 14.1, pieces like NgIf, NgFor and AsyncPipe can be imported individually. Importing CommonModule is still fine; the choice is yours.

provideRouter and Lazy Loading

In Angular 12 we tied routes to a module with RouterModule.forRoot(routes). In a standalone app, routes sit in their own file as a plain array:

import { Routes } from '@angular/router';
import { ProductListComponent } from './product-list/product-list.component';

export const routes: Routes = [
  { path: '', redirectTo: 'products', pathMatch: 'full' },
  { path: 'products', component: ProductListComponent },
  {
    path: 'products/:id',
    loadComponent: () => import('./product-detail/product-detail.component')
      .then(m => m.ProductDetailComponent)
  },
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES)
  }
];

The difference on the lazy loading side matters. The loadChildren we saw earlier loaded an NgModule: import('./admin/admin.module').then(m => m.AdminModule). Now there are two new options. loadComponent can load a single standalone component, and loadChildren can load a plain route array that lives in another file. No module in between. Both features actually arrived in Angular 14; with Angular 15 they became ground you can safely build an app on.

Router settings are added to provideRouter through functions as well. The equivalent of Angular 12's RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }) is this:

provideRouter(routes, withPreloading(PreloadAllModules))

Other features such as withDebugTracing(), withInMemoryScrolling() and withRouterConfig() are added or removed the same way. Since a feature you do not use never makes it into the bundle, this setup also helps with bundle size.

provideHttpClient and importProvidersFrom

For HttpClient in Angular 12, we imported HttpClientModule into AppModule. In Angular 14, people building a standalone app used importProvidersFrom to move a module-based package into the providers list:

bootstrapApplication(AppComponent, {
  providers: [importProvidersFrom(HttpClientModule)]
});

Angular 15 introduced a dedicated function for this: the provideHttpClient() you saw in main.ts above. It is shorter, clearer and open to the new features we will see in the next post.

importProvidersFrom does not go in the bin, though. It acts as a bridge for a library that does not offer a provide... function yet, or for an NgModule of your own that takes settings through forRoot(). Think of it as the adapter that lets you plug an old plug into a new socket.

If you have class-based interceptors like the ones we saw earlier, you do not lose them either:

providers: [
  provideHttpClient(withInterceptorsFromDi()),
  { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
]

withInterceptorsFromDi() keeps interceptors registered with the HTTP_INTERCEPTORS token working as before. We will look at the new style of interceptors, written as functions, in the next post.

The Reasoning Behind Migrating an Existing Project

If you have an NgModule project with hundreds of components, you do not have to convert all of it to standalone in one weekend. The two worlds can live side by side; remember how we added a standalone component to an NgModule's imports array in the Angular 14 post.

That is why the healthiest path is to move from the leaves toward the root. Start with small pieces that depend on nothing else: pipes, directives, simple components like buttons or cards. Make each one standalone: true, add what it needs to its own imports, and move it from declarations to imports in the module. Then move on to feature modules; turn a lazily loaded module into a route array and load it with loadChildren. AppModule goes last; once everything is ready, you switch main.ts to bootstrapApplication.

You do not have to do this by hand either. Angular 15.2 shipped a migration schematic:

ng generate @angular/core:standalone

The command offers three modes, and you are meant to run them in order. The first converts all components, directives and pipes to standalone. The second deletes the NgModule classes that are no longer needed. The third changes main.ts so the app starts with bootstrapApplication. Running the build and tests and committing after each mode makes it much easier to find what broke if something goes wrong.

A Small Experiment

Create a new Angular 15 project or upgrade the one you have to 15. First write the PriceTagPipe above as standalone and add it to AppModule's imports instead of its declarations. Then generate a component with ng g c product-list --standalone, add the pipe to its imports and see it on screen.

If you feel bolder, go one step further: make AppComponent standalone, define your routes in an app.routes.ts file and rewrite main.ts with bootstrapApplication. If the app still starts after you delete AppModule, the move is done. Also try removing the provideHttpClient() line once and using HttpClient in a service; the "No provider" error you get will show you very clearly where providers live now.

The standalone backbone is in place. In the second half of Angular 15 we will look at the features built on top of it: guards and interceptors written as functions, hostDirectives for composing directives, and NgOptimizedImage for optimizing images.