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

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

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Migrating from Create React App (CRA) or the Next.js Pages Router to the App Router is one of those projects that looks straightforward on Monday and humbling by Friday. The official Next.js docs cover the happy path. This article covers the real path — the one with legacy auth middleware, shared state between old and new routes, hydration errors that only appear in production, and business stakeholders who will not tolerate downtime.

After doing this migration pattern on multiple codebases, here is what actually works.

Why Incremental Migration Is the Only Viable Option

A "big bang" rewrite — stopping feature work, migrating everything at once, deploying — almost never succeeds for codebases of meaningful size. The reasons are predictable: edge cases multiply faster than you can resolve them, regression coverage is never as good as you think, and the business pressure to ship features does not pause for your migration timeline.

The incremental approach means running your old routing alongside the new App Router, migrating route by route, and deploying continuously throughout. You get the App Router benefits in new and recently-touched pages immediately, while the legacy routes keep working untouched.

This is how Next.js itself recommends migrating from the Pages Router. For teams coming from CRA, there is an extra step: you first need to bring the application inside a Next.js host at all.

Step 1: CRA Users — Get Into Next.js First

If you are starting from Create React App (or Vite with a React SPA setup), your first task is not the App Router. It is getting your application running inside Next.js at all, as a single-page app, before migrating any routing.

Create a minimal Next.js project alongside your existing code, or replace the CRA build config while keeping all your components untouched. The goal of this step is a Next.js app/ directory with a single catch-all route that renders your existing CRA app:

// app/[[...slug]]/page.tsx
'use client'

import App from '../../src/App'  // your existing CRA root

export default function LegacyApp() {
  return <App />
}

This sounds too simple to be true, but it works. Your React Router routes will still function client-side. You are just swapping out the build infrastructure. Run your full test suite against this before touching anything else.

