Standalone Components Migration: Lessons From 200k LOC
Migrating a 200,000-line Angular codebase to standalone components without breaking anything. The strategy, the gotchas, and what wed do differently.
The Problem With NgModules at Scale
NgModules made sense when Angular first shipped them. But in a codebase with 200+ modules, they become a maintenance burden — circular dependency errors, confusing exports arrays, and the constant question: "which module does this component belong to?"
Standalone components eliminate the module layer entirely. Each component declares its own dependencies. It's simpler, faster to compile, and much easier to reason about.
Our Migration Strategy: The Schematic First
Angular ships a migration schematic that handles most of the work:
ng generate @angular/core:standalone
Run it three times with different options:
# Step 1: Convert components, directives, pipes to standalone
ng generate @angular/core:standalone --mode=convert-to-standalone
# Step 2: Remove unnecessary NgModule declarations
ng generate @angular/core:standalone --mode=prune-ng-modules
# Step 3: Bootstrap without AppModule
ng generate @angular/core:standalone --mode=standalone-bootstrap
This handles around 80% of the work automatically. The remaining 20% is where judgment comes in.
The Manual Work: What the Schematic Misses
1. Lazy-Loaded Routes
The schematic doesn't always handle lazy routes correctly. Before:
// Old: module-based lazy loading
{
path: 'dashboard',
loadChildren: () =>
import('./dashboard/dashboard.module').then(m => m.DashboardModule)
}
After migration, update to load the component directly:
// New: standalone lazy loading
{
path: 'dashboard',
loadComponent: () =>
import('./dashboard/dashboard.component').then(m => m.DashboardComponent)
}
2. Shared Utilities
We had a SharedModule that exported 40+ components, pipes, and directives. After migration, each component that needed a shared piece had to import it individually.
We solved this with import barrel arrays:
// shared/index.ts
export const SHARED_COMPONENTS = [
DatePipePipe,
CurrencyFormatPipe,
LoadingSpinnerComponent,
EmptyStateComponent,
] as const;
// In consuming components:
@Component({
imports: [SHARED_COMPONENTS, CommonModule],
})
3. Providers That Were in NgModules
Services provided in a module's providers array need to move somewhere:
// Before — in a feature module
@NgModule({
- providers: [UserService, AuthGuard]
})
// After — move to route-level providers
{
path: 'users',
loadComponent: () => import('./users.component'),
+ providers: [UserService, AuthGuard]
}
The Gotcha That Cost Us Two Days
We had a component that used ViewChild to query a directive that was only exported via SharedModule. After migration, the ViewChild query silently returned undefined because the directive wasn't imported in the standalone component.
The lesson: after migration, run your full E2E test suite before merging. Unit tests won't catch missing imports in templates — they're often too shallow.
Performance Result
After completing the full migration:
| Metric | Before | After |
|---|---|---|
| Initial bundle | 2.1MB | 1.6MB |
| Build time | 4m 12s | 2m 38s |
| Lazy chunk average | 180kB | 95kB |
The build time improvement alone was worth the effort for our CI pipeline.
Would We Do It Again?
Yes — immediately. The codebase is dramatically easier to navigate. New developers onboarding can understand a component's dependencies just by looking at its imports array. No more hunting through 12 modules to find where SomeDirective is declared.
Run the schematic, fix what it misses, update your lazy routes, and consolidate shared imports. Budget two sprints for a codebase our size.
Comments