Migrating Legacy Systems to Next.js for Ultimate Speed
Migrating a legacy PHP application to Next.js without downtime is a routing problem before it is a rewriting problem. The approach that works is the strangler fig pattern: put a reverse proxy in front of both systems, move one route at a time, and let the legacy application keep serving everything you have not migrated yet. There is no cutover night. The hardest migration I have run this way was a Magento storefront that had to survive Black Friday while half-migrated — it held 99.99% uptime at 12,000 concurrent users precisely because traffic could be moved back to the legacy stack at any moment. What follows is the sequence I use, in order, along with the failure modes that cause teams to abandon migrations halfway through and go back to the system they were trying to escape.
Why big-bang rewrites fail
The instinct is to rebuild the whole thing on a branch and flip a switch. It almost never works, for a reason that has nothing to do with engineering skill: the old system keeps changing while you rebuild it. Every bug fix and every feature shipped to production during the rewrite is a change you have to replicate. On a six-month rewrite, you are chasing a moving target for six months, and the gap widens faster than you close it.
The second problem is that a big-bang cutover gives you exactly one chance to be right. You find out whether your session handling, your redirect map, and your checkout flow work at the same moment your entire user base finds out. There is no partial rollback — only a full revert, usually at 3am, usually with orders in flight.
The strangler fig pattern inverts both problems. You migrate a route, ship it, and it is done — permanently. The legacy system shrinks by one page and never grows that page back. And because both systems run simultaneously, rollback is a proxy configuration change rather than a redeploy.
What does the reverse proxy actually do?
The proxy is the whole mechanism, so it is worth being precise about it. You put nginx (or Vercel rewrites, or a CDN with path-based routing) in front of both applications. It owns the public domain. Requests matching migrated routes go to Next.js; everything else falls through to the legacy application.
The important property is that the proxy is the only thing that knows a migration is happening. Users see one domain. Search engines see one site with unchanged URLs. Your legacy application does not need to know Next.js exists, and your Next.js application does not need to know it is running alongside PHP. That isolation is what makes route-by-route migration safe.
Keep the route list in one place — an nginx location block or a rewrites array — and treat it as the migration's source of truth. When someone asks what is migrated, the answer should be a file, not a conversation.
Step 1: Inventory before you write any code
Document every route, every API endpoint, every cron job, every session dependency, and every third-party webhook target. This is tedious and it is the step teams skip. Missing one will bite you, and it will bite you at the worst possible time, because the things people forget are the things that run infrequently — the monthly invoice job, the payment provider's callback URL, the admin export that one person uses.
Pay particular attention to anything holding state outside the request cycle. Sessions stored in PHP's default file handler, in-process caches, scheduled jobs writing to local disk — each of these is an assumption that breaks the moment two systems serve the same users.
Also inventory your URLs as URLs, not as pages. A legacy system that has been live for years has accumulated redirects, trailing-slash variants, and old paths that still receive traffic and links. Those are ranking assets. Losing them during a migration is the most common way a technically successful migration turns into a traffic loss.
Step 2: Build a shared authentication layer
Both systems have to trust the same session, or users will be logged out every time they cross a boundary — which they will do constantly and invisibly.
There are two workable approaches. The first is JWTs signed with a shared secret, verified independently by both applications. The second is session cookies scoped to the parent domain, backed by a shared store such as Redis rather than either application's local session storage. Which you pick matters less than picking one before you migrate your first authenticated route.
Get this wrong and the symptom is maddening to debug: users report being randomly logged out, but you cannot reproduce it, because reproducing it requires crossing a specific boundary in a specific order. Build it first, and test it by logging in on one system and hitting a protected route on the other.
Step 3: Migrate the data layer before the UI
Move the data model and the API boundary before you touch a single component. A stable API contract lets the Next.js frontend evolve independently of the legacy database schema, and it means you can migrate the UI without simultaneously migrating persistence.
This ordering also front-loads the genuinely hard work. Schema changes, connection pooling, and query performance are where migrations actually get stuck. Discovering a connection-limit problem while you are also debugging a React hydration mismatch is significantly worse than discovering it on its own.
Connection handling deserves specific attention. A PHP application typically opens a connection per request and closes it; a Node.js application holds a pool. If you point both at the same database without accounting for that difference, you will exhaust connections under load. On one platform this meant putting PgBouncer in transaction pooling mode in front of Postgres, so that connection count tracked concurrent transactions rather than instance count. The point here is not the tuning itself but when you discover you need it: during the data-layer phase, with one system still serving traffic, rather than during cutover.
Step 4: How much traffic should you cut over at once?
Start at 5%. Watch error rates, not averages — a 1% error rate is invisible in a latency graph and catastrophic in a checkout flow. Then 25%, then 50%, then 100%, over weeks rather than hours.
The percentage matters less than the fact that you can move it in both directions. Each increase is a hypothesis: this route behaves correctly under this much real traffic. If error rates hold, you have evidence to increase. If they do not, you reduce the percentage and you have lost nothing.
This is also what makes migrating during a high-traffic period survivable. The Magento storefront I mentioned went into Black Friday partially migrated, which sounds reckless and was not, because the migrated routes could be returned to the legacy stack in the time it takes to reload a proxy configuration.
Instrument before you shift traffic, not after. You need per-route error rates and latency broken down by which backend served the request. Without that breakdown, an error rate increase tells you something is wrong but not which system caused it.
Step 5: Preserve every URL and every redirect
Every migrated route keeps its URL. If a URL genuinely must change, it gets a permanent redirect from the old path, and that redirect stays indefinitely.
Build the redirect map during the inventory step, not after launch. Test it as a list of assertions — old URL in, expected status and destination out — and run it in CI. This is the cheapest insurance in the entire migration, and the failure it prevents is the expensive kind: a technically flawless migration that quietly loses the search rankings the business depends on.
Check your canonical tags after each cutover as well. A page served from a new stack that self-references the wrong canonical is an easy mistake and a slow one to detect.
What performance actually improves, and why
The gains are real but they are worth understanding mechanically rather than treating as magic.
Time to first byte improves substantially — typically 60 to 80 percent on the migrations I have run — mostly because static and incrementally regenerated pages are served without executing application code per request. A PHP page that queries a database on every request cannot compete with a prerendered page served from cache, regardless of how well the PHP is written.
Server costs usually fall for the same reason. Requests served from cache do not consume application CPU, so the same traffic needs less compute.
Core Web Vitals improve, but less automatically than people expect. Next.js gives you image optimization, code splitting, and font handling as tools — it does not apply them for you. A careless Next.js build can post worse Largest Contentful Paint than the PHP page it replaced. The framework removes the excuses; it does not do the work.
The improvement that is hardest to measure and most valuable is development velocity. Shipping a feature stops involving negotiation with a decade of accumulated framework conventions.
The failure mode nobody warns you about
The most common way these migrations fail is not technical. It is that they stall at 60 percent, because the remaining 40 percent is the unglamorous part — the admin panels, the reporting exports, the one integration nobody understands — and the visible wins are already banked.
A half-migrated system is more expensive to run than either system alone. You are paying for two stacks, two deployment pipelines, and the cognitive overhead of knowing which is which. Decide up front whether the legacy system is being retired or kept permanently, write that decision down, and schedule the boring routes rather than leaving them to be picked up when someone has time. They never do.
Need help applying this to your project?
Book a free consultation →