Skip to content
Angular ng modules 4 min read

Standalone APIs

Standalone APIs let you build, bootstrap, and configure an entire Angular application without ever declaring an NgModule. Instead of a root module wiring everything together, a single bootstrapApplication() call mounts a standalone root component and reads its dependencies from a plain ApplicationConfig object. This is the default since Angular 17, and it makes the dependency graph explicit, tree-shakable, and far easier to reason about. This page covers the core trio — bootstrapApplication, ApplicationConfig, and the providers array — and how they replace the @NgModule machinery of older apps.

Bootstrapping with bootstrapApplication

In a module-based app, main.ts bootstrapped an AppModule, which in turn declared and exported AppComponent. Standalone apps skip the module entirely: bootstrapApplication() takes a standalone component and an optional config, then renders it as the application root.

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, appConfig)
  .catch((err) => console.error(err));

The component passed in must be standalone. In Angular 19+ that flag is the default, so a typical root component simply imports what its template uses:

// app/app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `
    @if (loading()) {
      <p>Loading…</p>
    }
    <router-outlet />
  `,
})
export class AppComponent {
  loading = signal(false);
}

Tip: bootstrapApplication() returns a Promise<ApplicationRef>. Always attach a .catch() so bootstrap failures surface in the console instead of failing silently.

Configuring with ApplicationConfig

The second argument is an ApplicationConfig — an object with a single providers array that defines the application-wide injector. This array is where you register routing, HTTP, hydration, and any global services, replacing the imports, providers, and module side-effects you used to scatter across AppModule.

// app/app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
};

Each provide* function returns providers (often EnvironmentProviders) that are fully tree-shakable: if you never call provideHttpClient(), the HTTP layer is dropped from the bundle. Keeping config in its own app.config.ts file also keeps main.ts minimal.

Provider functions that replace NgModules

The biggest shift is mental: every SomethingModule.forRoot() you imported now has a provide* equivalent you call inside providers. The table maps the most common ones.

NgModule approachStandalone APIPackage
RouterModule.forRoot(routes)provideRouter(routes)@angular/router
HttpClientModuleprovideHttpClient()@angular/common/http
BrowserAnimationsModuleprovideAnimationsAsync()@angular/platform-browser/animations/async
FormsModule / ReactiveFormsModuleimported per-component@angular/forms
StoreModule.forRoot()provideStore()@ngrx/store
service worker moduleprovideServiceWorker('ngsw-worker.js')@angular/service-worker

Many provide* functions accept feature functions (the with* helpers) to opt into extra behavior without enlarging the default bundle:

import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';

provideRouter(
  routes,
  withComponentInputBinding(),
  withViewTransitions(),
);

withComponentInputBinding() binds route params straight to component @Input()s; withViewTransitions() enables the browser View Transitions API on navigation. Each feature is its own tree-shakable function.

Functional guards and interceptors

Standalone apps favor plain functions over class-based DI tokens. Guards and interceptors are functions that call inject() directly — no constructor, no @Injectable boilerplate.

// core/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).token();
  const authed = token
    ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
    : req;
  return next(authed);
};
// core/auth.guard.ts
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';

export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  return auth.isLoggedIn() ? true : inject(Router).parseUrl('/login');
};

You register them through their host APIs — interceptors via withInterceptors([...]) and guards in the route definition’s canActivate array.

Route-level and lazy providers

providers is not limited to the root. Each route can declare its own providers, scoping services to a feature and lazily loading them. This replaces lazy-loaded feature NgModules.

// app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './core/auth.guard';

export const routes: Routes = [
  {
    path: 'admin',
    canActivate: [authGuard],
    providers: [{ provide: 'AREA', useValue: 'admin' }],
    loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
  },
];

Services provided at a route are created when the route activates and torn down when you navigate away, giving you precise lifetime control without a module wrapper.

Output:

✔ Application bundle generation complete. [1.832 seconds]
Initial chunk files | Names         |  Raw size
main-XQ4F.js        | main          | 215.41 kB
polyfills-FCFE.js   | polyfills     |  34.58 kB
Lazy chunk files    | Names         |  Raw size
chunk-ADMIN.js      | admin-routes  |  18.02 kB

Notice the admin feature ships as its own lazy chunk — a direct result of standalone routing and route-level providers.

Best Practices

  • Keep main.ts to a single bootstrapApplication() call and put configuration in a dedicated app.config.ts.
  • Prefer provide* functions over importing forRoot() modules so the build can tree-shake unused features.
  • Use functional guards, resolvers, and interceptors with inject() instead of class-based DI tokens.
  • Scope feature services with route-level providers and loadChildren rather than lazy feature modules.
  • Opt into router and HTTP features through with* helpers only when you need them, keeping the baseline bundle small.
  • Provide cross-cutting singletons with providedIn: 'root' on the service itself rather than listing them in the root config.
Last updated June 14, 2026
Was this helpful?