# 🏗️ Migrating from NgModules to Angular Standalone Components: The Complete Guide


---

## 🤔 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** 😓

```typescript
// ❌ 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** 😓

```typescript
// ❌ 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** 😓

```typescript
// ❌ 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** 😓

```typescript
// ❌ 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

```bash
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

```typescript
// ✅ 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:

```bash
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

```bash
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

```typescript
// ✅ 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:

```typescript
/* 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:

```bash
ng build  # ✅ must pass before continuing
git add . && git commit -m "chore: remove unnecessary NgModules"
```

---

### Step 3: Switch to standalone bootstrap API

```bash
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:**

```typescript
// ❌ BEFORE: main.ts
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));
```

```typescript
// ✅ 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`:

```typescript
// ✅ 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:

```bash
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`

```typescript
// ❌ 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:

```typescript
// ✅ Standalone lazy route with children
{
  path: 'products',
  loadChildren: () =>
    import('./products/products.routes').then(m => m.PRODUCT_ROUTES),
}
```

And the routes file:

```typescript
// 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()`

```typescript
// ❌ 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:

```typescript
// ❌ 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:

```typescript
// ❌ 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:

```typescript
// 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:

```typescript
// ❌ 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.

```json
// 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:

```typescript
// ❌ 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:

```typescript
// 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.

```bash
# 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`:

```typescript
// ❌ 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... 😓
```

```typescript
// ✅ 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

- 📖 [Angular docs: Standalone migration guide](https://angular.dev/reference/migrations/standalone)
- 🔄 [Angular docs: Standalone components overview](https://angular.dev/guide/components/importing)
- ⚡ [Angular SSR and incremental hydration](https://blog.techtush.in/angular-ssr-and-incremental-hydration-faster-apps-better-core-web-vitals)
- 🏗️ [Angular state management in 2026](https://blog.techtush.in/angular-state-management-in-2026-plain-services-vs-ngrx-signal-store-vs-ngrx)
