Scroll Top

Migrating from React to Next.js: A Practical Guide

migrating from react to nextjs

I’ve migrated a few React apps to Next.js at this point, and every time I search for a guide, I find one of two things: a tutorial that’s basically just “here are Next.js docs but longer,” or a guide that glosses over the parts that actually break.

This is my attempt to write the guide I wish I’d had, including the parts nobody talks about.

Before We Start: Two Very Different Paths

The phrase “migrate React to Next.js” gets used for two completely different things, and most guides don’t bother distinguishing them.

Path 1 — Incremental Adoption

Next.js can act as a proxy in front of your existing React app. You keep your current app running and migrate routes one at a time. This is what large teams do when they can’t afford downtime or a big-bang rewrite. It’s genuinely complex to set up, and it’s not what this guide covers, but if that’s what you need, the official Next.js incremental adoption docs are the right starting point.

Path 2 — Full Rewrite (What This Guide Covers)

You create a new Next.js project and move your components, pages, and logic over. This is what most developers with small-to-medium React apps actually end up doing. If your app is under 50k lines of code, doesn’t have complex deployment constraints, or you’re using Create React App (which is effectively unmaintained now), this is almost certainly the right call.

How do you pick?

  • Large app, multiple teams, can’t take downtime → incremental adoption
  • Small-medium app, okay with a rewrite sprint, want to clean up tech debt → full rewrite

For the rest of this guide, we’re doing the full rewrite.

Why Actually Move From React to Next.js?

Before committing to this, be honest about whether you need it. Next.js adds real complexity. If you have a small internal tool or a dashboard behind a login where SEO doesn’t matter, a plain React app with Vite is probably fine.

The cases where Next.js actually pulls its weight:

  • You need SEO — client-side rendering is rough for search engines. Server-side rendering fixes this.
  • Initial load performance matters — SSR and static generation send HTML to the browser immediately rather than making users stare at a blank page while JS loads.
  • You’re building a content-heavy site — blog, marketing site, docs — static generation is perfect here.
  • You want to consolidate your backend — Next.js API routes mean you can ditch a separate Express server for simple endpoints.
  • You want ISR — incremental static regeneration, which lets you update pages without rebuilding your entire site.

When you might not want to migrate: purely client-side apps with no SEO needs, apps with complex webpack customization that would be painful to port, or teams that aren’t yet comfortable with the server/client component mental model. It genuinely takes time to internalize.

Prerequisites

Before starting, make sure you have:

  • Node.js 18.18 or later (Node.js 20+ recommended)
  • A working React application with Git version control
  • Familiarity with ES6+ JavaScript

Version notes:

  • Next.js 15 supports both React 18 and React 19 — it doesn’t require React 19
  • The App Router has been stable since Next.js 14
  • TypeScript 5+ is recommended if you’re using TypeScript

What Actually Changes: React vs Next.js

Project Structure

React with CRA or Vite gives you the freedom to organize however you want. Next.js has opinions:

Your React app today:

my-react-app/
├── public/
│   └── index.html
├── src/
│   ├── components/
│   │   ├── Header.js
│   │   └── Footer.js
│   ├── pages/
│   │   ├── Home.js
│   │   └── About.js
│   ├── App.js
│   └── index.js
└── package.json

Your Next.js app:

my-nextjs-app/
├── app/
│   ├── layout.tsx       ← replaces App.js + index.html
│   ├── page.tsx         ← your home route
│   └── about/
│       └── page.tsx     ← /about route
├── components/
│   ├── Header.js
│   └── Footer.js
├── public/
├── next.config.js
└── package.json

The big mental shift: folder structure is your routing. No more React Router config. A file at app/blog/[slug]/page.tsx automatically becomes the /blog/:slug route.

Two Routing Systems — Pick One

Next.js has two routers, and this trips people up constantly:

  • Pages Router (pages/ directory) — The old way still works; you’ll see it in older tutorials everywhere
  • App Router (app/ directory) — the current approach, added in Next.js 13, stable since Next.js 14

Use the App Router. This guide focuses entirely on it. If you’re following a tutorial and it uses pages/, it’s probably outdated.

The App Router gives you React Server Components by default, better layouts and nested routing, streaming and Suspense support, and built-in loading/error states with special files.

Special files you’ll use constantly:

  • page.tsx — defines the UI for a route
  • layout.tsx — shared UI that wraps child routes
  • loading.tsx — loading UI with automatic Suspense boundaries
  • error.tsx — error UI with automatic error boundaries
  • not-found.tsx — 404 UI
  • route.ts — API route handlers

