Skip to main content

Command Palette

Search for a command to run...

๐Ÿ—๏ธ 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

Published
โ€ข14 min read
๐Ÿ—๏ธ Migrating from NgModules to Angular Standalone Components: The Complete Guide
T

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 HttpClient from. The component's imports array tells the whole story.
  • ๐Ÿงช Easier testing โ€” import the standalone component directly in TestBed without 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 build succeeds)
  • ๐ŸŒฟ 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 --hard as 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: true to every component, directive, and pipe
  • Moves dependencies from parent NgModule declarations into each component's own imports array
  • Adds NgIf, NgFor, AsyncPipe etc. 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 TODO comment 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) in main.ts to bootstrapApplication(AppComponent, appConfig)
  • Removes standalone: false from the root component
  • Deletes the root AppModule
  • Copies providers from AppModule into the new bootstrapApplication call

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, not declarations in 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 build passes)
  • ๐ŸŒฟ Clean Git branch
  • ๐Ÿงช All tests passing

Step 1 โ€” Convert declarations:

  • ๐Ÿค– Run ng g @angular/core:standalone โ†’ "Convert all components..."
  • ๐Ÿ—๏ธ Verify ng build passes
  • โœ”๏ธ 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 build passes
  • โœ”๏ธ Commit

Step 3 โ€” Standalone bootstrap:

  • ๐Ÿค– Run ng g @angular/core:standalone โ†’ "Bootstrap the project using standalone APIs"
  • ๐Ÿ—๏ธ Verify ng build passes
  • โœ”๏ธ Commit

Post-migration clean-up:

  • ๐Ÿ›ฃ๏ธ Update loadChildren โ†’ loadComponent for 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: true to tsconfig.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. ๐Ÿš€


๐Ÿ“š Further reading