← back to the work

Case study / 04

Frontend Platform Modernization

Modernizing two production frontends without stopping product delivery.

Role
Planned and led the migration
Surface
Two production frontends, three shared libraries
Span
Next 12 → 15, React 17 → 18

Two production applications had remained on Next.js 12 and React 17 while the surrounding ecosystem moved forward. Updating them meant more than changing framework versions: both applications depended on shared internal libraries and tightly coupled UI, state and form ecosystems.

So I treated it as a dependency problem, not a version-number problem.

01Nothing was completely broken. That was part of the problem.

Both applications ran. Nobody was blocked on a given morning. But the framework versions were aging, React was several releases behind, newer platform capabilities were out of reach, and the packages around them were tightly version-coupled — so every month the next upgrade cost slightly more than it would have the month before.

Technical debt rarely arrives all at once.

02The app wasn’t the dependency boundary.

Both applications consumed the same three shared internal frontend libraries, and those libraries carried peer-dependency constraints tied to the older React ecosystem. Nothing downstream could move until they did. Coordinating that took 31 prerelease package iterations while the two migrations advanced against them.

The upgrade started one level below the app.

03One major version at a time.

The final state was four framework majors away, and it would have been quicker to attempt in one jump. Staging it meant each phase isolated a different class of compatibility failure — so when something broke, the search space was one version wide instead of four.

Smaller blast radius.

04Knowing where to stop.

React 19 was available, and including it would have made the migration look more impressive. Before committing, I mapped what it would actually touch.

That is not a framework upgrade. That is a product rewrite wearing one. So the migration was deliberately capped at React 18, and React 19 was left as a separate, scoped piece of work with its own case for doing it.

The goal wasn’t the newest stack.

The goal was the safest meaningful modernization.

05Prove the path once.

The first application became the reference implementation: every dependency failure, every workaround, every ordering constraint was discovered there. The second application then reused the same phased structure rather than rediscovering the same problems.

App A — reference

  • Discover every failure
  • Record the ordering
  • Pay the unknowns once

App B — ported

  • Reuse the phases
  • Skip the rediscovery

06The product kept moving.

None of this happened during a feature freeze. The migration ran alongside normal product delivery, and I shipped unrelated product work through the same window. A platform migration that halts the roadmap tends not to get a second one approved.

Platform work shouldn’t hold users hostage.

07Conventions, not just versions.

Alongside the migration I also pushed for clearer frontend conventions — feature-oriented folder structure, a tidier types organisation, consistent asset handling, and dependency hygiene — and helped teammates approach related package modernization the same way.

08What I can prove — and what I can’t.

Can prove

  • Next.js 12 → 15
  • React 17 → 18
  • TypeScript 4.5 → 5.3
  • Node 16 → 20
  • Turbopack enabled
  • Two production applications migrated
  • Shared-library compatibility work
  • Still live in production

Can’t honestly claim

  • Build time improvement
  • Bundle size reduction
  • CI duration improvement
  • Memory improvement

I have one post-migration production build at roughly 98 seconds. On its own that number proves nothing — there is no pre-migration equivalent to compare it against, so presenting it as an improvement would be inventing a result. I didn’t capture a baseline before starting.

Measure before optimizing.

09What I’d do differently

The hardest part of a framework migration isn’t installing the new framework. It’s understanding everything that assumes the old one. If I ran this again, there would be one step before Phase 0: record build output, bundle size and CI duration before changing the first dependency.

Modernize deliberately.

← back to the work