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 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.
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.
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.
# Step 1: Convert all declarations to standalone
ng g @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 g @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.
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 } 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:
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.
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.
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
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 dramatically — import the standalone component directly in
TestBedwithout 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
Share
Related articles

Angular 19 Interview Questions: Signals, SSR and Must-Know Concepts
The most common Angular 19 interview questions: Signals, incremental hydration, zoneless change detection, and new reactive APIs with code examples and expected answers.

Top 25 Angular Interview Questions: Complete Guide to Success
The 25 most asked Angular interview questions in 2026. Detailed answers, code examples and tips to land your Angular developer position.

Angular 18: Signals and New Features
Discover Angular 18 Signals, zoneless change detection, and new signal-based APIs to build more performant applications.