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
useEffectdata fetching next/imagereplaces<img>tagsgenerateMetadatareplaces 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
useEffectthat 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
.envkeys) - Note Vite config (aliases, plugins,
import.meta.envusage) - Note any webpack plugins that have no Next.js equivalent
Auth:
- Is auth via
localStoragetokens orhttpOnlycookies? - Where does protected route logic live?
Third-party packages:
- Any packages that use
windowordocumentat 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/webpackvianext.config.ts, or inline SVGs as React componentsvite-plugin-pwa→next-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 customization | Next.js equivalent |
|---|---|
| SVG as React component | @svgr/webpack in next.config.ts |
| CSS Modules | Built-in — file must be named *.module.css |
| Sass/SCSS | Install sass, works automatically |
| Less | next-with-less plugin |
| Custom Babel plugins | babel.config.js at project root |
| Module path aliases | tsconfig.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-appproject - Copied
components/,hooks/,lib/,utils/directories - Renamed env variables (
REACT_APP_/VITE_→NEXT_PUBLIC_) - Updated
package.jsonscripts - 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()fromnext/navigation
Data fetching
-
useEffectdata 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-helmetreplaced withgenerateMetadata - Open Graph tags set per page
Client Components
-
'use client'added to every component usinguseState,useEffect, or browser APIs -
dynamic(() => import(...), { ssr: false })wrapping any SSR-crashing package
Images
-
<img>tags replaced withnext/image -
priorityset 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
How Long Does It Take?#
| App size | Solo 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.
Freelance
Need help with this?
I help startups and businesses ship web projects: migrations, new products, and performance fixes.
Get in touch →