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 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.
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.
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.
# Step 1: Convert all declarations to standalone
ng generate @angular/core:standalone --path=src/appSelect "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.
# Step 2: Remove empty NgModules
ng generate @angular/core:standalone --path=src/appSelect "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 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.
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));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.
// 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.
// 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:
const routes: Routes = [
{
path: 'dashboard',
loadChildren: () => import('./dashboard/dashboard.module')
.then(m => m.DashboardModule)
}
];The standalone equivalent loads individual components or route files directly:
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)
}
];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.
// 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.
{
"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:
| 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 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.
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.
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
- Angular v22 Release Notes - OnPush default, selectorless stable, TypeScript 6 requirement
- Angular Standalone Migration Guide - Official 3-step schematic documentation
- What's New in Angular 22 - Detailed changelog including change detection strategy changes
- Selectorless Components RFC - Design rationale and implementation status
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
loadChildrenwith NgModules toloadComponentor standalone route files - SharedModules are the primary blocker, convert each shared declaration to standalone individually, then delete the module
- Enable
strictStandalonein 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
TestBedwithout 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.
Can you spot the bug in Angular?
One real snippet, one hidden bug, one attempt a day. No account needed to try.

Written by
Anthony Fillion-MailletFounder of SharpSkill
Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.
Updated on August 21, 2026
Tags
Share
Related articles

Advanced Angular Dependency Injection in 2026: Providers, Tokens and Interview Questions
Master Angular dependency injection with InjectionToken, hierarchical injectors, resolution modifiers, and multi-providers. Includes interview questions and production patterns.

Angular Control Flow Syntax in 2026: @if, @for, @switch and Interview Questions
Master Angular's built-in control flow syntax with @if, @for, and @switch blocks. Learn the track expression, contextual variables, and common interview questions about Angular 22 template syntax.

Angular HttpClient and Interceptors in 2026: Request Handling and Interview Questions
Master Angular HttpClient with functional interceptors, authentication patterns, error handling, and caching strategies. Includes common interview questions and real-world code examples.