Awwwards Nominee Awwwards Nominee

How to Migrate from React to Next.js (CRA or Vite): A Step-by-Step Guide

by : deepak-chauhan Category : Nextjs,Technology,Website Date :
Request a Quote

To migrate a React app to Next.js, add Next.js to the project, render your existing single-page app inside one catch-all route as a client component, and get it running unchanged. Then move routes into the app/ directory one at a time, turning data-heavy pages into Server Components and replacing React Router, environment variables and API proxies with their Next.js equivalents. This works the same whether you started with Create React App (CRA) or Vite.

This guide covers when the move is worth it, the step-by-step process with the App Router, a React Router mapping table, a checklist and the pitfalls we see most often. If you already run Next.js on the Pages Router, read our guide to migrating from the Pages Router to the App Router instead.

Why move from React to Next.js?

React is a UI library; routing, data loading, rendering and deployment are left to you. Next.js is a React framework that makes those decisions and adds a server. That gives you:

  • HTML on first load. Pages are rendered on the server or at build time, so search engines, social previews and users on slow devices get real content instead of an empty <div id="root">.
  • Server Components. Components that only render data run on the server and send no component JavaScript to the browser, which keeps client bundles smaller.
  • File-based routing and nested layouts in place of a hand-maintained route tree.
  • Built-in SEO tools: the Metadata API, sitemaps, robots rules and Open Graph images.
  • A back end in the same project: Route Handlers and Server Actions.

Create React App is deprecated and no longer the recommended way to start a React project, so many teams on CRA have to move somewhere. Vite apps are not deprecated, but they share the same limitation: everything renders in the browser. For a fuller comparison, see Next.js vs React: when to use each and why.

When migrating is worth it, and when it is not

Migration costs engineering time and adds a server to run. Make the case on real problems, not fashion.

Migrate when

  • Public pages need to rank or be shared: marketing pages, product listings, blogs, directories, documentation.
  • First load is slow because a large JavaScript bundle has to download before anything appears.
  • You are on CRA and need a supported build tool anyway.
  • You keep a separate Express or serverless layer only to hide API keys or proxy requests.

Stay where you are when

  • The app sits entirely behind a login, has no SEO needs and performs well. A Vite single-page app is a perfectly good choice for dashboards and internal tools.
  • Your hosting cannot run Node.js and you need server rendering. Static export is possible, but it switches off features that need a server.

A middle path is common: move the public site to Next.js and leave the logged-in app as a single-page app.

Step 1: Audit the existing app

Before touching code, list what the app depends on. Each item maps to a decision later.

  • Routing: React Router version, nested routes, route guards, lazy-loaded routes, anything using loader or action.
  • Data fetching: useEffect + fetch, React Query/TanStack Query, SWR, Apollo, or Redux thunks.
  • Global state: Redux, Zustand, MobX, or Context providers wrapped around the whole app.
  • Environment variables: every REACT_APP_* (CRA) or import.meta.env.VITE_* (Vite) reference.
  • Browser-only APIs: window, document, localStorage, navigator, and libraries that touch them on import (charts, maps, rich-text editors).
  • Styling: plain CSS, Sass, CSS Modules, Tailwind, or CSS-in-JS such as styled-components or Emotion.
  • Build-tool specifics: the CRA proxy field or Vite server.proxy, SVGs imported as components, path aliases, custom webpack or Vite plugins.
  • Authentication: where tokens live (localStorage or cookies) and how protected routes are enforced.

Step 2: Install Next.js alongside your code

Adding Next.js to the existing repository is usually safest: history, tests and components stay put. Starting fresh with npx create-next-app@latest and copying components across suits small apps or redesigns.

To add it in place, install it and replace the old scripts:

npm install next@latest react@latest react-dom@latest
npm uninstall react-scripts   # CRA
# or: npm uninstall vite @vitejs/plugin-react   # Vite
// package.json
"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start"
}

Add a minimal next.config.ts (or next.config.js), and add .next and next-env.d.ts to .gitignore. If you use TypeScript, Next.js updates tsconfig.json on the first run; keep your path aliases in compilerOptions.paths.

Then create the root layout. It replaces public/index.html (CRA) or the root index.html (Vite), and it must render <html> and <body>:

// app/layout.tsx
import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  title: 'My App',
  description: 'What the app does, in one sentence.',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <div id="root">{children}</div>
      </body>
    </html>
  );
}

