Angular Standalone Components: Migration and Best Practices in 2026

Complete guide to migrating Angular applications from NgModules to standalone components. Covers the official 3-step CLI migration, lazy loading, routing, and best practices for Angular 21.

Angular standalone components migration architecture showing the transition from NgModule to standalone component tree

Angular standalone components eliminate the need for NgModules, reducing boilerplate and unlocking fine-grained lazy loading across the entire application. Since Angular 19 made standalone the default and Angular 21 solidified zoneless change detection, migrating legacy module-based codebases has become both straightforward and high-impact.

Key Takeaway

The official Angular CLI schematic handles most of the migration automatically in three passes. A typical enterprise application can complete the conversion in a single sprint, with bundle sizes dropping by 30-50% thanks to per-component lazy loading.

NgModules vs Standalone Components: What Changed

NgModules served as the compilation context for components since Angular 2. Every component, directive, and pipe had to be declared in exactly one module, and shared functionality required carefully orchestrated module imports and exports. This created tight coupling between unrelated features and made tree-shaking difficult.

Standalone components flip this model. Each component declares its own dependencies directly in the imports array of the @Component decorator. No module registration, no shared modules, no barrel exports of half the application.

hero-list.component.tstypescript
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HeroCardComponent } from './hero-card.component';
import { SearchPipe } from '../pipes/search.pipe';

@Component({
  selector: 'app-hero-list',
  standalone: true,
  imports: [CommonModule, HeroCardComponent, SearchPipe],
  template: `
    <div class="hero-grid">
      @for (hero of heroes | search:query; track hero.id) {
        <app-hero-card [hero]="hero" />
      }
    </div>
  `
})
export class HeroListComponent {
  heroes = signal<Hero[]>([]);
  query = signal('');
}

The imports array replaces the entire NgModule dependency graph. The bundler sees exactly which components, pipes, and directives each file needs, enabling precise tree-shaking.

The 3-Step CLI Migration Process

Angular provides an automated schematic that handles the migration in three sequential passes. Each step builds on the previous one, and the project should compile cleanly between each pass.

Step 1: Convert Declarations to Standalone

The first pass scans every component, directive, and pipe in the project, adds standalone: true, and moves the necessary imports from their parent NgModule into each component's own imports array.

bash
# Step 1: Convert all declarations to standalone
ng g @angular/core:standalone --path=src/app

Select "Convert all components, directives and pipes to standalone" when prompted. The schematic uses static analysis to resolve dependencies, so any component with metadata that cannot be analyzed at build time will be skipped with a warning.

Step 2: Remove Unnecessary NgModules

With all declarations now standalone, many NgModules become empty shells. This pass identifies modules that only re-exported standalone declarations and removes them.

bash
# Step 2: Remove empty NgModules
ng g @angular/core:standalone --path=src/app

Select "Remove unnecessary NgModule classes". Modules that still contain providers, route configurations, or are imported by multiple other modules will be preserved with a TODO comment for manual review.

Step 3: Switch to Standalone Bootstrap

The final pass replaces the root NgModule with Angular's bootstrapApplication API and converts the root component to standalone.

main.ts (after migration)typescript
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));
app.config.tstypescript
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';

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

The ApplicationConfig pattern replaces the root module's providers and imports arrays. All provider functions (provideRouter, provideHttpClient, provideAnimations) work directly without module wrappers.

Routing Migration: From Modules to loadComponent

Routing modules require manual attention because the schematic does not automatically convert loadChildren module imports to loadComponent or route-level loadChildren with standalone routes.

The legacy pattern loaded entire feature modules:

app.routes.ts (before)typescript
const routes: Routes = [
  {
    path: 'dashboard',
    loadChildren: () => import('./dashboard/dashboard.module')
      .then(m => m.DashboardModule)
  }
];

The standalone equivalent loads individual components or route files directly:

app.routes.ts (after)typescript
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'dashboard',
    loadComponent: () => import('./dashboard/dashboard.component')
      .then(c => c.DashboardComponent)
  },
  {
    path: 'settings',
    loadChildren: () => import('./settings/settings.routes')
      .then(r => r.settingsRoutes)
  }
];
settings/settings.routes.tstypescript
import { Routes } from '@angular/router';

export const settingsRoutes: Routes = [
  {
    path: '',
    loadComponent: () => import('./settings.component')
      .then(c => c.SettingsComponent),
    children: [
      {
        path: 'profile',
        loadComponent: () => import('./profile/profile.component')
          .then(c => c.ProfileComponent)
      },
      {
        path: 'security',
        loadComponent: () => import('./security/security.component')
          .then(c => c.SecurityComponent)
      }
    ]
  }
];

