Application migration
Next.js Migration & Rescue
Pages Router to App Router, another framework to Next.js, or an inherited codebase nobody wants to touch - migrated incrementally, in production, without a freeze.
Two situations bring most teams here. Either an existing Next.js application has outgrown the Pages Router and the incremental path is not obvious, or an application built by someone else has become something the current team is afraid to deploy.
Both are tractable. Neither requires a rewrite.
Pages Router to App Router
The two routers interoperate. That is the whole strategy: a shared application
where app/ and pages/ coexist, migrated leaf-first, with each route moving
behind its own pull request and its own verification.
We start with low-traffic, low-risk routes to build the shared patterns -
layouts, data access, error boundaries - then work up to the routes that
matter. Data fetching moves from getServerSideProps into the component tree.
Client state that only ever existed to compensate for the old rendering model
usually disappears entirely.
The order is deliberate rather than alphabetical. The first routes to move are the ones that teach the team the new patterns at the lowest cost of being wrong, and the last are the ones where being wrong is expensive. By the time a checkout or a dashboard moves, the conventions it needs have already been proven on something that does not take revenue.
What usually takes the longest is not routing. It is the shared code that
assumed the old model - a data hook that expected to run in the browser, a
context provider wrapping the whole application because one component three
levels down needed a value, an authentication check written for
getServerSideProps. Each of those has a smaller replacement in the new model,
and finding them is the actual work. The mechanics, in detail.
Another framework to Next.js
Create React App, Vite SPAs, Gatsby, and a long tail of bespoke webpack configurations. The mechanical part is routing and build configuration; the part that takes judgement is deciding which of the application's assumptions were framework artefacts and which were real requirements.
A single-page application moving to server rendering is the case with the most
surprises in it, because everything in an SPA can assume a browser. window
in a module body, a date formatted in local time, a client-side auth guard
that was the only thing preventing access to a page - all of them work
perfectly until the same code runs on a server. That migration has
its own page, because it is enough work
to be its own engagement.
Inherited codebases
Before we change anything, we read everything. Two weeks of orientation produces a dependency map, a rendering inventory of every route, a list of what is dead, and a ranked risk register. That document is yours regardless of what you decide next - it is the thing that makes the codebase governable again, and several clients have taken it in-house and done the work themselves.
We think that is a reasonable outcome.
The risk register is the part that changes conversations. "The codebase is a mess" is a feeling and cannot be scheduled against. "Four routes have no error boundary, the payment integration has no test, and two dependencies have known advisories and no upgrade path" is a list, and a list can be worked through in priority order by whoever is available. What that read produces.
What holds the migration together
URLs do not move. The migration is internal. Every address that ranked yesterday answers the same way tomorrow, and where one genuinely must change, it redirects permanently and the redirect is asserted in CI.
Every step is deployable. No long-lived migration branch. If the work stops for a quarter because your priorities changed, what is merged is coherent and what is left is a list rather than a conflict.
The old and the new do not both get to be right. Once a pattern is chosen it replaces the previous one on the routes that have moved, rather than accumulating beside it. A codebase carrying two conventions indefinitely is the state the migration was supposed to end.
Verification per route, not per release. Each moved route is checked for rendering mode, bundle size and behaviour before the next one starts.
When an audit is the better first step
If what you have is a suspicion rather than a decision - the application feels slow, or fragile, or expensive to change, and nobody can say exactly why - an audit answers that in two weeks and tells you whether migration is even the right response. Sometimes it is not: the router is fine and the problem is a rendering decision on four routes, which is a far smaller piece of work.
How it is scoped
The first deliverable is a map rather than a migration: which routes exist, what each one depends on, and which ones nobody can explain any more.
From it comes a phased scope with a route count and a price for each phase. The contract refers to that list, so the argument about what is in scope happens while it is still cheap. You can stop after any phase and keep a working application, and that property is what makes this survivable.
Then the phases, route by route, with both routers running until the last one moves.
What you receive
The application on the App Router, migrated route by route, each phase its own pull request against a route list agreed in advance. Nothing moves in a single change too large to review.
You also get the map made at the start: which routes exist, what each depends on, and which ones nobody has been able to explain. That document outlives the migration and is usually the first thing a new developer is handed.
