Legacy React to Next.js App Router: An Incremental Migration Without Breaking Production

#React to Next.js migration
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

React to Next.js migration is one of the most requested — and most misunderstood — modernisation moves in the current SaaS landscape. Teams look at App Router's server components, built-in caching, and layout nesting, and they want it. What they underestimate is the distance between wanting it and having a production codebase that actually uses it.

This guide is for teams running a real Create React App (CRA) or Pages Router project in production. Not a greenfield. Not a demo. A codebase with users, an authentication layer, shared state, and a deploy pipeline that cannot go dark for three sprints while someone rebuilds everything from scratch.

The good news: you do not have to rewrite. The bad news: incremental migration requires discipline that "just rewrite it" does not.

Why Incremental Is the Only Realistic Path

Full rewrites feel clean on paper. In practice, they create a moving-target problem: by the time the rewrite is done, the original codebase has shipped six weeks of product changes that need to be ported forward. Teams end up running two codebases in parallel, burning engineering time on synchronisation instead of product, and eventually the rewrite either ships half-finished or gets abandoned.

Incremental migration keeps the original app serving traffic on every route you have not touched yet. You migrate one route or feature cluster at a time, validate it in production, then move on. The result is a slower headline number but a far lower risk of the catastrophic mid-migration rollback.

The catch is that "incremental" requires a genuine seam between the old and the new. That seam is what most teams skip planning for.

Step One: Audit Your Current Architecture Before Writing a Line

Before you touch Next.js, spend a day answering these questions about your existing app:

Routing. How many top-level routes do you have? Are any of them dynamically generated? Does your router hold state (query params, history manipulation)?

Authentication. Where does auth live? Client-side only (localStorage, in-memory)? Server-side sessions? A third-party provider like Auth0 or Clerk? The answer shapes everything about how you share auth state between old and new code.

Data fetching. Are you using React Query, SWR, Redux Thunk, or raw useEffect? These patterns have different counterparts in App Router's server components.

Shared UI. Do you have a component library or design system? Can those components run in a server context, or do they rely on window, document, or browser APIs?

This audit is not busywork. It produces a migration map that tells you which routes are safe to move first (low coupling, simple data fetching) and which ones will take real architecture work (complex auth flows, heavy client state).

Step Two: The Adapter Layer — Running Both Routers at Once

The most reliable pattern for migrating a CRA or Pages Router app to App Router is to run them side by side using Next.js's own incremental adoption support.

If you are starting from CRA, the first move is to get your existing app running inside a Next.js shell at all, without changing any of the application logic. This typically means:

  • Setting up a Next.js project alongside (or wrapping) your CRA app
  • Using a catch-all route ([[...slug]].tsx in Pages Router, or a layout + catch-all in App Router) to forward all unrecognised paths to the legacy SPA shell
  • Gradually carving off routes and rebuilding them as proper Next.js pages

If you are already on Pages Router, you have an easier starting point. Next.js supports running Pages Router and App Router pages in the same project simultaneously. Routes under app/ use App Router; routes under pages/ still use Pages Router. This is officially supported and used in production by large teams at Vercel and elsewhere.

The key discipline: resist the urge to migrate everything at once just because the door is open.

Step Three: Shared Authentication State

Authentication is where incremental migrations fall apart most often. The failure mode is that the new App Router pages cannot read the session from the old auth layer, so the user hits a logged-in page and gets bounced to the login screen.

The solution depends on your auth provider:

Cookies-based sessions (NextAuth, custom server sessions). These work naturally across both routers because the session cookie is read server-side. NextAuth v5 (Auth.js) has explicit support for App Router and its auth() helper works in both Server Components and route handlers.

Client-side auth (Auth0 SDK, Clerk, custom JWT in localStorage). You need a provider wrapper. Wrap your App Router layouts in the appropriate context provider (Clerk's <ClerkProvider>, Auth0's <Auth0Provider>) just as you would in a CRA app. The complication is that Server Components cannot read client context — so any server-fetched data that needs the user identity must read from the cookie or session, not from client state.

Custom in-memory auth. This is the hardest case. The auth lives nowhere durable. You will need to migrate auth to a cookie or server-session model before the App Router migration makes sense. Do that first, as a separate piece of work.

The rule of thumb: if your auth state is durable (cookie, server session, JWT in a cookie), migration is straightforward. If it lives purely in client memory or localStorage, fix that boundary before anything else.

