Why Your React App Needs to Migrate to Next.js (And How to Do It)
A practical guide to migrating from React to Next.js — what you gain, how long it takes, and the gotchas nobody warns you about.
The Problem with Plain React#
React is a library, not a framework. It handles the view layer well, but leaves everything else to you: routing, data fetching, server rendering, code splitting, image optimization, SEO metadata, and deployment configuration. Over time, most React projects accumulate a bespoke stack of libraries to fill these gaps — react-router, react-helmet, custom webpack configs, manual lazy loading — each one adding maintenance overhead and one more thing that can break.
The most damaging gap for most products is server-side rendering. A plain Create React App (or Vite) setup ships an empty HTML shell to the browser, then hydrates it with JavaScript. Search engines and social link previews receive that empty shell. Google has improved its ability to crawl JavaScript-rendered content, but it still treats server-rendered HTML with higher confidence and indexes it faster. If your competitors' pages are server-rendered and yours are not, they have a structural SEO advantage that no amount of content optimization can fully overcome.
The second problem is Core Web Vitals. Google's page experience signals weigh metrics like Largest Contentful Paint (LCP) and Interaction to Next Paint (INP). A React SPA that fetches data client-side after hydration will almost always underperform a server-rendered equivalent — the user stares at a blank or skeleton screen while the browser waits for JavaScript to load, parse, execute, and then make a network request to an API. That is a lot of latency stacked before the user sees anything meaningful.
What Next.js Actually Gives You#
Next.js is the React framework maintained by Vercel. It adds a production-quality layer on top of React that handles everything CRA leaves out:
- Server-side rendering (SSR) and static generation (SSG) out of the box
- File-based routing — no more configuring React Router
- API routes — backend endpoints living in the same project
- Automatic code splitting — only the JavaScript needed for the current page is loaded
- Built-in image optimization — automatic WebP conversion, lazy loading, and responsive sizing via
next/image - Metadata API — server-rendered
<head>tags for reliable SEO and social previews - React Server Components (App Router) — components that run on the server, with direct database or API access, zero client-side JavaScript
For a business, these translate to: faster pages, better Google rankings, lower hosting costs (statically generated pages are cheap to serve), and a codebase that follows a convention new engineers can onboard to quickly.
Is Migrating Worth It?#
For most production React apps, yes. Here is how to think about it.
Migrate if:
- Your Lighthouse performance score is below 70 on mobile
- Pages are slow to become interactive on low-end devices or slow networks
- You rely on react-helmet or similar for SEO metadata
- Your webpack or CRA config is heavily customized and brittle
- You are planning to grow the team and want a standard, well-documented architecture
You might not need to if:
- The app is a private internal tool where SEO and initial load time are irrelevant
- You are already running a similar setup with Vite + a custom SSR layer
- The app is approaching end-of-life and a migration would not recoup its cost
How the Migration Works#
A React-to-Next.js migration is usually a restructure, not a rewrite. Your component code, state management, and business logic stay largely intact. What changes is the outer shell: the routing model, where data fetching happens, and the project entry point.
Step 1: Audit your current stack#
Before writing any code, map out:
- Routing library and route structure (react-router v5 vs v6 matters)
- Data fetching patterns (useEffect, Redux Thunk, React Query, SWR)
- Any webpack plugins or CRA customizations that need a Next.js equivalent
- Environment variables and which ones are consumed client-side vs server-side
- Authentication patterns — especially whether you use cookies or localStorage
This audit takes a few hours for a small app and a few days for a large one. Do not skip it. The surprises discovered in audit are much cheaper to handle than the ones discovered mid-migration.
Step 2: Set up the Next.js project#
Create a fresh Next.js project alongside your existing one. Do not try to convert in-place:
npx create-next-app@latest my-app --typescript --app
Step 3: Migrate routing#
Next.js uses a file-based router. Every file named page.tsx inside the app/ directory maps to a URL segment:
app/
page.tsx → /
about/page.tsx → /about
blog/[slug]/page.tsx → /blog/:slug
dashboard/layout.tsx → shared layout for /dashboard/*
Translate each React Router route to a corresponding file. This is mostly mechanical work — often the most time-consuming part of the migration depending on how many routes your app has.
Step 4: Move data fetching to the server#
This is the biggest conceptual shift. In React SPAs, data is fetched inside useEffect or with a client-side library. In Next.js App Router, you fetch data in Server Components — async functions that run on the server, with direct access to your database or API:
// Before: client-side fetch in a 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 in Next.js
export default async function BlogPage() {
const posts = await db.query('SELECT * FROM posts WHERE published = true')
return <PostList posts={posts} />
}
The result: no loading spinners on first render, no empty HTML shell for crawlers, no extra browser-to-API round trip on page load.
Step 5: Replace react-helmet with Next.js Metadata API#
// Before: react-helmet (client-rendered, unreliable for crawlers)
<Helmet>
<title>My Page</title>
<meta name="description" content="..." />
</Helmet>
// After: Next.js generateMetadata (server-rendered, crawlers see it instantly)
export async function generateMetadata({ params }): Promise<Metadata> {
return {
title: 'My Page',
description: '...',
openGraph: { title: 'My Page', description: '...' },
}
}
Step 6: Handle client-only code#
Some things only run in the browser: localStorage, window events, third-party browser SDKs. In Next.js, these live in Client Components, marked with 'use client' at the top of the file. The default is server; add 'use client' only when the component needs interactivity or browser APIs.
'use client'
export function ThemeToggle() {
const [dark, setDark] = useState(false)
// ...
}
Step 7: Test side by side, then cut over#
Run both apps simultaneously and check for:
- Hydration mismatches (server HTML does not match the initial client render)
- Missing or misnamed environment variables
- Auth flows that relied on client-side state being available immediately
- Third-party packages that use
windowordocumentat import time — wrap these withdynamic(..., { ssr: false })
Deploy to Vercel, Railway, Coolify, or any VPS running Node. The first production deploy typically surfaces the final few edge cases.
How Long Does It Take?#
| App size | Solo developer, full-time |
|---|---|
| Small (< 10 routes, simple data fetching) | 1–2 weeks |
| Medium (10–30 routes, auth, 3rd-party APIs) | 3–6 weeks |
| Large (30+ routes, complex state, multiple teams) | 2–4 months |
Running the migration alongside feature development adds time. Budget accordingly.
Common Gotchas#
Cookie-based auth needs server-aware handling. If you use httpOnly cookies for authentication, you need to read them in Server Components via Next.js's cookies() function. This trips up almost every migration.
Some npm packages do not support server environments. Libraries that reference window or document at import time will crash inside a Server Component. The fix is dynamic(() => import('./MyComponent'), { ssr: false }).
Environment variables work differently. Only variables prefixed with NEXT_PUBLIC_ are exposed to the browser bundle. Audit your .env file before cutting over — leaking secrets to the client is a real risk in this step.
Programmatic navigation must be in a Client Component. useRouter().push() from next/navigation only works in 'use client' components.
What Is the ROI?#
For most businesses, the return on a Next.js migration comes from three places:
SEO. Server-rendered pages are indexed faster and more reliably. Organic traffic gains are commonly observed within 60–90 days of migrating, particularly for content-heavy pages.
Conversion. Faster LCP means lower bounce rates. A 100ms improvement in load time correlates with measurable conversion improvements — the principle is well-established even if the exact number varies by industry.
Developer velocity. Next.js conventions eliminate the decision overhead of a bespoke React stack. New engineers contribute faster because the patterns are standardized and well-documented. Less time configuring, more time building.
The migration cost in developer hours is typically recovered within 6–12 months for a product with meaningful traffic and an active development team.
If you are weighing whether this makes sense for your specific app — the stack, the timeline, the cost — feel free to reach out. I am happy to talk through it.
Freelance
Besoin d'aide sur ce sujet ?
Je peux aider sur les migrations, nouveaux produits et performances web.
Me contacter →