๐๏ธ Migrating from NgModules to Angular Standalone Components: The Complete Guide
NgModules are on their way out. Angular has been standalone-first since v17, and the automated migration schematic makes the switch easier than you think โ here's exactly how to do it, with real before/after code
Hi ๐, I'm Tushar Patil. Currently I am working as Frontend Developer (Angular) and also have expertise with .Net Core and Framework.
๐ค Why bother migrating?
NgModules were Angular's original way of organising code. Every component, directive, and pipe had to be declared in exactly one module โ and sharing anything between features meant building elaborate chains of imports and exports. It worked, but it was painful. ๐ฉ
Standalone components, introduced in Angular 14 and made the default in Angular 17, flip that model on its head. Every component manages its own dependencies directly. No module needed. The benefits are real:
- ๐ฆ Smaller bundles โ bundle sizes drop 30โ55% in typical apps thanks to component-level tree-shaking and granular lazy loading. With NgModules, lazy loading a single component pulled in every declaration that module exported. Standalone components break this coupling completely.
- ๐๏ธ Faster builds โ migrating to standalone unlocks ESBuild properly, dramatically reducing pipeline times. One team reported switching to ESBuild in literally 10 minutes after their NgModules were gone โ previously, module-related build issues had made it impossible.
- ๐ง Simpler architecture โ no more hunting through module files to figure out where a component gets
HttpClientfrom. The component'simportsarray tells the whole story. - ๐งช Easier testing โ import the standalone component directly in
TestBedwithout module configuration. - ๐ฎ Future-proofing โ Angular 19 made standalone the default, Angular 21 solidified the standalone + zoneless + signals stack. NgModules are in maintenance mode.
๐ก Good news: Angular ships an automated schematic that handles most of the migration in three sequential passes. A typical enterprise application can complete the conversion in a single sprint.
โ Pre-flight checklist
Before running the schematic, make sure your project meets these requirements:
- ๐ข Angular 15.2.0 or later (the schematic requires this as a minimum)
- ๐๏ธ Project builds without any compilation errors (
ng buildsucceeds) - ๐ฟ You are on a clean Git branch โ all work saved and committed
- ๐งช All unit tests pass on the current codebase
- ๐ฆ No pending
ng updateโ upgrade Angular first if needed
โ ๏ธ The clean Git branch step is critical. The schematic makes sweeping changes across many files. If something goes wrong, you want a clean
git reset --hardas your escape hatch. Never run the schematic on a dirty working tree.
๐ Before: what a typical NgModule app looks like
Let's establish our baseline. Here's a typical feature in a module-based Angular app โ a ProductsModule with a component, a pipe, a directive, and a service.
src/app/
app.module.ts
app.component.ts
shared/
shared.module.ts
highlight.directive.ts
currency-format.pipe.ts
products/
products.module.ts
products.component.ts
product-card.component.ts
product.service.ts
app.module.ts โ the root module ๐
// โ BEFORE: app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { SharedModule } from './shared/shared.module';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
HttpClientModule,
RouterModule.forRoot([
{
path: 'products',
loadChildren: () =>
import('./products/products.module').then(m => m.ProductsModule),
},
]),
SharedModule,
],
bootstrap: [AppComponent],
})
export class AppModule {}
shared.module.ts โ a shared module ๐
// โ BEFORE: shared.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HighlightDirective } from './highlight.directive';
import { CurrencyFormatPipe } from './currency-format.pipe';
@NgModule({
declarations: [HighlightDirective, CurrencyFormatPipe],
imports: [CommonModule],
exports: [HighlightDirective, CurrencyFormatPipe],
})
export class SharedModule {}
products.module.ts โ a feature module ๐
// โ BEFORE: products.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { SharedModule } from '../shared/shared.module';
import { ProductsComponent } from './products.component';
import { ProductCardComponent } from './product-card.component';
@NgModule({
declarations: [ProductsComponent, ProductCardComponent],
imports: [
CommonModule,
SharedModule,
RouterModule.forChild([
{ path: '', component: ProductsComponent },
]),
],
})
export class ProductsModule {}
products.component.ts โ a component ๐
// โ BEFORE: products.component.ts
import { Component, OnInit } from '@angular/core';
import { ProductService } from './product.service';
@Component({
selector: 'app-products',
templateUrl: './products.component.html',
})
export class ProductsComponent implements OnInit {
products$ = this.productService.getAll();
constructor(private productService: ProductService) {}
ngOnInit() {}
}
Notice what the component doesn't know about: CommonModule, SharedModule, HighlightDirective, CurrencyFormatPipe. All of that lives in modules. The component is dependent on its module hierarchy to work โ it can't stand alone. ๐
๐ค The automated migration: 3 steps
The migration process is composed of three steps. You'll have to run it multiple times and check manually that the project builds and behaves as expected.
Step 1: Convert all declarations to standalone
ng g @angular/core:standalone
When prompted, select: "Convert all components, directives and pipes to standalone"
โ What the schematic does automatically:
- Adds
standalone: trueto every component, directive, and pipe - Moves dependencies from parent NgModule
declarationsinto each component's ownimportsarray - Adds
NgIf,NgFor,AsyncPipeetc. as direct imports where needed
// โ
AFTER STEP 1: products.component.ts
import { Component } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { ProductCardComponent } from './product-card.component';
import { HighlightDirective } from '../shared/highlight.directive';
import { CurrencyFormatPipe } from '../shared/currency-format.pipe';
import { ProductService } from './product.service';
@Component({
selector: 'app-products',
standalone: true, // ๐ added by schematic
imports: [
AsyncPipe,
ProductCardComponent,
HighlightDirective,
CurrencyFormatPipe,
], // ๐ dependencies moved here
templateUrl: './products.component.html',
})
export class ProductsComponent {
products$ = this.productService.getAll();
constructor(private productService: ProductService) {}
}
The modules still exist at this point โ components are standalone but haven't been cut loose yet. After step 1:
ng build # โ
must pass before continuing
git add . && git commit -m "chore: convert all declarations to standalone"
โ ๏ธ Always verify and commit between steps. Each step builds on the previous. You have to run the schematic 3 times: first to mark everything as standalone and move components/pipes/directives to the imports array of their respective modules instead of the declarations array, next to remove the NgModules, and finally to remove the AppModule and use the standalone providers.
Step 2: Remove unnecessary NgModules
ng g @angular/core:standalone
When prompted, select: "Remove unnecessary NgModule classes"
โ What the schematic does automatically:
- Deletes NgModules that are now empty shells (modules whose only purpose was declaring components)
- Updates imports of removed modules throughout the codebase
- Leaves a
TODOcomment where it can't safely remove a reference automatically
// โ
AFTER STEP 2: shared.module.ts is DELETED ๐๏ธ
// HighlightDirective and CurrencyFormatPipe are now imported directly
// โ
products.module.ts is DELETED ๐๏ธ
// Products feature is now wired via routes directly
Some modules will survive step 2 โ particularly SharedModules that are imported by many other modules. Certain modules, particularly SharedModules, persisted after the schematic execution. The schematic cannot remove modules that are imported by other modules repeatedly, because it cannot tell if it is safe to remove them.
For these, you have two options:
- ๐ Investigate each one and remove it manually
- ๐ฃ Remove all remaining NgModules in one go and fix the resulting errors
For large apps, the second approach often works surprisingly well โ the build errors tell you exactly what's missing, and fixing them is systematic.
The TODO comments left by the schematic look like this:
/* TODO(standalone-migration): clean up removed NgModule reference manually */
import { SharedModule } from './shared/shared.module';
Search your codebase for TODO(standalone-migration) to find every manual fix needed. ๐
After step 2:
ng build # โ
must pass before continuing
git add . && git commit -m "chore: remove unnecessary NgModules"
Step 3: Switch to standalone bootstrap API
ng g @angular/core:standalone
When prompted, select: "Bootstrap the project using standalone APIs"
โ What the schematic does automatically:
- Converts
bootstrapModule(AppModule)inmain.tstobootstrapApplication(AppComponent, appConfig) - Removes
standalone: falsefrom the root component - Deletes the root
AppModule - Copies providers from
AppModuleinto the newbootstrapApplicationcall
main.ts before and after:
// โ BEFORE: main.ts
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));
// โ
AFTER: 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 schematic also generates a clean app.config.ts:
// โ
GENERATED: app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(), // replaces HttpClientModule
],
};
After step 3:
ng build # โ
must pass
git add . && git commit -m "chore: switch to standalone bootstrap API"
๐ Congratulations โ your app is now fully standalone!
๐ง Manual clean-up: what the schematic doesn't handle
The migration schematic does not migrate the routing to use the new standalone router API. Here are the key manual updates to make after the three automated steps.
1. ๐ฃ๏ธ Routing: loadChildren โ loadComponent
// โ Old module-based lazy loading
{
path: 'products',
loadChildren: () =>
import('./products/products.module').then(m => m.ProductsModule),
}
// โ
New standalone lazy loading โ direct to the component
{
path: 'products',
loadComponent: () =>
import('./products/products.component').then(m => m.ProductsComponent),
}
For feature routes with multiple child paths, use loadChildren with a routes array instead:
// โ
Standalone lazy route with children
{
path: 'products',
loadChildren: () =>
import('./products/products.routes').then(m => m.PRODUCT_ROUTES),
}
And the routes file:
// products.routes.ts
import { Routes } from '@angular/router';
export const PRODUCT_ROUTES: Routes = [
{ path: '', component: ProductsComponent },
{ path: ':id', component: ProductDetailComponent },
];
2. ๐ HTTP providers: HttpClientModule โ provideHttpClient()
// โ Before: in AppModule imports
HttpClientModule
// โ
After: in app.config.ts providers
import { provideHttpClient, withInterceptors } from '@angular/common/http';
provideHttpClient(
withInterceptors([myAuthInterceptor]) // functional interceptors
)
Class-based interceptors also need updating:
// โ Old class-based interceptor registration
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
// โ
New functional interceptor
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).token();
const authReq = req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`)
});
return next(authReq);
};
// In app.config.ts:
provideHttpClient(withInterceptors([authInterceptor]))
3. ๐๏ธ NgRx / third-party module providers
If you use NgRx, replace module-based setup with standalone providers:
// โ Before: in AppModule imports
StoreModule.forRoot(reducers),
EffectsModule.forRoot([AppEffects]),
StoreDevtoolsModule.instrument(),
// โ
After: in app.config.ts providers
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
provideStore(reducers),
provideEffects(AppEffects),
provideStoreDevtools({ maxAge: 25 }),
For feature-level NgRx state, register providers on the route:
// products.routes.ts โ NgRx for this feature only
export const PRODUCT_ROUTES: Routes = [
{
path: '',
component: ProductsComponent,
providers: [
provideState(productsFeature),
provideEffects(ProductsEffects),
],
},
];
4. ๐ CommonModule โ individual imports
The schematic handles most of this, but check any components that imported CommonModule wholesale:
// โ Still importing CommonModule (schematic may leave some)
imports: [CommonModule]
// โ
Import only what you actually use
imports: [NgIf, NgFor, NgClass, AsyncPipe, DatePipe, CurrencyPipe]
๐ Lock it in: prevent new NgModules
After migration, add strictStandalone to your tsconfig.json to enforce standalone-only authoring. Consider adding the strictStandalone option in tsconfig.json to enforce authoring only standalone components in the future.
// tsconfig.json
{
"angularCompilerOptions": {
"strictStandalone": true
}
}
With this set, any new component, directive, or pipe that lacks standalone: true will cause a TypeScript compile error. The migration can't accidentally be undone. โ
๐งช Updating tests
Standalone components make TestBed simpler โ no module needed:
// โ Before: required a module declaration
TestBed.configureTestingModule({
declarations: [ProductsComponent],
imports: [SharedModule, HttpClientTestingModule],
});
// โ
After: import the standalone component directly
TestBed.configureTestingModule({
imports: [
ProductsComponent, // ๐ standalone components go in imports, not declarations
HttpClientTestingModule,
],
});
๐ก Standalone components go in
imports, notdeclarationsin TestBed. This is the most common test update mistake after migration.
๐ง Common pitfalls and how to handle them
โ ๏ธ SharedModule not removed automatically
As noted above, SharedModules imported by many other modules often survive step 2. The solution: remove them manually and update each consuming component to import the directives/pipes/components directly.
โ ๏ธ Third-party library compatibility
Some older libraries still export NgModules. Use importProvidersFrom to bridge them:
// app.config.ts โ bridging an NgModule-based library
import { importProvidersFrom } from '@angular/core';
import { SomeLegacyModule } from 'some-legacy-library';
export const appConfig: ApplicationConfig = {
providers: [
importProvidersFrom(SomeLegacyModule.forRoot()),
],
};
importProvidersFrom is your bridge between the NgModule world and the standalone world. It extracts the providers from an NgModule and makes them available in a standalone context. ๐
โ ๏ธ Increased chunk count
Migrating to standalone components can result in a higher number of JavaScript chunks, potentially affecting load times. Ensure your server supports HTTP/2, which handles multiple simultaneous requests more efficiently, and analyze your application's chunking strategy to optimize load performance.
โ ๏ธ TODO comments left behind
Search for TODO(standalone-migration) after each step and resolve them before moving to the next step.
# Find all TODO comments left by the schematic
grep -r "TODO(standalone-migration)" src/
๐ Migration checklist
Pre-migration:
- ๐ข On Angular 15.2+
- ๐๏ธ Clean build (
ng buildpasses) - ๐ฟ Clean Git branch
- ๐งช All tests passing
Step 1 โ Convert declarations:
- ๐ค Run
ng g @angular/core:standaloneโ "Convert all components..." - ๐๏ธ Verify
ng buildpasses - โ๏ธ Commit
Step 2 โ Remove NgModules:
- ๐ค Run
ng g @angular/core:standaloneโ "Remove unnecessary NgModule classes" - ๐ Resolve all
TODO(standalone-migration)comments - ๐๏ธ Manually remove any surviving SharedModules
- ๐๏ธ Verify
ng buildpasses - โ๏ธ Commit
Step 3 โ Standalone bootstrap:
- ๐ค Run
ng g @angular/core:standaloneโ "Bootstrap the project using standalone APIs" - ๐๏ธ Verify
ng buildpasses - โ๏ธ Commit
Post-migration clean-up:
- ๐ฃ๏ธ Update
loadChildrenโloadComponentfor single-component routes - ๐ Replace
HttpClientModuleโprovideHttpClient() - ๐ Update class interceptors โ functional interceptors
- ๐ฆ Update NgRx/third-party libraries to standalone providers
- ๐ Replace remaining
CommonModuleโ individual pipe/directive imports - ๐ Add
strictStandalone: truetotsconfig.json - ๐งช Update TestBed configs โ standalone components go in
imports - ๐ Run full test suite and fix any failures
- ๐ Run linter and formatter
๐ฏ The end result
Here's the before/after picture for our ProductsComponent:
// โ BEFORE โ module-based
// Component knows nothing about its own dependencies
@Component({
selector: 'app-products',
templateUrl: './products.component.html',
})
export class ProductsComponent implements OnInit {
products$ = this.productService.getAll();
constructor(private productService: ProductService) {}
ngOnInit() {}
}
// Dependencies live in products.module.ts, shared.module.ts, app.module.ts... ๐
// โ
AFTER โ standalone
// Component is completely self-describing
@Component({
selector: 'app-products',
standalone: true,
imports: [
AsyncPipe,
ProductCardComponent,
HighlightDirective,
CurrencyFormatPipe,
],
templateUrl: './products.component.html',
})
export class ProductsComponent {
products$ = inject(ProductService).getAll();
}
// Everything this component needs is right here ๐
Two things to notice in the AFTER version: the component's entire dependency graph is visible at a glance โ no module-hunting required. And inject() replaces constructor injection, making the component even cleaner. The component went from depending on three module files to being entirely self-contained. That's the standalone promise, delivered. ๐