How I Migrated a Production App from CRA to Next.js
An incremental migration strategy that cut load times by 60% and pushed Lighthouse scores from 60 to 90+
In late 2023, I led the migration of a production SaaS dashboard from Create React App (CRA) to Next.js 14 at Hubspire. The app served thousands of daily active users and had grown to over 200 components across 50+ routes. What started as a routine dependency upgrade turned into a full architectural rethink when we realized CRA was holding us back in ways that patches couldn't fix.
This post covers why we migrated, the incremental strategy we used to avoid a risky rewrite, the technical challenges we hit along the way, and the measurable results we shipped. If you're maintaining a CRA app and wondering whether the migration is worth it, this is the breakdown I wish I'd had before we started.
CRA served us well in the early days. It got the app off the ground fast, and the zero-config setup meant we could focus on features instead of tooling. But as the app scaled, three problems became impossible to ignore.
Bundle Size Had Gotten Out of Control
Our main bundle had ballooned to 2.4 MB gzipped. CRA's Webpack config doesn't ship with granular code splitting out of the box, and our attempts to bolt on React.lazy and Suspense only solved part of the problem. Every route still loaded a massive shared chunk because CRA's default chunking strategy wasn't designed for apps of this size. First Contentful Paint was sitting at 4.2 seconds on a median connection.
SEO Was Non-Existent
The dashboard itself didn't need SEO, but the marketing pages, docs, and public-facing reports that lived in the same app did. CRA renders everything client-side, which meant Google was indexing empty shells. We'd hacked around this with react-helmet and a prerendering service, but it was fragile and added operational overhead we didn't want to maintain.
Developer Experience Was Deteriorating
CRA's Webpack 4 config (later 5) meant cold starts took 45+ seconds, and HMR often required a full page reload to pick up changes. The lack of built-in API routes forced us to maintain a separate Express server for BFF endpoints. Every workaround added complexity, and onboarding new developers meant explaining a maze of custom scripts and patches layered on top of CRA's ejected config.
A big-bang rewrite was off the table. The app was in active development with features shipping weekly, and we couldn't afford to freeze the codebase for months. Instead, we adopted an incremental migration strategy that let us ship Next.js pages alongside the existing CRA app.
Phase 1: Parallel Setup
We initialized a Next.js 14 project in a /next directory within the monorepo. Shared components, hooks, and utilities were extracted into a /packages/shared directory that both the CRA app and Next.js app could import from. This meant zero duplication from day one.
project/
packages/
shared/ # Components, hooks, utils
components/
hooks/
utils/
apps/
legacy-cra/ # Existing CRA app
next-app/ # New Next.js app
package.json # Workspace root
We configured a reverse proxy (Nginx in staging, Cloudflare Workers in production) to route traffic between the two apps based on URL patterns. New pages went to Next.js; everything else stayed on CRA. Users never noticed the transition.
Phase 2: Route-by-Route Migration
We migrated routes in order of impact: public-facing pages first (marketing, docs, reports), then high-traffic dashboard pages, then everything else. For each route, the process was:
- Create the Next.js page using the App Router with appropriate data fetching (
generateStaticParamsfor static content, server components for dynamic data) - Move the route's components from
legacy-cra/topackages/shared/if they weren't already shared - Add the
"use client"directive to components that needed browser APIs or React state - Update the proxy config to route the URL to Next.js
- Verify in staging, then ship
Phase 3: Data Fetching Overhaul
CRA had everything running through client-side useEffect + fetch calls, wrapped in React Query for caching. Moving to Next.js meant we could shift data fetching to the server where it belonged.
For pages that needed fresh data on every request, we used server components that fetch directly in the component body. For pages with data that changes infrequently, we used static generation with on-demand revalidation via revalidatePath. The React Query layer was kept for client-side mutations and optimistic updates, but the initial page load no longer depended on it.
// Before: Client-side fetching in CRA
function DashboardPage() {
const { data, isLoading } = useQuery({
queryKey: ['dashboard'],
queryFn: () => fetch('/api/dashboard').then(r => r.json())
});
if (isLoading) return <Skeleton />;
return <Dashboard data={data} />;
}
// After: Server component in Next.js
async function DashboardPage() {
const data = await getDashboardData();
return <Dashboard data={data} />;
}
Phase 4: Handling Client-Side Routing
The trickiest part was maintaining client-side navigation during the transition period. CRA used React Router v6, and Next.js uses its own file-based router. For cross-app navigation (clicking a link in a Next.js page that pointed to a route still on CRA), we fell back to full page navigations using standard <a> tags instead of Next.js <Link>. As more routes moved to Next.js, these cross-app jumps became less frequent until they disappeared entirely.
The migration completed over 10 weeks with zero downtime and no user-facing regressions. Here are the numbers that mattered:
Performance Breakdown
First Contentful Paint dropped from 4.2s to 1.6s. The main bundle went from 2.4 MB to 720 KB gzipped, thanks to Next.js's automatic code splitting per route and tree shaking that actually works. Server-side rendering eliminated the blank-screen flash that users saw on every page load with CRA.
Lighthouse performance scores went from a consistent 58-62 range to 90+ across all routes. The public-facing pages that needed SEO started appearing in Google search results within two weeks of migration, with proper meta tags, structured data, and server-rendered HTML that crawlers could actually parse.
Developer Experience
Cold start time dropped from 45+ seconds to under 3 seconds with Turbopack. HMR became instant and reliable. The API routes feature eliminated our separate Express BFF server, reducing the deployment surface from two services to one. New developers went from "productive in a week" to "productive on day one" because the Next.js conventions replaced our custom tooling maze.
Incremental beats big-bang, every time
The proxy-based approach let us ship continuously throughout the migration. We never had a "migration branch" that drifted from main. Every migrated route was deployed to production independently, validated with real traffic, and rolled back once when we caught a data fetching edge case. A rewrite would have meant weeks of integration pain at the end.
Extract shared code first
Moving components and utilities into a shared package before starting the migration was the single highest-leverage decision. It meant each route migration was purely about data fetching and page-level concerns, not about porting component code. If your components are tightly coupled to CRA-specific patterns (like importing images via Webpack loaders), decouple those first.
Server components change how you think about data
The biggest mental shift wasn't the routing or the file structure. It was moving from "fetch data on the client, show a spinner" to "fetch data on the server, send HTML." This sounds simple, but it changes component boundaries, error handling patterns, and loading state design. We refactored our component library to have server and client variants of data-dependent components, which was unexpected work but paid off in both performance and code clarity.
Measure before, during, and after
We set up Real User Monitoring (RUM) with Web Vitals tracking before starting the migration. This gave us a concrete baseline and let us catch performance regressions on individual routes as they were migrated. Without this data, we'd have been guessing about impact. The numbers in this post come from production RUM data, not synthetic benchmarks.
The best migration is the one your users never notice. Ship incrementally, measure everything, and let the data tell you when you're done.