Step Four: Route-by-Route, Starting With the Low-Risk End

With the adapter layer and auth boundary resolved, you can start migrating routes. A good ordering:

  1. Static or near-static pages first. About pages, pricing pages, marketing landing pages. These have minimal state, no auth dependency, and the App Router's static generation makes them straightforwardly faster.

  2. Authenticated data-display pages next. Dashboard views that fetch and display data but do not have complex mutation flows. These are the best showcase for Server Components — you fetch data server-side, stream it to the client, and avoid the loading-spinner-on-every-navigation pattern from SWR/React Query.

  3. Forms and mutation flows last. Any page with a form that touches your API in complex ways should come last. Server Actions are powerful but they have different error handling semantics than a useMutation hook, and you want to be confident in the pattern before applying it to critical user journeys.

For each route, the migration checklist is:

  • Move file from pages/ to app/ with a layout
  • Replace getServerSideProps or getStaticProps with async Server Component data fetching
  • Identify any component that uses useState, useEffect, or browser APIs — add 'use client' directive
  • Verify auth reads correctly from the server session
  • Run the route through your E2E test suite (or write one before migrating if you do not have one)

Hydration Mismatches: The Silent Migration Killer

The most common reason teams revert a migrated route is a hydration mismatch error. This happens when the HTML rendered on the server does not match what React produces on the client during hydration.

Common causes during migration:

Date and time rendering. If you render a timestamp and format it using the user's locale on the client, the server will produce a different string than the browser. Use a stable format (ISO or UTC) for SSR and apply locale formatting after mount.

Browser-only libraries. Any library that checks typeof window !== 'undefined' internally is telling you it is not safe to run in a server context. Wrap these in a dynamic import with ssr: false.

Conditional rendering based on auth state. If you show different UI based on whether the user is logged in, and you derive that from client state on the original app but from a server session on the new one, you will get a mismatch. Commit to one source of truth for auth on each migrated route before you go live.

Hydration errors are not catastrophic — React will fall back to client rendering — but they generate console noise, break streaming, and are a signal that something in your rendering assumptions is wrong. Treat them as blockers during migration, not annoyances to clean up later.

When You Actually Do Need to Stop and Refactor

The incremental path assumes your component code is portable. Some codebases have structural problems that block migration regardless of how careful you are:

  • Deeply coupled client state. If your global Redux store is the source of truth for data that should live server-side, you will fight every Server Component boundary. Plan a state architecture refactor alongside the migration.
  • Monolithic page components. If your pages are 1,500-line components with everything mixed together, splitting them into Server Component shells with Client Component leaves is painful. Break the component structure first.
  • No test coverage on critical paths. Migrating a route you cannot verify is a risk transfer, not a modernisation. Write integration or E2E tests for each route before you move it.

Wolf-Tech's legacy code optimisation service frequently starts with exactly this kind of structural audit — identifying which parts of a codebase are movable as-is and which need pre-work before a migration makes sense. The same thinking applies here.

The Post-Migration Cleanup You Will Be Glad You Did

Once all routes are on App Router, do not close the project yet. The adapter layer and catch-all routes you added during migration should come out. Dead pages/ directory files should be deleted. The _app.tsx and _document.tsx wrappers from Pages Router have no role in a pure App Router project.

More importantly, now is the time to revisit your data fetching strategy with App Router semantics in mind. Patterns that made sense under Pages Router (aggressive client-side caching with React Query because getServerSideProps was expensive) look different when you have server components that can fetch close to the data source. Not everything needs to change, but the mental model of "fetch on the server, stream to the client, only hydrate what needs interactivity" is worth applying consistently.

Getting It Right Without Getting Stuck

The teams that stall mid-migration share a pattern: they started without a clear seam between old and new, ran into an auth or hydration problem on route five, and did not have the structural knowledge to diagnose it quickly. The migration branch stayed open for three months, accumulated conflicts, and was eventually abandoned or merged in a broken state.

If your team is planning a React to Next.js migration and wants a second opinion on the architecture before you start — or if you are already stuck mid-migration — Wolf-Tech works with exactly these situations. Our web application development and code quality consulting services both touch this space regularly.

The migration is worth doing. App Router genuinely improves performance, simplifies data fetching, and sets your frontend up for where React is heading. The path just needs more planning than the framework documentation lets on.

Reach out at hello@wolf-tech.io if you want to talk through your specific situation before committing to an approach.