Migrating a 4-Year CRA Codebase to Next.js

5 min read·Feb 19, 2026

CRA was being deprecated. We had to move to Next.js. The problem: a direct migration broke immediately on a 4-year, 4,000-file codebase. This is how I closed the gap incrementally - until the actual migration commit touched just 18 files.


Why a Direct Migration Failed

The first attempt at a straight swap surfaced three categories of breakage at once:

  • Environment variables - CRA uses REACT_APP_ prefixes; Next.js requires NEXT_PUBLIC_ for client-exposed variables. Every env reference across the codebase pointed at variables that no longer existed.
  • SSR conflicts - Four years of code assumed a browser environment. localStorage, window, and other browser-only APIs were called directly in places that Next.js would now try to render on the server, where none of those globals exist.
  • Build tooling differences - Static asset handling, global CSS imports, and a Mapbox worker that depended on CRA's specific transpilation behavior all broke under Next.js's bundler.

Each of these alone would be a manageable fix. All three at once, across 4,000 files, made the direct migration too risky to ship. Any of these failures in production meant a broken page for one of 14,000 active clients.

So instead of a big-bang rewrite, I broke it into incremental steps - each one making the codebase look a little more like Next.js, without changing what users saw.


Step 1: Patch the Environment Variable Convention

Rather than touching every file that referenced process.env.REACT_APP_*, I used patch-package to patch react-scripts itself, changing the env variable prefix it recognized from REACT_APP_ to NEXT_PUBLIC_. This meant the codebase could be migrated to the Next.js naming convention while still running on CRA - decoupling the env variable rename from the actual framework switch. By the time the real migration happened, every environment variable already matched what Next.js expected.


Step 2: Force Full Client-Side Rendering

The bigger risk was SSR. Next.js renders on the server by default, and a codebase full of direct window and localStorage calls would crash on the server the moment those components rendered. Auditing and refactoring every one of those call sites across 4 years of code wasn't realistic on a safe timeline.

So instead of fixing every call site, I disabled SSR entirely and kept the app behaving like the CRA SPA it already was:

  • Used next/dynamic with { ssr: false } to prevent server-side rendering of client-dependent components
  • Combined this with dynamic imports at the root of the app, so nothing tried to render before the browser environment existed
  • Used the rewrites feature in next.config.js to redirect all non-/api requests to a single root entry point, effectively making Next.js serve the app the same way CRA's static server did

This meant giving up Next.js's SSR benefits temporarily - but it meant zero refactoring of four years of window/localStorage usage, and zero risk of server-rendering crashes. We kept React Router for client-side navigation on top of this, which had a side benefit: instant client-side route transitions with no server roundtrip, something most default Next.js apps don't get out of the box.


Step 3: Fix the Mapbox Worker

One of the more obscure breakages was a Mapbox web worker that relied on transpilation behavior specific to CRA's webpack configuration. Next.js's bundler transpiled the same worker code differently, and Mapbox silently failed to initialize.

The actual fix involved tracing back through CRA-specific hacks that had accumulated around the Mapbox integration over the years - workarounds for issues that no longer existed once the bundler changed. Removing those CRA-specific patches and importing the relevant files directly from the Mapbox package (rather than through the patched path) resolved it. It was the kind of fix that took disproportionately long to find relative to how small the final change was - a reminder that bundler-specific hacks have a shelf life, and you find out exactly when that shelf life ends.


Step 4: Migrate Routing to a Codegen Approach

The custom webpack loader that powered filesystem routing in CRA couldn't carry over as-is - Next.js has its own routing system, and eventually we wanted Turbopack, which doesn't support custom webpack loaders at all. I migrated the routing convention to a codegen approach inspired by Next.js's own file conventions, so the page structure and config pattern stayed familiar to the team even as the underlying mechanism changed.


Step 5: Clean Up Static Assets and Global CSS

CRA and Next.js handle static assets differently - public file serving paths, image imports, and global CSS imports that worked under CRA's webpack config needed to be restructured to match Next.js's conventions. This was mechanical but tedious work: auditing every global CSS import and either scoping it or moving it into the structure Next.js expects.


The Actual Migration Commit

By the time all five steps were done, the codebase was functionally already running like a Next.js app - it just hadn't switched frameworks yet. The actual migration commit - swapping react-scripts for next and flipping the build pipeline - touched 18 files across the 4,000-file, 200-page, 13-app codebase: +878 / -4841 lines.

That diff size is the entire point of doing it incrementally. A direct migration would have touched thousands of files at once, with no realistic way to review or roll back. Because almost everything had already been migrated piece by piece under the old framework, the final cutover was small enough to review in a single sitting and roll back with one git revert if anything went wrong.


Results

  • Final migration commit: 18 files changed across a 4,000-file codebase
  • Zero breaking changes in production
  • Instant client-side navigation preserved from the CRA era
  • Full rollback possible with a single commit revert
  • Total migration spread over a few months of incremental, low-risk steps instead of one high-risk rewrite

The lesson that generalizes: when a framework migration looks too risky to do directly, the fix usually isn't more caution on the big commit - it's finding the smaller commits that make the big one boring.


This post is part of a series on scaling a frontend platform to 18 apps. Read the full overview.