Wonsuk Choi
blog

How to Migrate React to Next.js in 2026: CRA, Vite & Custom Webpack Guide

Step-by-step migration for CRA, Vite, and custom webpack: routing, SSR, data fetching, images, and the gotchas most guides skip.

What Changes When You Migrate React to Next.js#

React is a library. Next.js is a framework built on top of React that adds routing, server rendering, image optimization, and a metadata API. When you migrate, your component code stays almost entirely intact. What changes is the outer shell:

  • File-based routing replaces React Router
  • Server Components replace client-side useEffect data fetching
  • next/image replaces <img> tags
  • generateMetadata replaces react-helmet
  • API routes optionally replace a separate Express/Node backend

The migration is a restructure, not a rewrite.

Before You Start: Migration Audit Checklist#

Map these out before writing a single line of code. The surprises you find in audit are much cheaper than the ones found mid-migration.

Routing:

  • List every React Router route (v5 vs v6 matters — they have different APIs)
  • Identify nested routes and shared layouts
  • Note any programmatic navigation (useNavigate, history.push)

Data fetching:

  • List every useEffect that fetches data
  • Note any Redux Thunk / React Query / SWR patterns
  • Identify which calls hit authenticated endpoints

Configuration:

  • Note CRA customizations (CRACO, react-app-rewired, custom .env keys)
  • Note Vite config (aliases, plugins, import.meta.env usage)
  • Note any webpack plugins that have no Next.js equivalent

Auth:

  • Is auth via localStorage tokens or httpOnly cookies?
  • Where does protected route logic live?

Third-party packages:

  • Any packages that use window or document at import time — these crash in Server Components
  • Browser-only SDKs (analytics, Intercom, chat widgets, etc.)

How to Migrate a Create React App (CRA) Project to Next.js#

CRA is the most common starting point. Most of the migration is the same as any React-to-Next.js move, but CRA has specific issues to handle first.

CRA-specific changes#

Replace react-scripts commands. CRA's npm start / npm run build become next dev / next build. Update package.json:

"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start"
}

Rename REACT_APP_ env variables to NEXT_PUBLIC_. CRA uses REACT_APP_FOO; Next.js uses NEXT_PUBLIC_FOO. Rename every variable in your .env files and every reference in the codebase:

# Before (CRA)
REACT_APP_API_URL=https://api.example.com

# After (Next.js)
NEXT_PUBLIC_API_URL=https://api.example.com

PUBLIC_URL is not needed. CRA's PUBLIC_URL variable becomes unnecessary — Next.js serves static assets from the public/ folder automatically with no path prefix required.

Delete react-app-env.d.ts. It's CRA-specific and will cause TypeScript issues if left in.

Translate CRACO / react-app-rewired configs to next.config.ts. Most use cases (aliased imports, SVG handling, custom Babel plugins) have direct Next.js equivalents. Don't copy the config over — identify what each rule was doing and find the Next.js idiomatic way.