Move anything that lived in the HTML <head> (title, meta tags, favicon links) into the metadata export, and put the favicon in app/ or public/. Delete src/index.tsx or src/main.tsx later; Next.js now owns the entry point.

Step 3: Render the existing SPA through a catch-all route

This step makes incremental migration possible. Instead of rewriting every route before you can ship, mount the whole existing app under one optional catch-all route and let React Router keep handling navigation inside it.

// app/[[...slug]]/client.tsx
'use client';

import dynamic from 'next/dynamic';

const App = dynamic(() => import('../../src/App'), { ssr: false });

export function ClientOnly() {
  return <App />;
}
// app/[[...slug]]/page.tsx
import { ClientOnly } from './client';

export function generateStaticParams() {
  return [{ slug: [''] }];
}

export default function Page() {
  return <ClientOnly />;
}

ssr: false keeps the app rendering only in the browser, exactly as before, so code that touches window at import time does not break the build. Keep your <BrowserRouter> inside App. Run npm run dev, click through every screen and fix build errors (usually environment variables and imports). You now have a Next.js app that behaves like your old SPA, and you can deploy it.

Step 4: Move routes into the app directory one by one

Pick a route that benefits most from server rendering, typically a public, content-heavy page, and give it its own folder under app/. Specific routes take priority over the catch-all, so app/pricing/page.tsx is served by Next.js while everything else still falls through to the SPA. Remove the matching <Route> from React Router as you go, and repeat until the catch-all can be deleted.

React Router to App Router mapping

