Skip to main content
Site logo
Back to blog

Migrating an app from Angular to React

I was handed the task of migrating an entire production app from Angular to React. Here's the process — the plan, the agent protocol, and the times the AI was convincingly wrong.

  • Engineering
  • Architecture
  • AI

I recently took over the frontend of a Brazilian financial-operations platform: about a year of Angular 19 built by an outside agency — 55 components, 24 services, and roughly 27,000 lines of TypeScript and templates. It worked. It also fought back every time we needed to move fast: hand-written API interfaces that had quietly drifted from the real backend schema, RxJS ceremony around what were mostly CRUD screens, and a test setup nobody wanted to touch.

I proposed migrating it to React, and the port itself took about a week — with an AI agent team doing most of the typing. This post documents how, including the part where the AI wrote convincing, wrong code, because that's the part most posts leave out.

The setup

A little context on my environment, because the workflow depends on it: I use Ghostty as my terminal, Claude Code as the agent harness, and the Zed IDE. I've been using Claude for a while now and the experience has been nothing but positive — but the point of this post is not the tool. It's the process that keeps the tool honest.

The strategy: parallel app, hard cutover

I didn't do a strangler migration with both frameworks sharing a page. The React app was built in parallel, in the same repository, with the Angular app parked in a legacy-angular/ folder — close enough to diff any screen side by side. Three things de-risked the eventual hard cutover:

  • main stayed Angular and deployable the whole time; all migration work lived on a long-running branch, with a child branch per phase.

  • The final move is a single cutover PR — not a slow bleed of half-migrated routes.

  • The last Angular commit gets an archive tag, so rollback is one revert away.

Plan first, code second

Before any code, I sat down with the agent and produced a migration plan — over nine hundred lines of it. It locked ten stack decisions up front (Vite, TanStack Router, Query, Table and Form, Zustand, Zod, Tailwind, and an API client generated from the OpenAPI schema), mapped the Angular codebase file by file to its React destination, and split the work into eleven phases: scaffold, foundation, layout, UI primitives, master data, a five-step registration wizard, two financial domains, integrations, dashboard, and hardening.

Every phase ends with one line that I consider the whole trick: an explicit exit criterion — a definition of done, written before the phase starts. Without it, an agent on a large codebase drifts: it decides adjacent code needs improving, or declares victory early. The exit criterion is the contract.

The protocol: the agent codes, I commit

Two rules made this migration reviewable instead of a leap of faith:

  1. The agent implements a phase and leaves the work staged. It never runs git commit. Committing is my checkpoint — if I can't explain a diff, it doesn't get committed.

  2. When a phase hits its exit criterion, a different agent — fresh context, a purpose-written reviewer definition — audits the staged diff against the plan and files findings by severity. A fresh reviewer has no attachment to the code and no memory of the reasoning that produced a bug, which is exactly what makes it useful.

Each phase also got a visual pass: driving the app with Playwright against a mocked API and comparing screens with the legacy app sitting right there in the same repo.

Where the AI was wrong

This workflow exists because the agent was wrong — several times, convincingly. The catches came from a different layer each time, which is the argument for having layers:

  • It guessed enum codes for a domain it couldn't see, and three of four tabs on one screen quietly queried the wrong resource. The reviewer agent caught it by checking the OpenAPI schema instead of trusting the code.

  • It inverted a backend enum (1 = no, 2 = yes) in a data mapper, so a record saved as active read back as inactive. A review had caught the read side earlier; the write side only fell to a mapper round-trip test. Tests catch what reviews miss.

  • It generated IDs from a module-level counter while the list persisted across refreshes — colliding IDs meant deleting one row could delete another.

  • It left TanStack Query's refetch-on-focus defaults on an editor screen, so alt-tabbing away and back silently wiped unsaved edits.

None of these reached the cutover. Each was caught by a different net: schema-grounded review, round-trip tests, or my own pass through the screens. That's the real lesson — not that the agent makes mistakes, but that no single safety net catches all of them.

Results so far

The numbers, kept honest: phases zero through ten — the entire port — took under three days, followed by three more days of polish that was mostly human taste: color tokens, animations, accessibility. The React app landed about 16% smaller than the Angular codebase at feature parity, mostly because generated API types and declarative data fetching deleted whole categories of boilerplate. Sixty-two tests run green against a mocked backend.

And the honest part: the cutover hasn't happened yet. There's no end-to-end run against the real backend, so I'm deliberately not claiming “identical to legacy” — the plan's last checkbox stays unchecked until it's true.

What I'd do differently

Generate types from the OpenAPI schema on day one. About half of the wrong-code incidents above trace back to the same root: hand-written interfaces — mine or the legacy app's — drifting from the actual backend contract. The generated schema should be the only source of truth from the first hour, and everything (the agent's code, the reviewer's audit, the tests) should be graded against it.

There will be a part two when the cutover lands. If it goes badly, that will be the more interesting post.