Angular Standalone Components: Migration and Best Practices for Angular 22

Complete guide to migrating Angular applications from NgModules to standalone components. Covers the official 3-step CLI migration, selectorless components, OnPush default, and lazy loading with loadComponent in Angular 22.

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 22 introduced selectorless components as stable, 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. As of Angular 22, this pattern is further simplified with selectorless components, allowing direct imports without string selectors.

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

@Component({
  selector: 'app-hero-list',
  standalone: true,
  imports: [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. Note that CommonModule is no longer needed in Angular 22 since control flow syntax (@for, @if) is built into the compiler.

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 generate @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 generate @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.

Angular 22 Requirement

Angular 22 requires TypeScript 6 and Node.js 26. Older versions are not supported. Run ng version to check compatibility before starting the migration.

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, provideZoneChangeDetection } 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: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    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.

Angular 22: OnPush Default and Selectorless Components

Angular 22 introduced two changes that affect standalone component development: OnPush as the default change detection strategy and selectorless components reaching stable status.

OnPush by Default

New components generated in Angular 22 use ChangeDetectionStrategy.OnPush by default. This encourages signal-based reactivity over Zone.js-triggered checks. During migration, the schematic adds explicit changeDetection: ChangeDetectionStrategy.Default to existing components to maintain backward compatibility.

typescript
// Angular 22: OnPush is implicit for new components
@Component({
  selector: 'app-dashboard',
  standalone: true,
  // changeDetection: ChangeDetectionStrategy.OnPush is now the default
  template: \`<h1>{{ title() }}</h1>\`
})
export class DashboardComponent {
  title = signal('Dashboard');
}

For migrated applications, consider adopting OnPush incrementally by replacing class properties with signals. This aligns with the signal-first architecture that Angular 22 promotes.

Selectorless Components (Stable)

Selectorless components allow importing components directly in templates without defining string selectors. This eliminates selector naming conflicts in large codebases and provides better type safety.

typescript
// Traditional approach with selector
@Component({
  selector: 'app-user-card',
  standalone: true,
  template: \`<div class="user-card">{{ user().name }}</div>\`
})
export class UserCardComponent {
  user = input.required<User>();
}

// Selectorless approach (Angular 22+)
@Component({
  standalone: true,
  template: \`<div class="user-card">{{ user().name }}</div>\`
})
export class UserCardComponent {
  user = input.required<User>();
}

In templates, selectorless components are used by their class name rather than a custom selector tag. The compiler ensures the import exists, catching errors at build time rather than at runtime.

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. The Angular forms migration follows the same pattern with Signal Forms now stable in Angular 22.

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) measured before and after standalone migration:

MetricNgModule-basedStandaloneImprovement
Initial bundle485 KB218 KB-55%
Largest lazy chunk142 KB38 KB-73%
Time to Interactive3.2s1.8s-44%
Build time (esbuild)12.4s8.1s-35%

These numbers result 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 with Vitest

Unit tests simplify significantly with standalone components. Angular 22 made Vitest the default test runner, replacing Karma. 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';
import { of } from 'rxjs';

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.

Interview Question

When asked about standalone components in an Angular interview, focus on three points: (1) dependency management moves from module level to component level, (2) tree-shaking becomes granular, and (3) testing setup simplifies because components declare their own imports.

Sources

Migrating to Standalone: Key Takeaways for Angular 22

  • 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 with Vitest as the default runner, import the standalone component directly in TestBed without module configuration
  • Angular 22's OnPush default and selectorless components pair naturally with standalone architecture for a cleaner, more maintainable codebase

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Daily challenge

Can you spot the bug in Angular?

One real snippet, one hidden bug, one attempt a day. No account needed to try.

Anthony Fillion-Maillet

Written by

Anthony Fillion-Maillet

Founder of SharpSkill

Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.

Updated on August 21, 2026

Tags

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

Share

Related articles