loadComponent lazy-loads a single component. loadChildren with a route file lazy-loads an entire feature area. Both produce separate chunks that the browser fetches on demand.

Ready to ace your Angular interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Handling SharedModules and Common Dependencies

SharedModules — the catch-all modules that export commonly used components, directives, and pipes — are the most common blocker during migration. The schematic cannot remove them automatically because multiple modules import them.

The solution: convert shared declarations to standalone individually, then delete the SharedModule once nothing imports it.

typescript
// Before: SharedModule re-exports everything
@NgModule({
  declarations: [LoadingSpinner, TooltipDirective, TruncatePipe],
  exports: [LoadingSpinner, TooltipDirective, TruncatePipe],
  imports: [CommonModule]
})
export class SharedModule {}

// After: Each declaration is standalone, import directly
// loading-spinner.component.ts
@Component({
  selector: 'app-loading-spinner',
  standalone: true,
  template: `<div class="spinner" role="status"></div>`
})
export class LoadingSpinner {}

Consumers now import LoadingSpinner directly instead of the entire SharedModule. The bundler includes only the specific components each route needs.

Enforcing Standalone-Only Development

After migration, preventing new NgModules from creeping back into the codebase is essential. Angular provides a TypeScript compiler option for this.

tsconfig.jsonjson
{
  "angularCompilerOptions": {
    "strictStandalone": true
  }
}

With strictStandalone enabled, any attempt to create a non-standalone component, directive, or pipe produces a compilation error. This enforces the new architecture across the entire team.

Performance Gains: Bundle Size and Lazy Loading

The primary performance benefit of standalone components comes from granular lazy loading. With NgModules, lazy loading operated at the module level — importing one component from a module pulled in every declaration that module exported. Standalone components break this coupling.

A real-world benchmark on a mid-size enterprise application (200+ components) showed:

| Metric | NgModule-based | Standalone | Improvement | |--------|---------------|------------|-------------| | Initial bundle | 485 KB | 218 KB | -55% | | Largest lazy chunk | 142 KB | 38 KB | -73% | | Time to Interactive | 3.2s | 1.8s | -44% | | Build time (esbuild) | 12.4s | 8.1s | -35% |

These numbers come from removing the overhead of module resolution and enabling the bundler to eliminate unused exports at the component level rather than the module level.

Testing Standalone Components

Unit tests simplify significantly with standalone components. The TestBed configuration no longer requires importing entire modules to satisfy a component's dependencies.

hero-list.component.spec.tstypescript
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HeroListComponent } from './hero-list.component';
import { HeroService } from '../services/hero.service';

describe('HeroListComponent', () => {
  let fixture: ComponentFixture<HeroListComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [HeroListComponent],
      providers: [
        { provide: HeroService, useValue: { getHeroes: () => of([]) } }
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(HeroListComponent);
  });

  it('should render hero cards', () => {
    fixture.componentRef.setInput('heroes', mockHeroes);
    fixture.detectChanges();
    const cards = fixture.nativeElement.querySelectorAll('app-hero-card');
    expect(cards.length).toBe(mockHeroes.length);
  });
});

The component goes directly into the imports array of TestBed.configureTestingModule. All its declared dependencies are already resolved through its own imports, so no additional module imports are needed.

Common Migration Pitfalls

Circular imports between standalone components. When component A imports component B and B imports A, the TypeScript compiler throws a circular dependency error. The fix: extract the shared interface into a separate file or use forwardRef() as a temporary workaround while refactoring the dependency chain.

Third-party libraries still using NgModules. Many libraries have migrated to standalone, but some legacy packages still export NgModules. Import these modules directly in the standalone component's imports array — Angular supports mixing standalone and module-based imports.

Missing providers after removing AppModule. Services previously provided in the root module's providers array must move to the ApplicationConfig in app.config.ts or use providedIn: 'root' in the @Injectable decorator. Route-scoped services should use the providers array in route configurations.

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Conclusion

  • Angular's official CLI schematic automates 80-90% of the migration through three sequential passes: convert declarations, remove modules, switch bootstrap
  • Routing modules need manual conversion from loadChildren with NgModules to loadComponent or standalone route files
  • SharedModules are the primary blocker — convert each shared declaration to standalone individually, then delete the module
  • Enable strictStandalone in tsconfig to prevent new NgModules from being introduced post-migration
  • Bundle sizes drop 30-55% thanks to component-level tree-shaking and granular lazy loading with loadComponent
  • Unit tests simplify dramatically — import the standalone component directly in TestBed without module configuration
  • Angular 21's zoneless change detection and signal-based reactivity pair naturally with standalone architecture for maximum performance

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

#angular
#standalone components
#migration
#angular 21
#ngmodule
#lazy loading

Share

Related articles