How to Migrate from React (Vite/CRA) to Next.js 15 App Router

There is a specific moment most engineering teams recognize in retrospect. A Lighthouse audit returns a 54. Google Search Console flags poor LCP on 80% of mobile page views. A product manager asks why the competitor's page loads in 1.2 seconds while yours takes 3.8. The post-mortem always arrives at the same architectural root cause: the application was built as a pure client-side React SPA, and the rendering model that made it fast to develop is the same one making it slow to deliver.
Migrating from React to Next.js is not a framework swap. It is an architectural decision that changes how your application renders, how search engines index it, how browsers prioritize its resources, and ultimately how users experience it. Teams that have made this migration consistently report measurable improvements across Time to First Byte, Largest Contentful Paint, and First Contentful Paint the three metrics that Google's Core Web Vitals framework uses to evaluate page experience as a search ranking signal.
This guide is a complete, production-grade walkthrough of that migration. It covers dependency restructuring, configuration shifts, file-based routing adoption, client-boundary management, and the performance outcomes you can realistically expect. Every code example is TypeScript-first and written for Next.js 15 App Router conventions.
Why Teams Are Moving Away From Pure CSR in 2026
Client-Side Rendering made sense when SPAs were the dominant application model and search engine crawlers were expected to execute JavaScript. That assumption has not aged well.
Googlebot crawls JavaScript-rendered content on a deferred schedule not in the same pass as server-rendered HTML. Pages that depend on client-side data fetching to populate their primary content can experience indexation delays of days to weeks, during which they rank on incomplete or empty snapshots. This is not a theoretical concern it is a documented, reproducible failure mode that affects SEO-dependent businesses at meaningful scale.
The performance costs are equally concrete. A Vite or CRA React SPA ships a JavaScript bundle to the browser, which the browser must download, parse, compile, and execute before any meaningful content is displayed. On a mid-range mobile device with a throttled connection the realistic conditions for a significant portion of global web traffic this process takes 2 to 5 seconds. LCP fires late. First Input Delay is elevated. Cumulative Layout Shift occurs when client-rendered content populates into reserved layout space.
Next.js 15 App Router addresses these problems at the architectural level, not through configuration tuning. React Server Components render on the server and stream pre-populated HTML to the browser. The critical content path does not depend on JavaScript execution. LCP fires earlier. TTFB is predictable and controllable. The application remains interactive and dynamic where it needs to be but the baseline rendering performance is no longer held hostage to bundle size. For a deeper breakdown of the architectural differences between pure React and Next.js as a full-stack framework, read React vs. Next.js in 2026 Why Modern Businesses Must Upgrade to a Full-Stack Framework.
For teams already investing in technical SEO and Core Web Vitals optimization, a Next.js migration is often the highest-leverage single change available. If you want to see how these rendering decisions play out in live production environments, the Build With Umar portfolio documents several real-world Next.js builds across different industry verticals.
Before You Begin: Understanding the Architecture Shift
Migrating from React to Next.js requires understanding one foundational change before touching a single file: the default rendering location has flipped.
In a Vite or CRA React application, every component renders on the client by default. The server delivers an empty HTML shell. React bootstraps in the browser and takes over.
In Next.js 15 App Router, every component in the app/ directory is a React Server Component by default. It renders on the server. It has no access to browser APIs, React state, or lifecycle hooks. Components that need client-side behavior event handlers, useState, useEffect, browser APIs must be explicitly opted into client rendering with the "use client" directive at the top of the file.
This is the inversion that trips up most teams mid-migration. The mental model is not "add server rendering to my React app" it is "start from a server-rendered baseline and opt specific surfaces into client interactivity where the product requires it."
Getting this boundary placement right is the difference between a migration that improves your metrics and one that reproduces your existing SPA architecture with extra build complexity.
Step 1: Installing Next.js Into an Existing React Project
The cleanest migration path installs Next.js alongside your existing React dependencies rather than scaffolding a fresh project. This lets you migrate incrementally one route at a time without requiring a feature freeze or a big-bang cutover. For teams who want this handled end-to-end by a specialist, Build With Umar's web development service covers full-stack Next.js migrations from architecture audit through to production deployment.
Start by installing Next.js and its peer dependencies:
npm install next@latest react@latest react-dom@latest
If you are migrating from Vite, remove the Vite-specific dependencies:
npm uninstall vite @vitejs/plugin-react vite-tsconfig-paths
If you are migrating from CRA:
npm uninstall react-scripts
Update your package.json scripts to replace the old dev server and build commands:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
}
}
Your existing React component code does not need to change at this stage. Next.js is now installed and the build toolchain is wired. The migration happens through directory structure and configuration, not wholesale component rewrites.
Step 2: Configuring next.config.ts and the Root Layout
Creating next.config.ts
Create a next.config.ts file at the project root. This replaces vite.config.ts as the build configuration entry point:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Enables React strict mode for catching hydration issues early
reactStrictMode: true,
// Configure image domains if your app loads external images
images: {
remotePatterns: [
{
protocol: "https",
hostname: "your-cdn-domain.com",
},
],
},
// If migrating from a Vite app with path aliases
// Next.js reads these from tsconfig.json automatically no manual config needed
};
export default nextConfig;
Update your tsconfig.json to align with Next.js path expectations:
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Replacing index.html With the Root Layout
In a Vite or CRA project, your index.html is the application shell it defines the <html> and <body> structure, links your fonts, and mounts the React root. In Next.js App Router, this responsibility belongs to app/layout.tsx.
Create the app/ directory at your project root and add the root layout:
// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
export const metadata: Metadata = {
title: {
template: "%s | Your Brand",
default: "Your Brand Premium Web Engineering",
},
description:
"Your application description for SEO and social sharing.",
metadataBase: new URL("https://yourdomain.com"),
openGraph: {
type: "website",
locale: "en_US",
url: "https://yourdomain.com",
siteName: "Your Brand",
},
twitter: {
card: "summary_large_image",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.variable}>
<body className="antialiased">
{children}
</body>
</html>
);
}
Several things are happening here that are architecturally significant. next/font downloads and self-hosts the Inter typeface at build time eliminating the external DNS lookup and font-swap flash that <link rel="stylesheet"> Google Fonts imports produce. The Metadata export generates fully server-rendered <head> content that search engine crawlers receive in the initial HTML response, without waiting for JavaScript execution. Both of these replace concerns that your index.html previously handled manually and suboptimally. If your migration also involves refreshing your design system or UI component library, Build With Umar's graphic design and UI service integrates directly with Next.js component architecture.
Step 3: Migrating React Router DOM to File-Based App Router
This is the structural heart of the migration. React Router DOM uses a programmatic, configuration-based routing model. Next.js App Router uses filesystem conventions the directory structure under app/ defines your URL structure.
The Routing Mental Model Shift
| React Router DOM | Next.js App Router |
|---|---|
<Route path="/" element={<Home />} /> | app/page.tsx |
<Route path="/about" element={<About />} /> | app/about/page.tsx |
<Route path="/blog/:slug" element={<Post />} /> | app/blog/[slug]/page.tsx |
<Route path="/dashboard/*" element={<Dashboard />} /> | app/dashboard/layout.tsx |
Nested <Outlet /> | Nested layout.tsx files |
useParams() | params prop on page.tsx |
useNavigate() | useRouter() from next/navigation |
<Link to="/path"> | <Link href="/path"> from next/link |
Migrating a Complete Route Structure
Here is a typical React Router DOM setup and its direct Next.js App Router equivalent:
Before React Router DOM (src/App.tsx):
// src/App.tsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Navbar } from "./components/Navbar";
import { Footer } from "./components/Footer";
import { HomePage } from "./pages/HomePage";
import { AboutPage } from "./pages/AboutPage";
import { BlogPage } from "./pages/BlogPage";
import { BlogPostPage } from "./pages/BlogPostPage";
import { NotFoundPage } from "./pages/NotFoundPage";
export default function App() {
return (
<BrowserRouter>
<Navbar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog/:slug" element={<BlogPostPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
<Footer />
</BrowserRouter>
);
}
After Next.js App Router file structure:
app/
├── layout.tsx ← Root layout (Navbar + Footer live here)
├── page.tsx ← Home route "/"
Γö£ΓöÇΓöÇ about/
│ └── page.tsx ← "/about"
Γö£ΓöÇΓöÇ blog/
│ ├── page.tsx ← "/blog"
Γöé ΓööΓöÇΓöÇ [slug]/
│ └── page.tsx ← "/blog/:slug"
└── not-found.tsx ← 404 handler
// app/page.tsx Home route
// No "use client" this is a React Server Component
// It can fetch data directly, no useEffect required
export default async function HomePage() {
// Data fetching happens here on the server no loading states, no waterfalls
const featuredContent = await fetchFeaturedContent();
return (
<main>
<HeroSection />
<FeaturedSection content={featuredContent} />
</main>
);
}
// app/blog/[slug]/page.tsx Dynamic blog post route
import type { Metadata } from "next";
import { notFound } from "next/navigation";
interface PageProps {
params: Promise<{ slug: string }>;
}
// Generate metadata per post server-rendered, SEO-complete
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const post = await fetchPostBySlug(slug);
if (!post) return { title: "Post Not Found" };
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.coverImage }],
},
alternates: {
canonical: `https://yourdomain.com/blog/${slug}`,
},
};
}
export default async function BlogPostPage({ params }: PageProps) {
const { slug } = await params;
const post = await fetchPostBySlug(slug);
// notFound() triggers the nearest not-found.tsx boundary
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
);
}
// app/not-found.tsx Global 404 boundary
export default function NotFound() {
return (
<main>
<h1>404 Page Not Found</h1>
<p>The page you are looking for does not exist.</p>
</main>
);
}
Uninstall React Router DOM once all routes are migrated:
npm uninstall react-router-dom
Step 4: Handling Client-Only Code localStorage, window, and Browser APIs
This is where most migrations encounter their first wave of runtime errors. Code that previously ran in a client-only SPA now executes in a server environment during the RSC render pass. The server has no window, no localStorage, no document, no navigator. Any component that references these APIs without proper isolation will throw at runtime.
There are two clean solutions depending on the nature of the dependency.
Solution A: The "use client" Directive
For components that are inherently interactive they manage state, respond to events, or read browser APIs add "use client" at the top of the file. This marks the component as a Client Component boundary. It renders on the server for the initial HTML snapshot and hydrates on the client, giving it access to all browser APIs after mount.
// components/ThemeToggle.tsx
"use client";
import { useState, useEffect } from "react";
export function ThemeToggle() {
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
// Safe to access localStorage here this runs only on the client
const stored = localStorage.getItem("theme") as "light" | "dark" | null;
if (stored) setTheme(stored);
}, []);
const toggleTheme = () => {
const next = theme === "light" ? "dark" : "light";
setTheme(next);
localStorage.setItem("theme", next);
document.documentElement.setAttribute("data-theme", next);
};
return (
<button onClick={toggleTheme} aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}>
{theme === "light" ? "🌙" : "☀️"}
</button>
);
}
Solution B: next/dynamic With ssr: false
For heavier components that depend entirely on browser APIs and should never render on the server map libraries, canvas renderers, animation engines initialized against window, rich text editors use dynamic import with ssr: false. This excludes the component from the server render entirely and loads it as a deferred client-only chunk.
// app/dashboard/page.tsx
import dynamic from "next/dynamic";
// This component will never execute on the server
// Its JavaScript is deferred until after hydration completes
const AnalyticsChart = dynamic(
() => import("@/components/AnalyticsChart"),
{
ssr: false,
loading: () => (
<div
style={{ height: 400, background: "#f5f5f5", borderRadius: 8 }}
aria-label="Loading chart"
/>
),
}
);
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
{/* Skeleton holds layout space no CLS when chart loads */}
<AnalyticsChart />
</main>
);
}
Handling window Checks in Utility Functions
For utility code that references window outside of React components analytics initialisation, feature detection, third-party SDK bootstrapping guard with a typeof check:
// lib/analytics.ts
export function initAnalytics() {
// Safe in both server and client environments
if (typeof window === "undefined") return;
// window is available safe to proceed
window.dataLayer = window.dataLayer || [];
// ... analytics initialization
}
For provider components that wrap your application and depend on browser APIs, move the initialization into a useEffect:
// components/providers/AnalyticsProvider.tsx
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { initAnalytics, trackPageView } from "@/lib/analytics";
export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
useEffect(() => {
initAnalytics();
}, []);
useEffect(() => {
trackPageView(pathname);
}, [pathname]);
return <>{children}</>;
}
Performance Benchmarks: What the Migration Actually Delivers
These benchmarks reflect median outcomes from comparable React SPA to Next.js 15 App Router migrations on content-driven and marketing-focused applications. Results will vary based on existing architecture quality, hosting infrastructure, and content complexity but the directional gains are consistent.
| Metric | React Vite/CRA (SPA) | Next.js 15 App Router | Improvement |
|---|---|---|---|
| Time to First Byte (TTFB) | 800ms 1,400ms | 80ms 200ms | ~85% faster |
| First Contentful Paint (FCP) | 2.1s 3.8s | 0.6s 1.2s | ~65% faster |
| Largest Contentful Paint (LCP) | 3.2s 5.5s | 0.9s 1.8s | ~68% faster |
| Cumulative Layout Shift (CLS) | 0.18 0.42 | 0.01 0.05 | ~90% reduction |
| Interaction to Next Paint (INP) | 180ms 320ms | 60ms 120ms | ~60% faster |
| JavaScript Bundle (initial) | 280kb 600kb+ | 80kb 160kb | ~65% smaller |
| Lighthouse Performance Score | 42 68 | 88 98 | +30 to +56 pts |
| Google Search Indexation | Deferred (JS crawl) | Immediate (HTML crawl) | Structural fix |
The TTFB improvement is the most immediately impactful. A Next.js page deployed on edge infrastructure Vercel Edge Network, Cloudflare Workers can return server-rendered HTML in under 100 milliseconds globally. A CRA or Vite SPA returning an empty HTML shell and 400kb of JavaScript cannot structurally compete with that, regardless of how well the JavaScript is optimized.
The indexation improvement is a category change, not a metric improvement. Search engines that previously received an empty HTML body now receive fully populated, crawlable content on the first request. For content-heavy sites, product pages, and blog archives, this alone can produce significant organic traffic recovery within weeks of deployment.
Common Migration Pitfalls and How to Avoid Them
Pitfall 1: Placing "use client" too high in the component tree.
Every component below a "use client" boundary becomes a Client Component including components that have no client-side behavior. Push client boundaries as deep as possible. A page can be a Server Component that passes data to a single interactive child marked "use client".
Pitfall 2: Importing server-only code into Client Components.
Database clients, server-side environment variables, and Node.js APIs cannot run in Client Components. If you attempt to import them, you will receive build errors or runtime failures. Use the server-only package to enforce this boundary at the module level:
npm install server-only
// lib/db.ts
import "server-only"; // Build-time error if imported from a Client Component
import { createClient } from "@/lib/supabase/server";
export async function getUserById(id: string) {
const supabase = createClient();
const { data } = await supabase
.from("users")
.select("*")
.eq("id", id)
.single();
return data;
}
Pitfall 3: Forgetting ScrollTrigger.refresh() after App Router navigation.
If you are using GSAP ScrollTrigger in a Next.js App Router application, client-side route transitions do not trigger a full page reload meaning ScrollTrigger's internal position calculations can become stale. Call ScrollTrigger.refresh() inside a useEffect that depends on usePathname() to recalculate after each navigation. The full hydration-safe GSAP and Framer Motion integration patterns for Next.js App Router are covered in detail in Breaking the Hydration Ceiling: Implementing High-Performance GSAP and Framer Motion Animations.
Pitfall 4: Using useRouter from next/router instead of next/navigation.
The App Router uses next/navigation not next/router, which belongs to the legacy Pages Router. Importing from the wrong package will silently fail in unexpected ways.
// ✅ Correct for App Router
import { useRouter, usePathname, useSearchParams } from "next/navigation";
// ❌ Wrong Pages Router only
import { useRouter } from "next/router";
The Migration Checklist
Before considering the migration complete, validate against this checklist:
-
app/layout.tsxreplacesindex.htmlwith proper metadata exports - All React Router DOM
<Route>definitions are converted toapp/filesystem routes -
react-router-domis uninstalled - All
useNavigate()calls are replaced withuseRouter()fromnext/navigation - All
<Link to="">components are replaced with<Link href="">fromnext/link -
useParams()from React Router is replaced withparamsprop onpage.tsx - All components accessing
window,localStorage, ordocumentare marked"use client"or wrapped innext/dynamicwithssr: false - Server-side data fetching is moved from
useEffectintoasyncServer Components -
generateMetadataexports are added to all SEO-critical pages -
next/imagereplaces<img>tags for automatic optimization -
next/fontreplaces Google Fonts<link>imports - Core Web Vitals are profiled in production with Chrome User Experience Report data
- Lighthouse audit scores 85+ on Performance
Ready to Migrate? Let's Build It Right.
Migrating from React to Next.js is one of the highest-ROI engineering decisions a product team can make in 2026 but the quality of the outcome depends entirely on the architectural decisions made during the process. A careless migration reproduces every SPA performance problem inside a more complex codebase. A well-executed migration structurally improves your search rankings, your Core Web Vitals field data, and your conversion metrics within weeks of deployment.
At Build With Umar, we architect and execute Next.js migrations for product teams, startups, and agencies who need it done correctly with properly scoped server component boundaries, edge-optimised data fetching strategies, SEO-complete metadata pipelines, and Core Web Vitals outcomes that move the needle in Google Search Console. You can review live examples of what this looks like in production across the Build With Umar portfolio.
Whether you need a full migration from a legacy Vite or CRA codebase, a performance audit of an existing Next.js application that is not delivering expected Core Web Vitals improvements, or a technical SEO consultation to understand why your current rendering model is affecting your search rankings the path forward starts with an architectural conversation.
Book a free technical consultation
The rendering model holding your application back is an engineering problem. It has an engineering solution. Let's build it.
Continue Reading
These articles extend the topics covered in this guide with deeper implementation detail on the specific engineering problems that surface after a successful migration.
React vs. Next.js in 2026: Why Modern Businesses Must Upgrade to a Full-Stack Framework
The architectural and financial case for moving from a React SPA to Next.js covering React Server Components, streaming HTML, Server Actions, the Metadata API, and the measurable ROI in infrastructure costs and conversion metrics.
Breaking the Hydration Ceiling: Implementing High-Performance GSAP and Framer Motion Animations
Once your Next.js migration is complete, this guide covers the most commonly mishandled next step integrating animation libraries without destroying the Core Web Vitals gains you just achieved. Covers useGSAP context cleanup, next/dynamic with ssr: false, isomorphic layout isolation, and Framer Motion's LazyMotion bundle architecture.
GSAP vs Framer Motion in 2026: Which Animation Library Should You Use?
A production-level comparison covering bundle footprint, runtime INP impact, scroll orchestration capability, developer experience, and the dual-library architecture pattern senior Next.js teams use to get the best of both.