
Next.js Migrating
At some point, almost every React codebase outgrows the tool it started with. A Create React App project hits a wall when someone asks for server-rendered meta tags. A Vite single-page app needs a public marketing page that has to rank on Google. A Next.js project still on the Pages Router wants React Server Components, and the only way to get them is to move to the App Router. "Migrating" is the umbrella the Next.js docs use for all three of these situations, and it's worth treating as its own topic rather than jumping straight to whichever specific guide matches your stack, because the decision of whether to migrate at all, and how aggressively, matters more than the mechanical steps.
This article is the orientation piece: what migrating to Next.js actually buys you, how to decide if it's worth the disruption, what's genuinely common across every migration path regardless of where you're coming from, and a comparative map of the three official routes so you know which deep-dive guide to read next.
What "Migrating" Covers, and What It Doesn't
The Next.js docs group three specific journeys under this heading:
- Pages Router → App Router — you're already on Next.js, and you're moving to the newer routing paradigm within the same framework.
- Create React App → Next.js — you're on a plain client-rendered React app with no built-in routing or server rendering, and you're adopting Next.js as a full framework.
- Vite (React) → Next.js — you're on a fast, modern build tool for a React SPA, and you're adopting Next.js primarily for routing, rendering, and full-stack conventions Vite doesn't provide out of the box.
Notice that the first one is fundamentally different in kind from the other two. Migrating Pages to App Router doesn't change your framework, your deployment target, your build tool, or your hosting provider — it changes your routing conventions and your rendering model inside Next.js. Migrating from CRA or Vite changes all of that at once. Keep this distinction in mind, because it changes how much risk you're taking on and how incrementally you can move.
This piece won't repeat the file-by-file instructions from those three guides. Instead, it answers the questions that come before you open any of them.
Is Migrating Actually Worth It?
Nobody migrates a working application for fun. Before touching a single file, it's worth being explicit about what's driving the decision, because "we should migrate to Next.js" is often stated as an unquestioned goal when it's really a means to a handful of specific ends:
You need content indexed by search engines. Client-only React apps built with CRA or Vite render an empty <div id="root"> until JavaScript executes. Crawlers that don't run JavaScript — and even some that do, inconsistently — see nothing. If organic search traffic matters for any part of your app, this alone is usually sufficient justification.
You need fast first paint on real-world devices. A large CRA bundle downloading, parsing, and executing before anything appears on screen is a bad experience on a mid-range phone over a mediocre connection. Server rendering (or static generation) sends usable HTML immediately; JavaScript hydrates on top of it.
You want file-based routing instead of hand-rolled configuration. React Router, Vite's various routing plugins, and CRA's total absence of routing all require you to maintain route definitions by hand. Next.js infers routes from your folder structure, which scales better as an app grows past a dozen pages.
You want backend and frontend in one deployable unit. Route Handlers let you write API endpoints in the same project, same language, same deploy step as your UI. If you're currently maintaining a separate Express server or serverless functions project just to serve your SPA's data, collapsing that into one Next.js app is a real simplification.
You're already on Next.js and want React Server Components. If this is your situation, you're not weighing "should we adopt a new framework" — you're weighing "should we adopt the App Router's rendering model," which is a much narrower and generally easier yes.
What's not a good reason to migrate: "Next.js is what everyone uses now." Framework popularity is a signal worth noticing, but it's not a requirements document. If your CRA app is an internal admin tool that nobody outside your company will ever load, SEO is irrelevant, and the client-rendering cold-start cost is a non-issue behind a login wall. Migrating that app buys you very little and costs real engineering time. Reserve migrations for apps where the benefits above map to something you actually need.
What's Common to Every Migration, Regardless of Source
Whether you're moving from Pages, CRA, or Vite, the same handful of structural changes recur. Understanding these once, in the abstract, makes each specific guide faster to follow because you're filling in details rather than learning concepts from scratch.
1. Routing Moves From Code to the Filesystem
If you're coming from React Router or a Vite routing plugin, your routes currently live in a configuration object or a tree of <Route> components:
// Before: React Router config
const router = createBrowserRouter([
{ path: "/", element: <Home /> },
{ path: "/blog/:slug", element: <BlogPost /> },
{ path: "/dashboard", element: <Dashboard /> },
]);
In Next.js, that same structure is expressed as folders and page.tsx files:
app/
├── page.tsx → /
├── blog/
│ └── [slug]/
│ └── page.tsx → /blog/:slug
└── dashboard/
└── page.tsx → /dashboard
There's no router configuration file to keep in sync with your actual pages, because the folder structure is the configuration. The tradeoff is that route structure now has physical consequences — renaming a folder renames a route — which is a bigger deal in a large, established codebase than in a fresh one.
2. Environment Variables Change Their Prefix and Access Pattern
Every build tool has its own convention for exposing environment variables to client-side code, and none of them match:
# Vite
VITE_API_URL=https://api.example.com
# Create React App
REACT_APP_API_URL=https://api.example.com
# Next.js
NEXT_PUBLIC_API_URL=https://api.example.com
And the access pattern changes too — Vite uses import.meta.env.VITE_API_URL, CRA uses process.env.REACT_APP_API_URL, and Next.js also uses process.env.NEXT_PUBLIC_API_URL but only inlines it at build time for variables carrying that specific prefix. Any variable without NEXT_PUBLIC_ stays server-only automatically, which is a security improvement over CRA's model (where forgetting the prefix just means the variable silently doesn't exist on the client) but does mean auditing every environment variable your app currently uses and deciding, deliberately, whether it belongs on the client at all.
3. Static Assets and Images Get a New Home and a New Component
CRA serves anything in public/ at the root path, and Vite does the same with a few additional conventions for imported assets. Next.js keeps the public/ convention for static files, but introduces next/image as the default way to render images:
// Before
<img src="/logo.png" alt="Logo" width={200} height={50} />;
// After
import Image from "next/image";
<Image src="/logo.png" alt="Logo" width={200} height={50} />;
You don't have to switch every <img> to <Image> on day one — plain <img> tags still work fine in Next.js — but leaving them unconverted means you're not getting automatic format conversion, resizing, or lazy loading, which is a meaningful chunk of what "migrate to Next.js" is usually for in the first place.
4. CSS Import Paths and Global Stylesheets Need a New Entry Point
CRA and Vite both let you import a global stylesheet from anywhere, often src/index.tsx. Next.js requires global CSS to be imported from the root layout (App Router) or _app.tsx (Pages Router), and it will throw a build error if you try to import global CSS from a nested component. CSS Modules (*.module.css) work the same way across all three tools, so those files typically need no changes at all.
5. The Build Output and Deployment Story Changes Completely
CRA's npm run build produces a folder of static files you can drop on any static host. Vite does the same. Next.js's build produces a hybrid output — some routes prerendered to static HTML, some rendered on demand by a Node.js server, some running on an edge runtime — and that output generally needs a Next.js-aware host or your own Node.js server to run correctly. If your team's muscle memory is "build, then upload the dist/ folder to S3," this is the step most likely to cause a deployment-day surprise, and it's worth testing your production build against your actual hosting target well before the migration is "done."
Deciding How Incrementally to Move
The single biggest lever you have for reducing migration risk is choosing how incrementally you move, and this is where the three paths genuinely diverge.
Pages → App Router is designed for incremental adoption by default. Both routers can coexist in the same Next.js project — the App Router takes priority for any route it defines, and everything else falls through to the Pages Router. This means you can migrate one route at a time, ship each one independently, and run both routers in production simultaneously for as long as you need to. There is essentially no reason to attempt this migration as a single big-bang rewrite.
CRA and Vite → Next.js are less naturally incremental because you're changing frameworks, not just routing conventions within one. That said, both official guides describe a pattern where you keep your existing React Router setup temporarily and let Next.js handle only a subset of routes at first, using rewrites in next.config.js to send unmigrated paths back to the old app (often running as a separate deployment behind a proxy) while new or converted pages are served by Next.js directly. This is more setup work than a from-scratch migration, but it means you can ship incrementally instead of maintaining a long-lived feature-frozen branch while a full rewrite catches up to main.
// next.config.js — example of proxying unmigrated routes during an incremental migration
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: "/legacy/:path*",
destination: "https://old-app.example.com/legacy/:path*",
},
];
},
};
module.exports = nextConfig;
Whichever path you're on, resist the temptation to "improve things while you're in there." A migration that also refactors your state management, redesigns your component API, and upgrades six unrelated dependencies is a migration where, if something breaks in production, you have no idea which of the four simultaneous changes caused it. Migrate first, refactor after, as two separate, separately-reviewable efforts.
A Comparative Summary of the Three Paths
| Migration | What actually changes | Typical effort | Can you do it incrementally? |
|---|---|---|---|
| Pages Router → App Router | Routing conventions, rendering model (Server/Client Components), data fetching APIs | Low to medium — mechanical for simple pages, more involved wherever getServerSideProps/getStaticProps logic is complex | Yes, natively — both routers run side by side in one project |
| Create React App → Next.js | Build tooling, routing (introduced from nothing), rendering model (introduced from nothing), deployment target | High — you're adopting concepts (SSR, file-based routing) that didn't exist in your app before | Partially — via a temporary proxy/rewrite setup pointing unmigrated routes at the old CRA build |
| Vite (React) → Next.js | Build tooling, routing (if using a router plugin), rendering model, environment variable conventions | Medium — dev experience and component code usually transfer with few changes; routing and env vars need deliberate conversion | Partially — same rewrite-based approach as CRA, somewhat easier since Vite projects are often already closer to Next.js's mental model |
If you're migrating from Pages to App Router, read that specific guide next — it's the lowest-risk of the three and the one where "just start converting routes one at a time" is genuinely the right advice with no caveats.
If you're coming from CRA or Vite, read whichever guide matches your current tool, and go in expecting the migration to touch routing, environment variables, and your deployment pipeline no matter how careful you are — those three are unavoidable regardless of how well-organized your existing codebase is.
Common Mistakes Across All Three Migrations
Migrating everything before testing anything. Convert one route, one page, one component tree — deploy it, verify it in production, and only then move to the next. A migration that's 100% complete but untested until the very end has no working checkpoint to roll back to if something's wrong.
Forgetting that NODE_ENV and bundler-specific globals behave differently. Code that checks process.env.NODE_ENV === 'development' generally still works, but code relying on Vite-specific globals like import.meta.hot for HMR logic, or CRA-specific process.env.PUBLIC_URL, will silently stop working and needs to be found and replaced deliberately — these failures are rarely caught by TypeScript, since the types often still resolve.
Treating the App Router migration as optional if you're on Pages. It's true that Next.js will continue to support the Pages Router, but new features — Server Actions, Cache Components, most of the newer caching and metadata APIs — are App Router-first or App Router-only. Staying on Pages indefinitely means slowly falling out of step with where the framework's investment is going.
Underestimating the deployment step. Every team that has migrated a static CRA or Vite app to Next.js has, at some point, been surprised that "just upload the build output to our static host" no longer works. Validate your actual deployment target — not just next build succeeding locally — as early in the migration as you validate the code itself.
Key Takeaways
- "Migrating" in the Next.js docs covers three distinct journeys: Pages Router → App Router (same framework, different router), Create React App → Next.js, and Vite → Next.js (both: adopting a new framework).
- Migrate for a specific, needed outcome — SEO, first-paint performance, unified routing, or a combined frontend/backend — not because Next.js is popular.
- Routing conventions, environment variable prefixes, image handling, global CSS entry points, and deployment output are the five changes common to every migration path.
- The Pages → App Router migration supports running both routers side by side natively; CRA and Vite migrations can achieve similar incrementality using
next.config.jsrewrites to proxy unmigrated routes to the old app temporarily. - Migrate first, refactor later — bundling unrelated improvements into a framework migration makes failures much harder to isolate.


