Step-by-Step Guide to Migrate a React Project to Next.js Without Breaking Dependencies
Migrating an existing React application to Next.js is one of the most common migrations in frontend development. Companies do it for SEO, server-side rendering, better performance, and the developer experience of the App Router. But doing it wrong can break your entire build, leave you with incompatible dependencies, and cause weeks of lost productivity.
I have done this migration four times on production applications, ranging from a small SaaS dashboard to a content platform with over 200 components and 50+ npm dependencies. Every time I learned something new about what breaks and what does not. This guide covers the practical approach: migrating incrementally, not rewriting from scratch.
Before you start, understand that Next.js is not a replacement for React. It is a framework built on top of React. Everything that works in React should work in Next.js, but there are edge cases. The main areas where things break are routing, data fetching, images, and client-side-only libraries.
Phase 1: Pre-Migration Audit
Before writing any code, audit your existing project. Run npm ls --depth=0 and look for packages that depend on the browser window, document, or navigator objects. These will not work during server-side rendering. Common culprits: chart libraries (recharts, chart.js), animation libraries (GSAP, framer-motion older versions), and DOM manipulation utilities.
Next, review your routing. If you are using React Router, document every route and its corresponding component. Create a route map that shows the nesting structure. This map will translate directly to the App Router folder structure later. Also check your data fetching patterns. If you use useEffect with fetch calls, those will need to move to Server Components or use the App Router's loading patterns.
Finally, check your build configuration. If you use custom webpack plugins, CRACO, or react-app-rewired, those will not work with Next.js out of the box. You will need to find Next.js equivalents or use next.config.js plugins.
Phase 2: Initialize Next.js in the Same Repository
Do not create a new repository. Stay in the same repo to preserve git history and make incremental migration possible. Run the following inside your existing project: npm install next@latest react@latest react-dom@latest. You might need to upgrade React to version 18 or 19 depending on the Next.js version you target. Next.js 15 requires React 19.
Create a next.config.js file at the root. Start minimal: just module.exports = {}. You can add configuration options as you encounter issues. Then update your package.json scripts. Add "dev": "next dev", "build": "next build", and "start": "next start". Keep your old start script as a fallback so you can switch back if something breaks.
Create the app directory with a simple layout.js and page.js. The layout should render your existing root component. The page should render your existing home page component. At this point, Next.js and your old React app coexist. You run next dev to test the Next.js version, and npm start to run the old version.
Phase 3: Migrate Routing Incrementally
This is where most migrations fail because people try to move all routes at once. Instead, move one route at a time. Create the folder structure in the app directory that mirrors one route. For example, if you have /dashboard/settings, create app/dashboard/settings/page.jsx.
Copy the component from your React Router route into the new page file. Do not worry about data fetching yet. Replace React Router hooks like useParams with Next.js params prop. Replace useNavigate with the router from next/navigation. Replace Link from react-router-dom with next/link.
Test that single route thoroughly before moving to the next. Keep both routing systems working simultaneously. The App Router and React Router can coexist during migration. Your old routes still work through React Router, and new routes work through the App Router. This is the safest approach.
Phase 4: Migrate Data Fetching
React applications typically fetch data in useEffect hooks. In Next.js, you have better options. Start by identifying which pages can be Server Components. If a page does not need client-side interactivity, make it a Server Component and fetch data directly inside the component using async/await. This is simpler and faster than useEffect patterns.
For pages that need client-side interactivity, keep the data fetching in useEffect but move it to a Client Component with the "use client" directive. Alternatively, use the App Router's loading.js and error.js files to handle loading and error states gracefully. The key insight: you do not have to migrate all data fetching at once. Start with the simplest pages, learn the patterns, then tackle complex pages.
Phase 5: Handle Images and Assets
Next.js requires its own Image component for optimized images. If your app uses tags everywhere, you have two options. Option one: replace them all with next/image at once using a codemod. Option two: configure next.config.js with the images.remotePatterns setting to allow external images, and replace
tags gradually. I recommend option two for less disruption.
For static assets like SVGs, PDFs, and fonts, move them to the public folder. Update any import paths that reference src/assets or similar. In Next.js, files in public are served at the root URL, so an image at public/images/logo.png becomes /images/logo.png in your code.
Phase 6: Fix Common Breaking Issues
You will encounter errors. "window is not defined" means a component runs during SSR but references browser APIs. Fix: wrap the component in dynamic(() => import('./Component'), { ssr: false }). "module not found: Can't resolve 'fs'" means a Node.js module is being bundled for the browser. Fix: check your imports and make sure server-only modules are not imported in client components.
CSS-in-JS libraries may need special configuration. For styled-components, add the styledComponents flag in next.config.js. For Emotion, you might need a custom plugin. Check the library documentation for Next.js-specific setup. Tailwind CSS works out of the box with Next.js.
Environment variables: Next.js only exposes variables prefixed with NEXT_PUBLIC_ to the browser. Rename your REACT_APP_ variables to NEXT_PUBLIC_. Your server-side environment variables do not need the prefix because they are never sent to the client.
Phase 7: Test and Verify
After migration, run your test suite. Unit tests should work unchanged because they test components in isolation. Integration tests may fail if they depend on React Router context. Update those tests to use Next.js navigation mocks. E2E tests with Cypress or Playwright should work if your URLs have not changed.
Check your build output. Run next build and look at the size of each page. The App Router automatically code-splits by route, so each page should have its own JavaScript bundle. If a page bundle is larger than expected, check for large imports that are not tree-shaken.
Finally, deploy to a staging environment and test every route manually. Check that server-rendered pages show content immediately when you view the page source. That is the whole point of migrating to Next.js.