Server Components vs Client Components

This is the biggest conceptual change, and where most confusion comes from.

In the App Router, every component is a Server Component by default. Server Components render on the server, never ship JavaScript to the browser, and can directly talk to databases or internal APIs. They cannot use useState, useEffect, or any browser APIs.

When you need interactivity, you opt into a Client Component by adding 'use client' at the top of the file. Client Components work like React components always have — they render on both server (for the initial HTML) and client (for hydration).

The rule of thumb: push 'use client' as deep into your component tree as possible. Fetch data in Server Components, pass it down to small Client Components that handle interaction.

What Actually Breaks (Before You Write Any Code)

Before touching a single file, spend an hour on this audit. It will save you a day of whack-a-mole debugging.

  1. List every route in your current app
  2. Flag every component that uses useState, useEffect, browser APIs, or event handlers — these need 'use client'
  3. List your third-party dependencies — some won’t support SSR (more on this below)
  4. Note any environment variables with the REACT_APP_ prefix — they all need renaming
  5. Flag any protected routes — you’ll need to handle these with middleware

Environment variables

CRA uses REACT_APP_ prefix. Next.js uses NEXT_PUBLIC_ for client-exposed variables. Rename every single one and update your .env files. Easy to miss, annoying to debug because the variable silently returns undefined.

Libraries that don’t support SSR

Some packages access window or document at import time and crashes when Next.js tries to render them on the server. Common culprits: certain charting libraries, drag-and-drop libraries, and some analytics SDKs.

Fix with dynamic imports and { ssr: false }:

import dynamic from 'next/dynamic';

const MyChart = dynamic(() => import('@/components/MyChart'), { ssr: false });

useEffect firing twice in development

Code like useEffect(() => { analytics.init() }, []) fires twice in development because of React 18’s Strict Mode. This isn’t a Next.js issue, but migration is a good time to audit it. The fix is usually checking for an existing instance before initializing.

BrowserRouter still in your code

If you moved components without fully removing React Router, you might have BrowserRouter somewhere that conflicts with Next.js’s router. Grep for react-router-dom and nuke every trace.

Webpack customizations

If your React app had a custom webpack config via craco or Vite, you’ll need to recreate it in next.config.js. The most common case is path aliases — @/ is built into Next.js, but custom aliases need to be set in both next.config.js and tsconfig.json/jsconfig.json.

Turbopack is now the default (Next.js 15.3+)

next dev uses Turbopack by default as of Next.js 15.3. It’s faster, but occasionally breaks with certain webpack plugins. If you see unexpected build errors, try next dev --webpack to rule them out.

Step 1: Set Up the Next.js Project

pnpm create next-app@latest my-nextjs-app --yes
cd my-nextjs-app
pnpm dev

During setup, pick:

  • TypeScript (recommended, but optional)
  • ESLint
  • App Router — make sure this is selected, not Pages Router
  • Tailwind CSS — recommended

Keep your existing React app running alongside it while you migrate. Don’t delete it until you’re confident everything works.

Step 2: Move Your Components

Most components move over with minimal changes. The main things to update:

Replace React Router’s Link with Next.js Link:

// ❌ Before (React Router)
import { Link } from 'react-router-dom';
<Link to="/about">About</Link>

// ✅ After (Next.js)
import Link from 'next/link';
<Link href="/about">About</Link>

Add 'use client' to any component using hooks or browser APIs:

