ReactNext.jsNode.jsTypeScriptPerformanceMigration

Full-Stack Modernization — From Legacy PHP to Modern React

Migrated a decade-old jQuery/PHP monolith to Next.js and Node.js, cutting page load times by 80% and taking Lighthouse from 28 to 94 over 14 weeks of incremental migration.


0→94

Lighthouse Score

0%

Faster Load Times

0.0s→0.8s

LCP Improvement

0

Deployment Downtime

The Problem

The codebase was ten years old. Not ten years of careful stewardship — ten years of patches on patches, jQuery plugins bolted onto a PHP monolith, and a deployment process that involved FTP-ing files to a shared hosting server. The team had stopped touching anything they didn't absolutely have to.

Lighthouse scored it at 28. The LCP was 4.2 seconds. Time to First Byte was 1.8 seconds — before a single byte of HTML had rendered. On mobile, the site was effectively unusable. Google's Core Web Vitals assessment: "Poor" across every metric.

The business impact was measurable. Bounce rates were climbing. Organic search rankings had dropped steadily for 18 months. The engineering team was spending 60% of their sprint capacity on bug fixes caused by the tangled jQuery event handlers and global PHP state. New features took three times longer than they should because nobody could predict what touching one part of the system would break in another.

The ask was a full rewrite. My answer was: not yet.

Migration Strategy

A big-bang rewrite is a bet that you can rebuild everything correctly before the business runs out of patience. That bet fails more often than it succeeds. The strangler fig pattern is slower but survivable.

The plan: migrate route by route, starting with the highest-traffic pages. New pages built in Next.js. Legacy pages proxied through Next.js rewrites so users see a single domain throughout. The old PHP backend stays alive until every route is migrated, then gets decommissioned.

// next.config.js — proxy legacy routes during migration
async rewrites() {
  return {
    fallback: [
      {
        source: '/:path*',
        destination: 'http://legacy-php.internal/:path*',
      },
    ],
  };
},

This meant users never saw a broken experience. The new Next.js app handled the routes it owned; everything else fell through to the PHP backend. From the outside, it looked like a single coherent site. From the inside, we were replacing the engine while the car was moving.

The migration order was driven by data, not intuition. Google Analytics showed the top 20 pages by traffic accounted for 78% of all sessions. We migrated those first, in order of traffic volume. The long tail of rarely-visited pages came last.

Architecture

The before state: a single PHP application handling routing, templating, business logic, and database queries. jQuery for interactivity. MySQL accessed directly from PHP with hand-written SQL. Deployments via FTP, manually, with no rollback mechanism.

The after state: Next.js frontend on Vercel, Node.js API layer on Railway, PostgreSQL managed by Supabase. GitHub Actions for CI/CD. Every push to main triggers a build, runs tests, and deploys automatically. Rollback is a single click.

Lighthouse Performance Score

2894+236%

Largest Contentful Paint

4.2s0.8s-81%

Time to First Byte

1.8s0.3s-83%

The API layer was the critical architectural decision. Rather than having Next.js talk directly to the database, I introduced a Node.js service that owns the data access layer. This separation meant the frontend could be deployed independently, the API could be scaled independently, and the database schema could evolve without touching frontend code.

The API contract was defined in TypeScript first, before any implementation. Zod schemas for every request and response shape. This gave us type safety end-to-end and made the migration from PHP predictable — we knew exactly what data shape each page needed before we wrote a line of the new implementation.

Key Technical Decisions

Why Incremental Over Rewrite

The business had a hard constraint: the site couldn't go dark. Revenue depended on it. A full rewrite would have required a feature freeze for 8-10 weeks while the new system caught up to the old one. The incremental approach let the team ship new features on the new stack while the migration was still in progress.

There's also a knowledge argument. The PHP codebase had accumulated a decade of business logic, some of it undocumented. A rewrite forces you to rediscover that logic by reading old code and talking to people who remember why decisions were made. The incremental approach let us migrate that logic piece by piece, with tests, rather than trying to reconstruct it all at once.

Database Migration Strategy