Set up import aliases in tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./*"]
    }
  }
}

After handling these, continue to the shared steps below.


How to Migrate a Vite React App to Next.js#

Vite projects are usually cleaner than CRA, but have their own migration points.

Vite-specific changes#

Replace import.meta.env with process.env. Vite uses import.meta.env.VITE_FOO; Next.js uses process.env.NEXT_PUBLIC_FOO. Rename variables and update every usage:

// Before (Vite)
const apiUrl = import.meta.env.VITE_API_URL

// After (Next.js)
const apiUrl = process.env.NEXT_PUBLIC_API_URL

Move Vite aliases to tsconfig.json paths. If you used resolve.alias in vite.config.ts, move them to tsconfig.json — Next.js reads path aliases from there automatically:

// vite.config.ts (what you had)
resolve: { alias: { '@': path.resolve(__dirname, 'src') } }

// tsconfig.json (what you need instead)
// "paths": { "@/*": ["./src/*"] }

Replace Vite-specific plugins:

  • vite-plugin-svgr → use @svgr/webpack via next.config.ts, or inline SVGs as React components
  • vite-plugin-pwanext-pwa
  • @vitejs/plugin-react → not needed; Next.js includes React support out of the box

After handling these, continue to the shared steps below.


How to Migrate a Custom Webpack React App to Next.js#

This covers ejected CRA, hand-rolled webpack setups, or projects using older tooling.

Custom webpack configs rarely transfer 1:1. Identify what each rule was doing and find the Next.js equivalent:

Webpack customizationNext.js equivalent
SVG as React component@svgr/webpack in next.config.ts
CSS ModulesBuilt-in — file must be named *.module.css
Sass/SCSSInstall sass, works automatically
Lessnext-with-less plugin
Custom Babel pluginsbabel.config.js at project root
Module path aliasestsconfig.json paths
Environment-specific builds.env.local, .env.production, etc.
Bundle analyzer@next/bundle-analyzer

Don't try to port the entire webpack config. Go feature by feature — what is each rule accomplishing, and what's the idiomatic Next.js way to accomplish it?

After handling these, continue to the shared steps below.


Shared Migration Steps (All React Apps)#

These steps apply regardless of whether you're coming from CRA, Vite, or a custom setup.

Step 1: Create the Next.js project#

Start fresh — don't try to convert in-place. Create a new project alongside the existing one:

npx create-next-app@latest my-app-nextjs --typescript --app

Copy your components/, hooks/, lib/, and utils/ directories into the new project. Component code is nearly all reusable.

Step 2: Migrate routing#

Next.js uses a file-based router. Create a page.tsx for every route:

app/
  page.tsx               → /
  about/page.tsx         → /about
  blog/[slug]/page.tsx   → /blog/:slug
  dashboard/layout.tsx   → shared layout for /dashboard/*

Every React Router <Route> maps to a file. It's mechanical work — the most time-consuming part for large apps.

Programmatic navigation: Replace useNavigate() (React Router) with useRouter() from next/navigation inside a 'use client' component.

Step 3: Move data fetching to Server Components#

This is the biggest conceptual shift. Instead of useEffect + client-side API calls, you fetch data directly in async Server Components:

// Before: client-side fetch (React SPA)
function BlogPage() {
  const [posts, setPosts] = useState([])
  useEffect(() => {
    fetch('/api/posts').then(r => r.json()).then(setPosts)
  }, [])
  return <PostList posts={posts} />
}

// After: Server Component (Next.js)
export default async function BlogPage() {
  const posts = await db.query('SELECT * FROM posts WHERE published = true')
  return <PostList posts={posts} />
}

Result: no loading spinners on first render, server-rendered HTML for crawlers, no extra browser round-trip on page load.

Step 4: Replace react-helmet with generateMetadata#

// Before: react-helmet (client-rendered — crawlers often miss it)
<Helmet>
  <title>My Page</title>
  <meta name="description" content="..." />
</Helmet>

// After: generateMetadata (server-rendered — crawlers see it immediately)
export async function generateMetadata({ params }): Promise<Metadata> {
  return {
    title: 'My Page',
    description: '...',
    openGraph: { title: 'My Page', description: '...' },
  }
}

Step 5: Handle browser-only code#

Components that use localStorage, window events, or browser SDKs must be marked as Client Components:

'use client'

export function ThemeToggle() {
  const [dark, setDark] = useState(false)
  // safe to use browser APIs here
}

For third-party packages that use window at import time, wrap with dynamic:

const BrowserOnlyChart = dynamic(() => import('./Chart'), { ssr: false })

Step 6: Replace <img> with next/image#

// Before
<img src="/hero.jpg" alt="Hero" />

// After
import Image from 'next/image'
<Image src="/hero.jpg" alt="Hero" width={1200} height={630} priority />

next/image gives you automatic WebP conversion, lazy loading, and responsive sizing. Add priority to above-the-fold images to avoid LCP issues.

Step 7: Test, then cut over#

Run both apps simultaneously and check for:

  • Hydration mismatch warnings in the browser console
  • Missing or misnamed environment variables
  • Auth flows that relied on immediate client-side state
  • Any package that crashes with window is not defined

Deploy to Coolify, Vercel, Railway, or any Node-capable VPS. The first production deploy usually surfaces the last few edge cases.


React to Next.js Migration Checklist#

Setup

  • Created fresh create-next-app project
  • Copied components/, hooks/, lib/, utils/ directories
  • Renamed env variables (REACT_APP_ / VITE_NEXT_PUBLIC_)
  • Updated package.json scripts
  • Set up path aliases in tsconfig.json

Routing

  • Every React Router route has a corresponding page.tsx
  • Dynamic routes converted to [slug]/page.tsx
  • Nested layouts moved to layout.tsx
  • Programmatic navigation updated to useRouter() from next/navigation

Data fetching

  • useEffect data fetches moved to Server Components
  • Auth-gated data uses server-side cookie reading via cookies()
  • Client-only fetching (triggered by user actions) left as Client Components

Metadata

  • react-helmet replaced with generateMetadata
  • Open Graph tags set per page

Client Components

  • 'use client' added to every component using useState, useEffect, or browser APIs
  • dynamic(() => import(...), { ssr: false }) wrapping any SSR-crashing package

Images

  • <img> tags replaced with next/image
  • priority set on above-the-fold images

Testing

  • No hydration mismatch errors in browser console
  • Auth flow works end to end
  • All env variables present in .env.local
  • Build passes (next build) with no type errors

React to Next.js Conversion Prompt (for Claude, Cursor, or ChatGPT)#

Feeding an AI tool your whole codebase and saying "convert this to Next.js" produces messy, inconsistent output. It works much better broken into the same steps as this guide — one file or one concern at a time, explicit about which router and rendering model you're targeting.

Copy this as a starting prompt, fill in the bracketed parts:

I'm migrating a React app (built with [CRA / Vite / custom webpack]) to
Next.js [App Router]. Convert the following component/file, applying
these rules:

1. If this component fetches data with useEffect + fetch/axios, and it
   doesn't need browser-only APIs (localStorage, window, event
   listeners), convert it to an async Server Component that fetches
   data directly — no useState/useEffect for the initial load.
2. If this component genuinely needs interactivity (useState, onClick,
   browser APIs), keep it a Client Component and add 'use client' at
   the top — but push it as deep in the tree as possible, don't mark
   parent layout/page files as client just because one child needs it.
3. Replace react-router-dom navigation (useNavigate, Link, useParams)
   with next/navigation equivalents (useRouter, next/link, useParams).
4. Replace <img> tags with next/image, inferring width/height from
   context or asking me if unknown.
5. Replace react-helmet / manual <head> tags with a generateMetadata
   export if this is a page-level file.
6. Flag anything that reads process.env.REACT_APP_* or
   import.meta.env.VITE_* so I can rename it to NEXT_PUBLIC_*.
7. Don't guess at file structure — tell me what new file path this
   should live at under app/, and ask if it's ambiguous.

Here's the file:
[paste component]

Feed it one route or shared component at a time, in the order from the migration checklist above — routing first, then data fetching, then metadata, then images. Trying to convert an entire app in one prompt is exactly what produces the broken, half-migrated output most AI-assisted migrations end up with.

How Long Does It Take?#

App sizeSolo developer, full-time
Small (< 10 routes, no auth)1–2 weeks
Medium (10–30 routes, auth, 3rd-party APIs)3–6 weeks
Large (30+ routes, complex state, multiple teams)2–4 months

Add 30–50% to these estimates if you're migrating while actively developing new features in the existing app.

Common Gotchas#

Cookie-based auth needs cookies(). If you use httpOnly cookies, read them in Server Components via Next.js's cookies() function. This is the single most common blocker in production migrations.

window is not defined. Third-party packages that reference window at import time crash in Server Components. Fix with dynamic(..., { ssr: false }).

Hydration mismatches. If server HTML doesn't match the first client render (common with localStorage-based themes or random IDs), Next.js warns and remounts. Move such logic inside useEffect or use suppressHydrationWarning on the specific element.

useRouter() changed. In the App Router, useRouter comes from next/navigation, not next/router. The query object is gone — use useParams() for route params and useSearchParams() for query strings.

Server Components can't use hooks. useState, useEffect, and React context don't work in Server Components. Add 'use client' to any component that needs them.

Environment variables without NEXT_PUBLIC_ are server-only. They won't be in the browser bundle, which is good for secrets — but if you reference a non-prefixed variable client-side, it'll be undefined. Audit your env usage during the migration.


If you're weighing whether this migration makes sense for your specific stack — the timeline, the cost, the risk — feel free to reach out. Happy to talk through it.

Related posts

Freelance

Need help with this?

I help startups and businesses ship web projects: migrations, new products, and performance fixes.

Get in touch