Standalone Components Migration: Lessons From 200k LOC

Sayeed Mohammad7 min read

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:

bash
ng generate @angular/core:standalone

Run it three times with different options:

bash
# 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:

typescript
// Old: module-based lazy loading
{
  path: 'dashboard',
  loadChildren: () =>
    import('./dashboard/dashboard.module').then(m => m.DashboardModule)
}

After migration, update to load the component directly:

typescript
// 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:

typescript
// 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:

diff
// 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

Leave a comment