
Next.js Migrating from Create React App
Create React App has been officially archived, and if you're still running a CRA project, you've probably noticed the signs even before that announcement made it official: react-scripts hasn't meaningfully changed in years, its webpack config is stuck behind current tooling, and every "how do I do X in CRA" search increasingly ends with "you can't, easily." Meanwhile the React team itself has been steering people toward frameworks for anything beyond a toy project, and Next.js is the most common landing spot.
The good news is that migrating a CRA app to Next.js doesn't have to be a rewrite. You can get a working Next.js app running your existing CRA codebase, largely unchanged, in under an hour, and then adopt Next.js features incrementally from there instead of in one risky leap. This guide walks through exactly that path: turn your CRA app into a Next.js single-page application first, get it deployed and working, and only then start peeling off pieces to take advantage of server rendering, file-based routing, and everything else Next.js offers.
Why Bother Switching?
It's worth being honest about what CRA actually costs you before you spend an afternoon migrating away from it, because "the tool is unmaintained" isn't a good enough reason on its own if the app works fine.
Everything loads after the JavaScript does. CRA ships a pure client-side rendered app. The browser has to download your JS bundle, parse it, execute it, and only then can your app start firing off requests for data. On a fast connection with a small bundle this is barely noticeable. On a slow connection with a growing bundle — which is where most real apps end up after a year or two of feature additions — this becomes the dominant cost of your app's time-to-interactive.
Code splitting is manual and easy to get wrong. You can chip away at the bundle-size problem with React.lazy and dynamic imports, but doing this by hand tends to introduce exactly the kind of network waterfall you were trying to avoid: component A loads, which triggers loading component B, which triggers loading component C, each one waiting on the last. Next.js's router does this splitting automatically per route, and it's tied into the build pipeline rather than bolted on.
Data fetching waterfalls are structural, not just a bad habit. The typical CRA pattern — render a loading placeholder, fetch data in a useEffect once the component mounts, render again — means a child component can't start its own fetch until its parent has finished fetching and rendering. Nest that a few levels deep and you get a staircase of sequential round trips before the page is actually usable. Next.js lets you move data fetching to the server, where components can fetch in parallel rather than waiting on each other's client-side mount cycles.
Streaming gives you actual control over loading order. With Suspense-based streaming built into routing, you can decide which part of the page ships first and which parts stream in after, instead of the all-or-nothing "spinner until everything's ready" model CRA pushes you toward. Done well, this also avoids the layout shift that happens when a spinner is replaced by real content all at once.
You get to choose the rendering strategy per page. A marketing page can be statically generated at build time. A dashboard can render per-request. A product page can regenerate itself in the background on a schedule. CRA gives you exactly one option — render in the browser, always — and Next.js gives you all of them, decided independently per route.
Proxy replaces the client-side auth-guard dance. Next.js's Proxy convention runs on the server before a request completes, which means you can redirect an unauthenticated user to a login page before any client-side JavaScript has a chance to flash the protected content first. It's also the natural place to put A/B test bucketing or locale detection.
Images, fonts, and scripts get optimized without you doing the work. The <Image> component, next/font, and the <Script> component handle resizing, format negotiation, self-hosting fonts to kill layout shift, and controlling exactly when third-party scripts execute — all things you'd otherwise hand-roll or just not bother with in a CRA app.
None of this means CRA was a bad tool. It was, for years, the easiest way to get a React app running with zero config. But it made a specific bet — ship everything to the client and render there — and that bet doesn't hold up well against how much users' expectations around load performance have shifted since.
The Migration Strategy: SPA First, Then Incrementally Adopt
The temptation with any framework migration is to try to "do it properly" in one pass — convert every route to the App Router's file conventions, move every fetch to a Server Component, restructure your component tree around Server/Client boundaries, all at once. Resist this. It multiplies the surface area for something to break, and it means you have no working app to fall back to if something does.
The approach that actually works: get your existing CRA app running inside Next.js as a client-side-only single-page application first, with your existing router (React Router or whatever you're using) completely untouched. Once that's deployed and confirmed working, you incrementally peel off pieces — first images, then fonts, then maybe one route converted to real server rendering — each one a small, revertible change.
This means for a while you'll have a Next.js app that isn't really using any of Next.js's actual features yet. That's fine. It's a deliberate waypoint, not a failure to migrate properly.
Step 1: Install Next.js
Add Next.js to your existing project without removing anything yet:
npm install next@latest
# or, if you use a different package manager
yarn add next@latest
pnpm add next@latest
bun add next@latest
At this point your package.json has both react-scripts and next as dependencies. That's expected for now — you'll remove react-scripts in the cleanup step once everything's confirmed working.
Step 2: Add a Next.js Config File
Create next.config.ts at the project root, next to package.json:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export", // produces a static, client-rendered export
distDir: "build", // matches CRA's existing output directory name
};
export default nextConfig;
The output: 'export' option is doing a lot of work here, and it's worth understanding exactly what it means: it tells Next.js to produce a purely static export with no server. You will not have access to server-side rendering, Route Handlers, or anything that needs a running Node process — which is fine, because at this stage of the migration you're not using those anyway. You keep distDir: 'build' purely so your existing deploy pipeline, which probably expects a build/ folder, doesn't need to change on day one. You can rename this later or drop the static export requirement entirely once you start adopting server features.
Step 3: Create the Root Layout
Every Next.js App Router project needs a root layout file — a Server Component that wraps every page in your app. This is the closest thing Next.js has to your old public/index.html.
Create app/layout.tsx (put the app directory inside src/ if that's where your existing code lives, or at the project root — either works):
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My App</title>
<meta name="description" content="Migrated from Create React App" />
</head>
<body>
<div id="root">{children}</div>
</body>
</html>
);
}
Copy the actual contents of your old index.html <head> into this file, and replace whatever CRA had inside <body> (<div id="root"></div>, <noscript>, and so on) with <div id="root">{children}</div>. This is the one file where you're translating markup rather than writing something new.
A quick cleanup pass is worth doing here rather than later: Next.js already emits the charset and viewport meta tags automatically, so you can delete them from your layout. And every <link rel="icon">, <meta name="description">, and similar tag can eventually move into a typed metadata export instead of living in raw JSX:
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "My App",
description: "Migrated from Create React App",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<div id="root">{children}</div>
</body>
</html>
);
}
Doing this also means favicon and icon files placed directly in the app/ directory get picked up automatically — no manual <link> tag needed at all. This isn't strictly required to get the app running, but it's a five-minute change that removes a chunk of boilerplate you'd otherwise carry forward indefinitely.
Step 4: Bring Over Your Styles
If you have a global stylesheet, import it directly into the root layout:
// app/layout.tsx
import "../index.css";
CSS Modules work identically to how they did in CRA — no changes needed there. If your app uses Tailwind, you'll want to follow Next.js's own Tailwind setup rather than reusing your CRA PostCSS config verbatim, since the plugin wiring differs slightly between the two build systems.
Step 5: Create a Catch-All Entry Point
This is the step that trips people up conceptually, so it's worth slowing down on. CRA has one entry point: src/index.tsx mounts your <App /> component, and your client-side router (React Router, most likely) takes it from there, handling every path in the browser.
Next.js's App Router, by contrast, expects a folder-per-route structure — normally you'd have app/about/page.tsx, app/contact/page.tsx, and so on, each one a distinct route Next.js knows about at build time. You are explicitly not doing that yet. You want Next.js to hand off every single URL to your existing client-side router unchanged, which means you need one route that matches everything.
That's what an optional catch-all segment is for — a folder named [[...slug]] matches any path, including the root:
app/
├── [[...slug]]/
│ └── page.tsx
└── layout.tsx
// app/[[...slug]]/page.tsx
export function generateStaticParams() {
return [{ slug: [""] }];
}
export default function Page() {
return "..."; // replaced in the next step
}
generateStaticParams returning a single empty slug tells Next.js's static export to generate exactly one HTML file for this route rather than trying to enumerate every possible path your client-side router might handle — which it has no way of knowing about anyway, since that routing lives entirely in your existing app code.
Step 6: Mount Your Existing App as a Client Component
Your old <App /> component — the one that set up your router, your providers, all of it — gets wrapped in a Client Component and dynamically imported with server-side rendering explicitly turned off:
// app/[[...slug]]/client.tsx
"use client";
import dynamic from "next/dynamic";
const App = dynamic(() => import("../../App"), { ssr: false });
export function ClientOnly() {
return <App />;
}
// app/[[...slug]]/page.tsx
import { ClientOnly } from "./client";
export function generateStaticParams() {
return [{ slug: [""] }];
}
export default function Page() {
return <ClientOnly />;
}
Two things are doing real work in that client.tsx file. The 'use client' directive is what makes this a Client Component in the first place — without it, Next.js would try to run this as a Server Component, and your CRA app almost certainly depends on browser globals and effects that don't exist during server rendering. And ssr: false on the dynamic import goes a step further than just marking the component client-side: it tells Next.js not to even attempt rendering this component during the build's static generation pass, which matters because your old app likely reads from window, localStorage, or does other browser-only setup the moment it mounts.
It's worth understanding what you now have: a page that Next.js treats as fully static (because of output: 'export' and the single catch-all route), which loads and then hands control entirely to your old client-side app, router included. Functionally this isn't that different from what CRA was already doing — you've mostly just changed how the initial HTML shell gets built and served.
Step 7: Fix Static Image Imports
This is a real behavioral difference, not just config. In CRA, import image from './logo.png' gives you back a string — the resolved URL. In Next.js, the same import gives you back an object with src, width, and height properties, meant for direct use with the <Image> component.
If you're keeping plain <img> tags during this first pass (recommended — don't take on <Image>'s automatic sizing behavior in the same change where you're also swapping build tools), you just need to reach into the object:
// Before (CRA)
import logo from "./logo.png";
<img src={logo} />;
// After (Next.js)
import logo from "./logo.png";
<img src={logo.src} />;
Anything importing from /public with an absolute path needs to become a relative import instead:
// Before
import logo from "/logo.png";
// After
import logo from "../public/logo.png";
If you hit TypeScript errors on .src specifically, it usually means next-env.d.ts isn't listed in your tsconfig.json's include array yet — Next.js generates this file automatically the first time you run next dev, so if you haven't run it yet, that's likely the whole problem.
The docs are explicit that you don't need to migrate to the <Image> component in this pass at all — keeping <img> tags minimizes the blast radius of this migration. <Image>'s automatic optimization is a real win, but it's a separate, later change, and mixing it into your build-tool migration makes it much harder to tell which change broke what if something goes wrong.
Step 8: Rename Your Environment Variables
Swap every REACT_APP_ prefix for NEXT_PUBLIC_:
# Before
REACT_APP_API_URL=https://api.example.com
# After
NEXT_PUBLIC_API_URL=https://api.example.com
Same rule as CRA — only variables with this specific prefix get exposed to browser code, and everything else stays server-only (which, on a static export, effectively means build-time-only). Grep your codebase for process.env.REACT_APP_ and you'll find every place that needs updating; there's no way around doing this one mechanically.
Step 9: Update Your Scripts
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "npx serve@latest ./build"
}
}
Add .next and next-env.d.ts to .gitignore if they're not already covered by a wildcard. Then run npm run dev, open localhost:3000, and you should see your existing app, unchanged, now being served by Next.js.
If it doesn't come up cleanly, the most common culprits at this stage are a missed REACT_APP_ variable reference, a component that reads window or document outside of an effect (and therefore executes during the build's static generation pass instead of only in the browser), or a leftover absolute import path for a public asset.
Step 10: Delete What You No Longer Need
Once the app runs correctly under Next.js:
- Delete
public/index.html,src/index.tsx, andsrc/react-app-env.d.ts. - Remove any
reportWebVitalssetup (Next.js has its ownuseReportWebVitalshook if you want it back later). - Uninstall
react-scriptsfrompackage.json.
Don't do this cleanup before confirming the app runs — keeping the old entry points around costs nothing while you're still debugging, and having them available as a reference is genuinely useful if something in the new setup behaves unexpectedly.
Handling the Configuration You Haven't Thought About Yet
A few CRA features don't have a direct one-line Next.js equivalent and are easy to forget until something breaks in staging.
A custom homepage field. If your CRA package.json had a homepage value to serve the app under a subpath, replicate that with basePath in next.config.ts:
const nextConfig: NextConfig = {
basePath: "/my-subpath",
};
A custom service worker. If you registered one manually with CRA, the registration call itself barely changes:
await navigator.serviceWorker.register(
new URL("../serviceWorker.js", import.meta.url),
);
If you're building this out further, it's worth reading up on Next.js's approach to Progressive Web Apps rather than just porting the CRA setup verbatim — there are framework-specific patterns worth adopting once you're not just doing a like-for-like migration.
A dev-server API proxy. CRA's "proxy" field in package.json — commonly used to forward /api/* requests to a separate backend during development — becomes a rewrite:
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: "/api/:path*",
destination: "https://your-backend.com/:path*",
},
];
},
};
Custom webpack or Babel tweaks. These carry over reasonably well through Next.js's own webpack config function — but note that Next.js defaults to Turbopack for next dev now, not webpack, so a custom webpack config only takes effect if you also add --webpack to your dev script. If you had meaningful custom webpack configuration in CRA (a lot of teams didn't, but some did for things like SVG-as-component imports), plan on running with --webpack at least until you've ported that configuration over and verified it still does what you need.
What You Have Now, and What's Actually Left
At the end of this process you have a Next.js app that is, functionally, still a single-page application — your old router is still deciding what renders, your data is still fetched entirely client-side, and none of Next.js's server rendering or file-based routing is actually in play yet. That's the intended state, not a stopping point you got stuck at.
From here, the realistic next moves, roughly in the order most teams find least disruptive:
- Swap
<img>tags for<Image>one component at a time, since this is isolated and easy to verify visually. - Adopt
next/fontfor any web fonts you're loading, which removes a render-blocking network request and the layout shift that comes with it. - Move third-party scripts (analytics, chat widgets, tag managers) to the
<Script>component, so you control load timing explicitly instead of dropping a<script>tag in your HTML and hoping it doesn't block anything important. - Migrate off your client-side router to the App Router one section of the app at a time, not all at once — this is the biggest step and the one place where doing it incrementally really pays off, since it's also the point where you start getting real server rendering, streaming, and file-based routing instead of the SPA shell you migrated in with.
One thing worth flagging explicitly because it catches people off guard: as long as output: 'export' is set in your config, you don't have access to useParams in the way you might expect from server-rendered routes, or any feature that needs a running server process — Route Handlers, ISR, on-demand revalidation, none of it. That's not a bug, it's the tradeoff you signed up for by asking for a static export in step 2. When you're ready to actually use Next.js as a server-rendering framework rather than a fancier CRA replacement, removing that line — and the routing work that goes with it — is the real migration. Everything up to that point was just changing your build tool without changing your architecture, which is exactly why it's safe to do first.
Key Takeaways
| Stage | What changes | What stays the same |
|---|---|---|
| Install Next.js | New dependency, new config file | Your entire existing app |
| Root layout | index.html becomes layout.tsx | Head content, meta tags |
| Catch-all route | One route ([[...slug]]) handles everything | Your existing client-side router |
| Client entry | Old <App /> wrapped as a Client Component, ssr: false | App logic, state, providers |
| Images | .src needed on imports | Plain <img> tags (for now) |
| Env vars | REACT_APP_ → NEXT_PUBLIC_ | Values, usage sites |
| Cleanup | Remove CRA-only files and react-scripts | — |
Migrating off Create React App doesn't have to mean rewriting your app's architecture in the same breath as switching build tools. Get it running as a static, client-rendered Next.js app first — that alone gets you off an unmaintained toolchain and onto one with a real upgrade path — and treat everything else, server rendering included, as a series of small, separately-verifiable steps you take once the ground under you has stopped moving.