// ❌ This breaks — Server Components can't use hooks
export default function SearchBar() {
  const [query, setQuery] = useState('');
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

// ✅ Add 'use client' at the top
'use client';

import { useState } from 'react';

export default function SearchBar() {
  const [query, setQuery] = useState('');
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

The error when you forget this is clear — Next.js tells you exactly which hook is causing the problem. Still, it’s worth auditing upfront and flagging anything that uses useState, useEffect, useRef, useContext, event handlers, or window/document.

The Root Layout

Your App.js and index.html get replaced by app/layout.tsx. This is where your global styles, fonts, and shared UI like headers and footers live:

// app/layout.tsx
import './globals.css';
import Header from '@/components/Header';
import Footer from '@/components/Footer';

export const metadata = {
  title: {
    default: 'My App',
    template: '%s | My App',  // child pages set their own title
  },
  description: 'Description for SEO',
  openGraph: {
    title: 'My App',
    description: 'Description for SEO',
    images: ['/og-image.jpg'],
  },
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <Header />
        <main>{children}</main>
        <Footer />
      </body>
    </html>
  );
}

The metadata export replaces react-helmet for static metadata — titles, descriptions, Open Graph tags, all handled without extra packages. One caveat: if you were using react-helmet to update the page title dynamically based on user interaction (like a title that changes as someone types), metadata can’t do that. You’d handle those cases inside a Client Component instead.

Fonts: if you were loading Google Fonts via a <link> tag in your old index.html, swap that out for next/font. It self-hosts the font files, eliminates the extra network request, and kills the layout shift you might have been living with.

// app/layout.tsx
import { Inter } from 'next/font/google';

const inter = Inter({ subsets: ['latin'] });

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

For pages that need dynamic metadata based on route params, use generateMetadata:

// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
  const { slug } = await params;
  const post = await fetch(`https://api.example.com/posts/${slug}`)
    .then(res => res.json());

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      images: [post.coverImage],
    },
  };
}

Server and Client Components in Practice

Here’s the composition pattern you’ll use constantly: a Server Component fetches data, a Client Component handles interaction:

Server Component (default; no directive needed):

// app/products/page.tsx
async function getProducts() {
  const res = await fetch('<https://api.example.com/products>');
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <div>
      <h1>Our Products</h1>
      {products.map(product => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  );
}

Combining both:

// app/products/[id]/page.tsx — Server Component fetches, Client Component acts
import AddToCart from '@/components/AddToCart';

export default async function ProductPage({ params }) {
  const { id } = await params;
  const res = await fetch(`https://api.example.com/products/${id}`);
  const product = await res.json();

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>${product.price}</p>
      <AddToCart productId={product.id} />
    </div>
  );
}

Step 3: Replace Your Routing

Delete React Router entirely. Uninstall react-router-dom. Your folder structure handles routing now.

From this:

// src/App.js
import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/contact" element={<Contact />} />
        <Route path="/blog/:slug" element={<BlogPost />} />
      </Routes>
    </BrowserRouter>
  );
}

To this:

app/
├── page.tsx              → /
├── about/
│   └── page.tsx          → /about
├── contact/
│   └── page.tsx          → /contact
└── blog/
    └── [slug]/
        └── page.tsx      → /blog/:slug

Each page.tsx just exports the component:

// app/about/page.tsx
export default function About() {
  return <h1>About Us</h1>;
}

Dynamic Routes

Route patterns at a glance:

RouteExample URLparams value
app/blog/[slug]/page.tsx/blog/hello{ slug: 'hello' }
app/shop/[...slug]/page.tsx/shop/clothes/tops{ slug: ['clothes', 'tops'] }
app/shop/[[...slug]]/page.tsx/shop{ slug: undefined }
app/[category]/[id]/page.tsx/electronics/123{ category: 'electronics', id: '123' }

Use bracket notation in folder names. In Server Components with Next.js 15, params is a Promise — you need to await it:

// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
  const { slug } = await params;  // await required in Next.js 15 Server Components
  return <h1>Post: {slug}</h1>;
}

Coming from Next.js 13 or 14? params used to be a plain object. Next.js 15 made it a Promise in Server Components. If you’re seeing undefined on params, this is why.

In Client Components, use React’s use() hook instead of await:

'use client';
import { use } from 'react';

export default function BlogPost({ params }) {
  const { slug } = use(params);
  return <h1>Post: {slug}</h1>;
}

For static generation of dynamic routes, export generateStaticParams:

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await fetch('<https://api.example.com/posts>')
    .then(res => res.json());

  return posts.map(post => ({ slug: post.slug }));
}

export default async function BlogPost({ params }) {
  const { slug } = await params;
  const post = await fetch(`https://api.example.com/posts/${slug}`)
    .then(res => res.json());

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

For matching multiple path segments, use catch-all routes:

// app/shop/[...slug]/page.tsx — matches /shop/a, /shop/a/b, /shop/a/b/c
export default async function ShopPage({ params }) {
  const { slug } = await params;  // slug is an array: ['a', 'b', 'c']
  return <div>Category: {slug.join(' / ')}</div>;
}

// app/shop/[[...slug]]/page.tsx — also matches /shop itself
export default async function ShopPage({ params }) {
  const { slug } = await params;
  if (!slug) return <div>All Products</div>;
  return <div>Category: {slug.join(' / ')}</div>;
}

Protected Routes and Middleware

If your React app had protected routes — redirect to /login if the user isn’t authenticated, this is handled in Next.js with middleware.ts at the root of your project:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token');

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/dashboard/:path*',
};