Zero downtime was non-negotiable. The approach: dual-write during the transition period.

The new Node.js API wrote to both the old MySQL database and the new PostgreSQL database simultaneously. Reads came from MySQL until we had confidence in the PostgreSQL data, then we flipped the read source. Once reads were stable on PostgreSQL for two weeks, we stopped the dual-write and decommissioned MySQL.

This added complexity — the dual-write layer had to handle failures gracefully, and we needed reconciliation scripts to catch any divergence. But it meant zero data loss and zero downtime. The business never noticed the database change.

Testing Strategy During Migration

The legacy codebase had no tests. Starting a migration with no test coverage is terrifying — you don't know what you're breaking until users tell you.

The approach: write integration tests against the legacy PHP endpoints first, capturing the expected behavior. Then use those same tests against the new Next.js routes. If the new implementation passes the same tests as the old one, you've preserved the behavior.

This isn't perfect — the tests only cover what you thought to test. But it's dramatically better than nothing, and it gave the team confidence to move fast.

CI/CD Pipeline

The old deployment process was: SSH into the server, pull from git, restart PHP-FPM, pray. No staging environment. No automated tests. No rollback.

The new process: push to a feature branch, GitHub Actions runs TypeScript checks and integration tests, Vercel creates a preview deployment with a unique URL. The team reviews the preview. Merge to main triggers a production deployment. If something goes wrong, Vercel's instant rollback reverts to the previous deployment in under 30 seconds.

The psychological impact of this change was as significant as the technical one. The team stopped being afraid to deploy.

Results

Core Web Vitals — Before vs After

Every Core Web Vitals metric moved from "Poor" to "Good" by Google's thresholds. LCP dropped from 4.2 seconds to 0.8 seconds. FID (now INP) dropped from 280ms to 45ms. CLS went from 0.42 to 0.04. TTFB dropped from 1.8 seconds to 290ms.

Lighthouse Performance Score — 14-Week Migration

The Lighthouse score progression tells the story of the migration. The first four weeks were infrastructure work — CI/CD, the API layer, the database migration scaffolding. The score barely moved. Weeks 5 through 9 were the high-traffic page migrations, and the score climbed steadily as the heaviest pages moved to the new stack. The final weeks were polish: image optimization, font loading strategy, eliminating render-blocking resources.

The business results followed the technical ones. Organic search rankings recovered within six weeks of the migration completing. Bounce rate dropped 23%. The engineering team's bug-fix overhead dropped from 60% of sprint capacity to under 15%.

What Worked

The strangler fig approach. Migrating route by route, with the legacy system as a fallback, meant we could move at whatever pace the business could absorb. Some weeks we migrated three routes. Some weeks we migrated none because a product deadline took priority. The migration was always in progress but never blocking.

TypeScript from the start. Defining the API contracts in TypeScript before implementing them caught dozens of mismatches between what the frontend expected and what the backend provided. The compiler found bugs that would have been production incidents.

Dual-write for the database. It added complexity, but it made the database migration invisible to users. Zero downtime, zero data loss. Worth every hour of extra engineering.

Automated preview deployments. Vercel's preview URLs meant every pull request had a live environment to review. The team caught visual regressions before they hit production. This alone changed how the team worked.

What I'd Reconsider

The API layer timing. I introduced the Node.js API service at the start of the migration, before we had a clear picture of all the data access patterns. We ended up refactoring the API contract twice as we discovered edge cases in the legacy PHP logic. Starting with a thinner API layer and expanding it as we migrated each route would have been cleaner.

MySQL dual-write duration. We ran dual-write for six weeks. Four would have been enough. The reconciliation overhead wasn't worth the extra two weeks of safety margin. Once the data was stable for two weeks, we should have cut over.

Not writing the legacy tests first. We started migrating the first two routes before we had integration tests against the legacy system. We caught a behavioral difference in production — nothing catastrophic, but embarrassing. After that, writing legacy tests before migration became a hard rule. It should have been the rule from day one.


Stack: Next.js · Node.js · TypeScript · PostgreSQL · Vercel · Docker · GitHub Actions