Migrating to standalone components isn't just a syntax change or a quick ng generate run. On a side project, sure, it's easy. In a production system that's already live, the story is very different. From my experience fighting through this, here are the things I do.

If you're not sure what standalone even means, here's the short version:

// before: component lives inside a module
@NgModule({
  declarations: [MemberCardComponent],
  imports: [CommonModule, RouterModule]
})
export class MemberModule {}

// after: component declares its own dependencies
@Component({
  standalone: true,
  selector: 'app-member-card',
  imports: [RouterModule, CurrencyPipe],
  templateUrl: './member-card.component.html'
})
export class MemberCardComponent {}

No more NgModule wrappers. Each component is self-contained. That's the whole shift. Now here's what makes doing this in production painful.

1. Strategy: clean codebase or clean history?

There's a classic dilemma before you even start. The safest way, in my view, is creating a new project and moving code over piece by piece. Why? You guarantee no old junk gets carried over. The codebase starts actually clean.

The downside? You lose your Git history. For a large team, knowing who changed what a year ago matters a lot when tracking bugs. If your team can't operate without that history, incremental migration is the only real option, even though the risk of breaking things mid-way is higher.

2. New control flow: bye ngIf and ngFor

New Angular ships with built-in control flow. Time to drop *ngIf and *ngFor, which always looked messy on HTML attributes anyway.

<!-- before -->
<div *ngIf="isLoggedIn; else guestBlock">Hello</div>
<ng-template #guestBlock>Please login</ng-template>

<li *ngFor="let item of items; trackBy: trackById">{{ item.name }}</li>

<!-- after -->
@if (isLoggedIn) {
  <div>Hello</div>
} @else {
  <div>Please login</div>
}

@for (item of items; track item.id) {
  <li>{{ item.name }}</li>
}

@if, @else, and @for read more like actual code. But the bigger win isn't aesthetics. Because these come from the compiler, they run faster. And you stop needing to import CommonModule just for basic loops. In a codebase with hundreds of components, removing CommonModule from all of them adds up fast.

3. UI library headaches: PrimeNG and Material

This is where most migrations actually slow down.

PrimeNG dropped PrimeFlex and moved to Tailwind. If you have thousands of PrimeFlex classes across the project, that's manual conversion with no shortcut. Plus component names changed: p-dropdown is now p-select, for example. Looks small. Across hundreds of files, it's not.

Angular Material changed its DOM structure from v15 onward to support MDC. If you were targeting internal classes like .mat-form-field-flex in your stylesheets and everyone does at some point those classes may no longer exist. Expect visual regressions anywhere that happened.

UI libraries are usually where the confidence dies. Budget time for it.

4. The dead library trap

Somewhere mid-migration you'll hit a library that hasn't kept up with newer Angular. ngx-barcode was one I ran into. The options are limited: find a maintained alternative, or force an upgrade to a newer version where the API changed.

As a lead, you can't sit on this. Every sprint you wait is a sprint wasted. How fast you need to move depends on how load-bearing the library is. A barcode scanner in a stock-opname flow isn't something you can defer.

5. Testing: last checkpoint before you ship

Migration isn't done until the test suite is green. Two specific things break almost every time:

Run the full suite after each module you migrate. That's the only way regressions don't pile up on you.

6. The actual target: zoneless

The whole point of going through all this is dropping zone.js. Zone.js patches browser APIs to detect async events and trigger change detection. It works, but since it can't tell what actually changed, it re-checks everything. Fine for small apps. In something with heavy data flows, that overhead is real.

Zoneless Angular uses signals and explicit change detection instead. The app only updates what actually changed. On financial dashboards and real-time inventory, the difference shows.

// signal: value that Angular tracks automatically
balance = signal(0);

// computed: derived value, only recalculates when balance changes
formatted = computed(() => `Rp ${this.balance().toLocaleString('id-ID')}`);

// update
deposit(amount: number) {
  this.balance.update(val => val + amount);
}

That's what I aim for on every project now: standalone, signal-based, zoneless.

Summary

Migration is about risk management, not just upgrading Angular versions. Knowing when to start fresh, when to go incremental, and when to cut a dead library loose is what actually determines whether the project finishes on schedule.