Middleware runs on the Edge before the page renders. It’s the right place for auth checks, redirects, and locale detection, anything you were previously doing with a wrapper component or useEffect redirect.

Note: if your app reads auth cookies inside components using cookies() from next/headers, those are also async in Next.js 15, same as params, You need to await them.

Note (Next.js 16+): Middleware has been renamed to Proxy to better The functionality remains the same. Refer: Official docs


Check out the best NextJS Template – Materio MUI NextJS Admin Dashboard

materio mui nextjs admin template blog

This is one of the best Vercel Template to use for professional web apps.


Loading and Error States

Instead of wiring up your own loading spinners and error boundaries, Next.js handles these with special files:

// app/blog/loading.tsx — automatically wraps page.tsx in a Suspense boundary
export default function Loading() {
  return (
    <div className="flex items-center justify-center p-8">
      <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900" />
    </div>
  );
}
// app/blog/error.tsx
'use client'; // error boundaries must be Client Components — not optional

import { useEffect } from 'react';

export default function Error({ error, reset }) {
  useEffect(() => {
    console.error(error);  // log to your error reporting service here
  }, [error]);

  return (
    <div>
      <h2>Something went wrong.</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

The reason error.tsx requires 'use client': error boundaries in React are class-component-based under the hood and need client-side React to catch and recover from errors. It’s not a quirk – it’s a React constraint.

// app/blog/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div>
      <h2>Post Not Found</h2>
      <Link href="/blog">Back to Blog</Link>
    </div>
  );
}

Trigger the not-found UI explicitly with notFound():

import { notFound } from 'next/navigation';

export default async function BlogPost({ params }) {
  const { slug } = await params;
  const post = await fetch(`https://api.example.com/posts/${slug}`)
    .then(res => res.ok ? res.json() : null);

  if (!post) {
    notFound();  // renders the nearest not-found.tsx
  }

  return <article>{/* render post */}</article>;
}

Step 4: Update Data Fetching

If your React app fetches data with useEffect, you have two paths in Next.js.

Option A: Move it to a Server Component (do this when you can):

// app/products/page.tsx — no 'use client', no useEffect, no loading state needed
export default async function ProductsPage() {
  const res = await fetch('<https://api.example.com/products>', {
    next: { revalidate: 60 }  // ISR — re-fetch in background every 60 seconds
  });
  const products = await res.json();

  return (
    <div>
      {products.map(product => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  );
}

The fetch cache options control rendering strategy:

  • cache: 'force-cache' — fetch once at build time, never again (SSG)
  • cache: 'no-store' — fetch fresh on every request (SSR)
  • next: { revalidate: 60 } — cache for 60 seconds, refresh in background (ISR)

Option B: Keep it client-side (when you need interactivity):

'use client';

import { useState, useEffect } from 'react';

export default function ProductSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query) return;
    fetch(`/api/products?q=${query}`)
      .then(r => r.json())
      .then(setResults);
  }, [query]);

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {results.map(r => <div key={r.id}>{r.name}</div>)}
    </div>
  );
}

A pattern that works well: fetch initial data in a Server Component, pass it to a Client Component, then let the client handle live updates. Fast initial load, full interactivity:

// app/products/page.tsx — Server Component
import ProductList from '@/components/ProductList';

export default async function ProductsPage() {
  const res = await fetch('<https://api.example.com/products>');
  const initialProducts = await res.json();

  return <ProductList initialProducts={initialProducts} />;
}
// components/ProductList.tsx — Client Component
'use client';

import { useState } from 'react';

export default function ProductList({ initialProducts }) {
  const [products, setProducts] = useState(initialProducts);
  // handle filtering, sorting, etc. client-side from here
  return <div>{/* render products */}</div>;
}

When to use which:

  • Static content, no interaction → Server Component with cache: 'force-cache'
  • Needs to be fresh for every request → Server Component with cache: 'no-store'
  • Updates periodically → Server Component with next: { revalidate }
  • User interaction, hooks, browser APIs → Client Component with 'use client'

A Note on Server Actions

If you’re migrating forms that POST to a separate Express endpoint, Server Actions are worth looking at. They let you define server-side functions that can be called directly from a Client Component; no separate API route needed for simple mutations. They’re now the idiomatic Next.js way to handle form submissions.

Step 5: Assets and Styling

