Angular Standalone Components vs NgModules: A Decision Guide for New Projects
Standalone components are the right default for new Angular projects, but the decision depends on version, library compatibility, and team context. This guide compares both options and shows a working standalone setup.
01 Aug 2025, 13:57 UTC

Starting a new Angular project forces an early architectural choice: build everything as standalone components (the default since Angular 17) or keep the classic NgModule structure. The wrong call is hard to reverse once hundreds of components exist, so it is worth deciding deliberately. The short version: for a new project on Angular 15 or later, standalone is the safer default. NgModules remain fully supported, but they now mainly make sense when you are extending an existing module-based codebase or depend on libraries that still assume them.
The decision and its constraints
Standalone components carry their own imports, so a component declares exactly what it needs instead of relying on a shared module to register it. This removes an entire layer of boilerplate (no app.module.ts, no declarations arrays) and lets the router lazy-load single components directly.
The constraints that actually matter:
- Team familiarity. NgModule patterns dominate older tutorials, Stack Overflow answers, and internal code. A team mid-migration may run both styles, which is supported but adds cognitive overhead.
- Library compatibility. Most maintained libraries ship standalone-compatible APIs (e.g.
provideHttpClient()instead ofHttpClientModule), but some older ones still export only modules. Standalone components can import modules, so this is rarely a blocker. - Angular version. Standalone APIs are stable since Angular 15 and the CLI default since Angular 17. If you are pinned to Angular 14, treat standalone as preview and prefer NgModules.
Comparing the supported options
| Concern | Standalone | NgModule |
|---|---|---|
| Boilerplate | None; component imports its own dependencies | Module file with declarations/imports/exports per feature |
| Lazy loading | loadComponent per route | loadChildren pointing at a module |
| Providers | bootstrapApplication and route-level providers | Module providers arrays |
| Shared UI pieces | Import the component/pipe directly where used | SharedModule pattern |
| Tree-shaking | Straightforward; unused imports are dropped | Depends on module structure |
| Ecosystem fit | Current docs, schematics, and CLI default | Legacy docs and older libraries |
Trade-offs in practice
The main thing standalone removes is indirection. With NgModules, finding where a pipe comes from often means opening a feature module and tracing exports. With standalone, the answer is in the component's own imports array. The cost is repetition: a component using CommonModule directives and ReactiveFormsModule lists them explicitly, and so does every sibling. In practice this is a few lines per component and IDEs auto-import them.
The other real trade-off is shared configuration. NgModules gave you one obvious place to register app-wide providers. The standalone equivalent is the providers array passed to bootstrapApplication in main.ts, plus functional interceptors and provide* helper functions. It is a different shape, not a missing capability.
Concrete implementation
Generate a standalone-first project (Angular 17+ CLI does this by default; the flag below makes it explicit):
ng new my-app --standalone
cd my-app
Run this in a terminal with Node.js and the Angular CLI installed. No elevated permissions are needed. Verify the result by confirming that src/app/app.module.ts does not exist and that main.ts bootstraps directly:
// src/main.ts
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()]
});
A standalone component declares its own dependencies inline:
import { Component } from '@angular/core';
import { DatePipe } from '@angular/common';
@Component({
selector: 'app-order-summary',
standalone: true,
imports: [DatePipe],
template: `<p>Placed {{ orderDate | date:'mediumDate' }}</p>`
})
export class OrderSummaryComponent {
orderDate = new Date();
}
Lazy-load it without any module:
export const routes: Routes = [
{
path: 'orders',
loadComponent: () =>
import('./orders/order-summary.component').then(m => m.OrderSummaryComponent)
}
];
Validating the choice
Two practical checks. First, confirm the build works and routes resolve: ng serve, then navigate to the lazy route and watch the network tab — the component should load as a separate chunk. Second, compare bundle output if you migrated from NgModules:
ng build --stats-json
This writes dist/<project>/stats.json, which you can inspect with webpack-bundle-analyzer to confirm removed module boilerplate actually shrank the initial chunk. Gains are usually modest for small apps; the bigger win is simpler structure, not raw bytes.
Limitations and when to stay on NgModules
Standalone does not remove every module-era concept. Feature-level provider scoping that a lazy-loaded NgModule gave you for free must now be expressed with route-level providers. Some third-party libraries still document only module-based setup, so expect occasional translation work. And if your organization maintains a large NgModule codebase, introducing standalone incrementally — new features standalone, old features untouched — is supported and usually beats a big-bang rewrite. The ng generate @angular/core:standalone schematic can automate much of a migration, but review its output before committing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.