Common blockers at this stage:

  • Environment variables. CRA uses REACT_APP_*; Next.js uses NEXT_PUBLIC_* for client-accessible variables. You will need to update references, or create a compatibility shim in next.config.js.
  • Absolute imports. CRA sets up src/ as a root. Next.js uses tsconfig.json path aliases — add a paths entry pointing @/* or src/* to the right place.
  • Public assets. CRA's public/ folder maps directly; Next.js handles this similarly, but check that your index.html meta tags and custom fonts are replaced by the Next.js <head> equivalent.

Once your CRA app runs under Next.js — same behavior, just different dev server — you are ready to start the actual migration.

Step 2: Run Pages Router and App Router in Parallel

Next.js 13+ supports running the Pages Router (pages/) and App Router (app/) simultaneously. This is the backbone of incremental migration.

The rule is simple: if a route exists in app/, it takes precedence. If it does not exist in app/, Next.js falls back to pages/. You can migrate a single page at a time without touching anything else.

Set up your project structure like this:

/app
  layout.tsx          ← new root layout
  dashboard/
    page.tsx          ← migrated route
/pages
  _app.tsx            ← legacy app wrapper
  _document.tsx       ← legacy document
  settings.tsx        ← not yet migrated
  profile.tsx         ← not yet migrated

The app/layout.tsx file is now your root for migrated routes. Everything in pages/ keeps working with the old _app.tsx wrapper.

Critical: avoid shared global CSS conflicts. The App Router uses layout.tsx imports for CSS; the Pages Router uses _app.tsx imports. If you import a global stylesheet in both, it will load twice in some states. Audit your CSS imports carefully and consider moving shared styles to a package or a dedicated file imported by both wrappers explicitly.

Step 3: Shared Authentication State

Auth is the hardest part of incremental migration and the most common reason teams roll back.

If you are using a client-side auth library like Auth0, Clerk, or a custom JWT-in-localStorage approach, the challenge is that your App Router pages and your Pages Router pages need to share auth state seamlessly — users must not be asked to log in again when navigating between an old and a new route.

For token-based auth (JWT in httpOnly cookie): This is the easiest case. The cookie is available to all routes regardless of which router handles the page. Your middleware or server components can read it directly. Migrate to this pattern before starting the App Router migration if you are not already using httpOnly cookies.

For client-side auth providers (Context-based): You need the same provider wrapping both the app/layout.tsx and the pages/_app.tsx. Create a shared AuthProvider component that lives outside both router directories and import it from both:

// components/providers/AuthProvider.tsx
'use client'
export function AuthProvider({ children }: { children: React.ReactNode }) {
  // your auth logic here
  return <AuthContext.Provider value={...}>{children}</AuthContext.Provider>
}

Then in app/layout.tsx:

import { AuthProvider } from '@/components/providers/AuthProvider'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <AuthProvider>{children}</AuthProvider>
      </body>
    </html>
  )
}

And in pages/_app.tsx:

import { AuthProvider } from '../components/providers/AuthProvider'

export default function App({ Component, pageProps }) {
  return (
    <AuthProvider>
      <Component {...pageProps} />
    </AuthProvider>
  )
}

Both routers now share the same auth component. The provider initializes from the cookie or token on mount, so state is consistent across navigation.

If you need help designing an auth architecture that survives this migration, Wolf-Tech offers architecture reviews specifically for these kinds of cross-cutting concerns.

Step 4: Migrate Route by Route

With the parallel setup in place, start migrating individual routes. A good order:

  1. Static or near-static pages first (marketing pages, docs, legal). These have minimal state and data requirements, so they validate your setup with low risk.
  2. Data-fetching pages next. Replacing getServerSideProps or useEffect data fetching with Server Components and fetch() is the most visible benefit of the App Router. Take it route by route.
  3. Complex interactive pages last. Dashboards, form-heavy flows, and pages with significant client state should be migrated last, after you have confidence in your approach.

When migrating a route, move or recreate the file from pages/ into app/. Decide which parts are Server Components (default) and which need 'use client'. A common heuristic: if the component uses useState, useEffect, event handlers, or browser APIs, it needs 'use client'. Everything else can be a Server Component and will benefit from it.

Step 5: Avoid the Hydration Mismatch Traps

Hydration errors — where the server-rendered HTML does not match what React produces on the client — are the most common production-only failure mode in App Router migrations.

The most frequent causes:

Browser-only code in Server Components. If you reference window, document, or localStorage in a component without 'use client', you will get a hydration error in production (not always in development, which is why it surprises teams). Move this code into a useEffect or into a client component.

Date and locale rendering. new Date().toLocaleDateString() produces different output on the server (UTC, typically) and the client (user's locale). Use explicit locale arguments: date.toLocaleDateString('en-GB', { timeZone: 'UTC' }).

Conditional rendering based on typeof window. The classic SPA pattern:

// ❌ Causes hydration mismatch
const isClient = typeof window !== 'undefined'
return isClient ? <ClientOnlyThing /> : null

Replace this with dynamic imports with ssr: false:

import dynamic from 'next/dynamic'
const ClientOnlyThing = dynamic(() => import('./ClientOnlyThing'), { ssr: false })

Third-party libraries that assume a browser environment. Many older React libraries — charting libraries, rich text editors, drag-and-drop kits — assume window exists on import. Wrap these in dynamic() with ssr: false or move them fully into 'use client' components with lazy loading.

Step 6: Clean Up the Pages Router

Once all routes are live in app/, the Pages Router files become dead code. Remove pages/ incrementally as routes are confirmed stable in production — ideally with a feature-flag or canary deployment for each batch.

Do not rush this step. The temptation is to delete pages/ the moment the last route is migrated, but it is worth keeping the old files around for one or two deployment cycles as a rollback safety net. Once you have confidence in the App Router routes under real traffic, delete the corresponding pages/ files.

After pages/ is empty (except for API routes if you are keeping them), you can remove the Pages Router configuration from next.config.js and delete pages/_app.tsx and pages/_document.tsx.

What Forces Teams to Roll Back

Based on migrations that went sideways, these are the failure modes to watch:

Skipping the auth audit. Teams that do not explicitly verify auth state sharing end up with users who appear logged in on old routes and logged out on new ones, or vice versa. This is a production incident, not a dev environment bug.

Migrating too fast. Doing five or ten routes in a single PR makes rollback nearly impossible if something goes wrong. One or two routes per PR, with QA between, is the right pace.

Ignoring middleware compatibility. Next.js middleware (middleware.ts) runs on all routes but behaves differently between the two routers in some edge cases — particularly around redirects and cookie access. Test your middleware explicitly against both old and new routes.

Underestimating third-party library work. Some component libraries have App Router-specific versions or configurations. React Query, for example, requires a specific provider setup for App Router. Check each major dependency's App Router documentation before migrating pages that depend on it.

The Real Timeline

A realistic migration timeline for a mid-size SaaS (30–60 routes, one development team):

  • Week 1–2: CRA → Next.js host setup (if applicable), Pages Router parallel setup, auth provider extraction, dev environment validated
  • Week 3–6: Route-by-route migration, static and data-fetching pages, 2–3 routes per week
  • Week 7–10: Complex interactive routes, third-party library migrations, regression testing
  • Week 11–12: Pages Router cleanup, final QA, documentation

This is a four-quarter-sprint project, not a single sprint. Planning it as anything shorter is where timelines collapse.

Is This Worth Doing?

The App Router benefits are real: server-side rendering without the getServerSideProps boilerplate, parallel data fetching with Suspense, nested layouts without prop drilling, and significantly better performance for data-heavy pages. For teams building content-heavy or data-rich applications, the improvements are measurable.

For teams with a stable, working Pages Router codebase and no immediate pain points, the migration is worth planning but not rushing. The incremental path described here means you can start capturing benefits on new routes immediately while leaving stable routes untouched.

If you are dealing with a CRA codebase that has grown into a maintenance problem — slow builds, no SSR, dependency conflicts — the migration pays off faster and the urgency is higher.

Getting Help

Legacy-to-modern migrations are a significant part of what Wolf-Tech does, and the React/Next.js path specifically is one we know well. If your team is planning this migration and wants an expert to review your approach before you start — or to unstick a migration that has stalled — reach out at hello@wolf-tech.io or visit wolf-tech.io to book a free consultation.

The goal is to get you to the App Router without a production incident. With the right incremental plan, that is entirely achievable.