Images

Replace <img> with Next.js’s <Image> component. It handles lazy loading, automatic WebP conversion, and responsive sizing:

import Image from 'next/image';

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

// ✅ After
<Image
  src="/images/hero.jpg"
  alt="Hero"
  width={800}
  height={600}
  priority  // add for above-the-fold images — skips lazy loading
/>

For external images, whitelist the domain in next.config.js:

const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'your-image-cdn.com',
      },
    ],
  },
};

export default nextConfig;

images.domains still works, but shows a deprecation warning. Use remotePatterns — it’s more flexible.

Coming from CRA: image imports now return an object, not a string. <img src={logo} /> breaks silently. Use <img src={logo.src} /> or switch to the <Image> component.

Styles

Global CSS imports only work in app/layout.tsx. Importing a global CSS file from a component throws an error. For component-level styles, use CSS Modules (.module.css). If you’re using Sass, install the sass package and rename files to .scss — No other config needed.

If you’re not already on Tailwind, migration is a good time to switch. You stop jumping between your component file and a stylesheet to change a margin, you stop inventing class names for one-off elements, and the production bundle only includes the classes you actually used. Next.js has Tailwind support built in — select it during

create-next-app setup, and it’s ready to go. If you’re migrating an existing app, converting as you go is less painful than doing it all at once.

Common Mistakes

Using <a> instead of <Link>

Plain anchor tags cause full page reloads and lose client-side navigation entirely. Use next/link for anything internal.

Passing non-serializable props to Client Components

Server Components can only pass plain objects, arrays, strings, and numbers to Client Components. Functions, class instances, and Dates don’t survive the server/client boundary. This shows up as a runtime error, not a build error so don’t assume a clean npm run build means you’re in the clear.

// ❌ Runtime error — functions can't cross the server/client boundary
export default function Page() {
  const handleClick = () => console.log('clicked');
  return <ClientButton onClick={handleClick} />;
}

// ✅ Define the handler inside the Client Component
'use client';
export default function ClientButton() {
  return <button onClick={() => console.log('clicked')}>Click</button>;
}

Overusing 'use client'

Every 'use client' boundary is JavaScript you’re shipping to the browser. Before adding it, ask: does this component actually need to run on the client? If it’s just rendering data with no interaction, keep it as a Server Component.

Forgetting to await params in Server Components

// ❌ Wrong — params is a Promise in Next.js 15 Server Components
export default async function Page({ params }) {
  const slug = params.slug;  // undefined
}

// ✅ Correct
export default async function Page({ params }) {
  const { slug } = await params;
}

Build and Deploy

npm run build   # always run this locally before deploying — catches errors early
npm start       # test the production build locally before pushing

Vercel is the obvious choice (they built Next.js), but it runs fine on Netlify, AWS, Docker, or any Node.js host with npm run build && npm start.

For self-hosting, the production build goes into .next/. Your server needs to keep that directory and run next start, not next dev. Add .next and next-env.d.ts to your .gitignore.

If you had routes that changed during migration, handle the redirects in next.config.js so you don’t lose SEO equity:

const nextConfig = {
  async redirects() {
    return [
      {
        source: '/old-blog/:slug',
        destination: '/blog/:slug',
        permanent: true,
      },
    ];
  },
};

export default nextConfig;

Conclusion:

The migration itself isn’t that hard. The mental model shift — thinking about what runs on the server vs the client, pushing interactivity to leaf components, and remembering that a passing build doesn’t catch runtime boundary errors — takes longer than the actual code changes.

On one marketing site migration, Lighthouse scores went from the mid-60s to above 90, and we cut about 30% of the JavaScript bundle just by moving data fetching to Server Components. That’s one app with a lot of client-side fetching that moved server-side your results will depend on where your current app is spending its bytes.

If you get stuck, the Next.js Discord is active and genuinely helpful. The official migration guide covers some edge cases I haven’t.

Related Posts

close-link
Register to ThemeSelection 🚀

Prefer to Login/Register with:

OR
Already Have Account?

By Signin or Signup to ThemeSelection.com using social accounts or login/register form, You are agreeing to our Terms & Conditions and Privacy Policy
close-link
Reset Your Password 🔐

Enter your username/email address, we will send you reset password link on it. 🔓

Privacy Preferences
When you visit our website, it may store information through your browser from specific services, usually in form of cookies. Here you can change your privacy preferences. Please note that blocking some types of cookies may impact your experience on our website and the services we offer.