React RouterNext.js App Router
<Route path="/about">app/about/page.tsx
/products/:idapp/products/[id]/page.tsx, read from params
/docs/*app/docs/[...slug]/page.tsx
Parent route with <Outlet />layout.tsx with {children}
<Link to="/x"><Link href="/x"> from next/link
useNavigate()useRouter() from next/navigation (router.push, router.replace)
useLocation().pathnameusePathname()
useSearchParams()useSearchParams() from next/navigation (read-only), or searchParams prop on pages
useParams()params prop in pages, or useParams() in client components
<Navigate to="/login">redirect('/login') from next/navigation
loader functionAsync Server Component that fetches its own data
action functionServer Action or Route Handler
errorElementerror.tsx (a client component)
Catch-all 404 routenot-found.tsx and notFound()
React.lazy + Suspense for code splittingAutomatic per-route splitting; loading.tsx for loading states

Dynamic segments and layouts

A dynamic route receives its segment values through params. In recent Next.js versions params is a Promise, so await it:

// app/products/[id]/page.tsx
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);
  return <h1>{product.name}</h1>;
}

Shared chrome such as a dashboard sidebar goes in app/dashboard/layout.tsx. Layouts stay mounted when users move between child routes, so state in the sidebar is not lost. Route groups like app/(marketing)/ let you give sections different layouts without changing URLs.

Navigation

Replace React Router links with next/link, which prefetches routes in view. Programmatic navigation and the current path come from next/navigation, not next/router (that is the Pages Router API). These hooks only work in client components:

'use client';

import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';

export function Nav() {
  const pathname = usePathname();
  const router = useRouter();

  return (
    <nav>
      <Link href="/pricing" className={pathname === '/pricing' ? 'active' : ''}>
        Pricing
      </Link>
      <button onClick={() => router.push('/signup')}>Sign up</button>
    </nav>
  );
}

A component that calls useSearchParams() should sit inside a <Suspense> boundary, otherwise it can force the whole page to render in the browser.

Step 5: Rethink data fetching

In a React SPA, data is fetched in useEffect after the page loads. In the App Router, a Server Component can be async and fetch before any HTML is sent:

// app/blog/page.tsx
export const revalidate = 3600; // regenerate at most once an hour

export default async function BlogPage() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 },
  });
  const posts: { id: string; title: string }[] = await res.json();

  return (
    <ul>
      {posts.map((p) => <li key={p.id}>{p.title}</li>)}
    </ul>
  );
}

Caching defaults have changed between Next.js versions, so be explicit: set revalidate for content that can be a little stale, use cache: 'no-store' for data that must be fresh on every request, and call revalidatePath or revalidateTag after writes. You can also query a database or ORM directly in a Server Component; that code never reaches the browser.

You do not have to move everything to the server. Keep React Query or SWR for data that is user-specific, polled, or changed by interactions in a client-heavy screen. Both work inside client components; wrap the query client provider in a small 'use client' component and render it from your layout. A common pattern is to fetch initial data on the server and pass it as a prop to a client component.

Global state libraries such as Redux and Zustand also belong in client components. Create the store per request inside a client provider rather than as a module-level singleton, otherwise state can leak between users on the server.

Step 6: Environment variables

Next.js reads .env, .env.local and environment-specific files automatically. Only variables prefixed with NEXT_PUBLIC_ are exposed to browser code, and they are inlined at build time. Everything else stays on the server, which is exactly where API secrets should live.

BeforeAfter
process.env.REACT_APP_API_URL (CRA)process.env.NEXT_PUBLIC_API_URL
import.meta.env.VITE_API_URL (Vite)process.env.NEXT_PUBLIC_API_URL
Secret key shipped in the bundleprocess.env.API_SECRET, used only in Server Components, Route Handlers or Server Actions

Search the codebase for REACT_APP_ and import.meta.env, and treat every public variable as visible to anyone who opens DevTools.

Step 7: Browser-only code and ‘use client’ boundaries

Every component in app/ is a Server Component unless its file starts with 'use client'. Server Components cannot use state, effects, event handlers or browser APIs. Add the directive to components that need them, and remember that everything a client component imports becomes client code too.

  • Put 'use client' on the smallest interactive piece (a button, a filter panel) rather than on a whole page.
  • Access window, document and localStorage inside useEffect or event handlers, never during render.
  • Load libraries that touch the DOM on import with dynamic(() => import(...), { ssr: false }) from a client component.
  • Mark modules that must never run in the browser with the server-only package, so an accidental import fails the build.

Step 8: Styling

  • Global CSS: import it once in app/layout.tsx. Move resets and base styles there.
  • CSS Modules: work as they did in CRA and Vite (Button.module.css), in both server and client components.
  • Sass: install sass and keep your .scss files.
  • Tailwind CSS: works in both component types; follow the Next.js setup for your Tailwind version and import the stylesheet in the root layout.
  • CSS-in-JS: runtime libraries such as styled-components and Emotion only work in client components and need a style registry for server rendering. If you rely on them heavily, budget time for that setup or consider moving to CSS Modules or Tailwind, which work in Server Components.

If you imported SVGs as components (import { ReactComponent as Logo } from './logo.svg' in CRA, or a Vite SVGR plugin), either add SVGR to the Next.js config or use the SVG as an image.

Step 9: Images and fonts

Swap <img> for next/image on important images. It serves responsive sizes, lazy-loads below the fold and reserves space to avoid layout shift. Remote images need their domains listed in images.remotePatterns in the Next.js config.

import Image from 'next/image';
import { Inter } from 'next/font/google';

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

<Image src="/hero.webp" alt="Product dashboard" width={1200} height={630} priority />

next/font self-hosts Google or local fonts at build time, so you can remove the <link> tags to Google Fonts from your old HTML template and apply inter.className to <body>.

Step 10: SEO with the Metadata API

Each route can export static metadata or an async generateMetadata function, which replaces React Helmet and similar libraries:

// app/products/[id]/page.tsx
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}): Promise<Metadata> {
  const { id } = await params;
  const product = await getProduct(id);
  return {
    title: product.name,
    description: product.summary,
    alternates: { canonical: `/products/${id}` },
  };
}

Add app/sitemap.ts and app/robots.ts, keep existing URLs identical, and set up 301 redirects in the Next.js config for any path that does change. Our Next.js SEO checklist covers the rest, from canonicals to structured data.

Step 11: API calls, proxies and Route Handlers

The CRA proxy field and Vite’s server.proxy only work in development. In Next.js you have two production-ready options. For a straight pass-through, use rewrites:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  async rewrites() {
    return [{ source: '/api/:path*', destination: 'https://backend.example.com/:path*' }];
  },
};

export default nextConfig;

When you need to add a secret header, validate input or reshape a response, write a Route Handler:

// app/api/weather/route.ts
export async function GET(request: Request) {
  const city = new URL(request.url).searchParams.get('city') ?? 'London';
  const res = await fetch(`https://api.example.com/weather?q=${encodeURIComponent(city)}`, {
    headers: { Authorization: `Bearer ${process.env.WEATHER_API_KEY}` },
  });
  return Response.json(await res.json());
}

For form submissions and mutations called from your own UI, Server Actions are often simpler than a separate endpoint.

Step 12: Authentication

Tokens in localStorage are invisible to the server, so server-rendered pages cannot tell who is logged in. Move session tokens into secure, httpOnly cookies. Server Components and Route Handlers can then read them with cookies() from next/headers, and middleware (renamed proxy in newer Next.js versions) can redirect signed-out users before a protected page renders.

Treat middleware as a first gate, not the only one: check the session again wherever data is loaded or changed. Libraries such as Auth.js handle cookies, sessions and providers for you.

Step 13: Deployment

A CRA or Vite build is a folder of static files. A Next.js app with server rendering needs somewhere to run Node.js: a platform with Next.js support, a Node server running next start, or a container built with output: 'standalone'. Our guide to deploying a Next.js application on AWS walks through one route.

If you must stay on static hosting, output: 'export' produces static files, but Server Components then render only at build time and features that need a server (Route Handlers that read the request, middleware, on-demand revalidation, image optimisation by default) are unavailable.

Migration checklist

AreaDone when
SetupNext.js installed, old build tool removed, dev/build/start scripts work
Root layoutindex.html content moved to app/layout.tsx and metadata
Catch-allWhole SPA runs under app/[[...slug]] and is deployed
RoutesEach route moved to app/, removed from React Router, catch-all deleted
NavigationNo imports left from react-router-dom
DataPublic pages fetch on the server with explicit caching; client libraries kept only where useful
Env varsAll REACT_APP_/VITE_ renamed; no secrets in NEXT_PUBLIC_
Client boundaries'use client' only on interactive components; no browser APIs during render
StylingGlobal CSS in root layout; CSS-in-JS registry set up or replaced
MediaKey images on next/image; fonts on next/font
SEOMetadata per route, sitemap, robots, canonicals, 301s for changed URLs
APIDev proxy replaced with rewrites or Route Handlers
AuthSession in httpOnly cookies, checked on the server
ReleaseHosting runs Node.js (or static export accepted), monitoring and error tracking in place

Common pitfalls

Hydration mismatches

React compares the server HTML with the first client render. Values that differ between the two, such as Date.now(), Math.random(), typeof window checks, localStorage reads or locale-formatted dates, cause hydration errors. Invalid HTML nesting (a <div> inside a <p>) does too. Move browser-only values into useEffect, or render that component on the client only.

Client-only libraries breaking the build

Chart, map and editor libraries often reference window on import. Wrap them in a client component and load them with next/dynamic and ssr: false.

Putting ‘use client’ everywhere

Adding 'use client' to every file makes errors disappear, but it also recreates your old SPA with extra steps. Keep pages and layouts as Server Components, fetch there, and pass data down to small interactive islands.

Mixing up the two routers

Tutorials written for the Pages Router use getStaticProps, getServerSideProps, next/router and next/head. None of these work in the app/ directory. Check which router an example targets before copying it.

How VOCSO can help

VOCSO has been building software since 2009 and has delivered 600+ projects. Our Next.js development team built the Colleges18 and Schools18 portals on Next.js with server rendering and programmatic SEO, and we migrate React apps route by route so your product keeps shipping during the move. If you need extra hands on an existing team, you can hire Next.js developers under NDA from day one. Tell us about your React app and we will scope the migration with you.

FAQ

Can I convert a React app to Next.js automatically?

No tool converts a React app to idiomatic Next.js in one step. The catch-all route approach gets your existing app running in Next.js quickly, then you move routes and data fetching by hand, one page at a time.

Do I have to rewrite my React components?

Mostly no. Components, hooks and styles carry over. The main changes are routing, data fetching, environment variables and adding 'use client' to components that use state, effects or browser APIs.

Is migrating from Create React App different from Vite?

The process is the same. The differences are in the details you remove: react-scripts and the REACT_APP_ prefix for CRA, the Vite config, plugins and import.meta.env for Vite.

Should I use the App Router or the Pages Router for a new migration?

Use the App Router. It is the recommended router for new work and supports Server Components, nested layouts and the Metadata API. Migrating to the Pages Router now would mean a second migration later.

Will migrating to Next.js improve my SEO?

It removes the biggest technical barrier, empty HTML on first load, and gives you proper metadata and sitemap tools. Rankings still depend on content, links and keeping existing URLs intact.

Related reading

Deepak Chauhan About Deepak Chauhan I am a technology strategist at VOCSO with 20 years of experience in full-stack development. Specializing in Python, the MERN stack, Node.js, and Next.js, I architect scalable, high-performance applications and custom solutions. I excel at transforming ideas into innovative digital products that drive business success.


Further Reading...

We use cookies to give you the best online experience. By using our website you agree to use of cookies in accordance with VOCSO cookie policy. I Accept Cookies