Migrating a Production React App from Vite to Next.js
Why We Moved From Vite to Next.js
Vite served us really well in the early stages of development.
However, as the project grew, several requirements pushed us toward a more full-featured framework.
In short:
> Vite is excellent for building fast client-side applications, but we needed a full-stack React framework with built-in rendering strategies.
---
Architecture Before Migration (Vite SPA)
bashflowchart LR: U[User] --> B[Browser] B --> CDN[CDN / Static Hosting] CDN --> S["React SPA (Vite Build)"] S --> A["Backend API"] A --> DB[(Database)]
Characteristics
* Client-side rendering only
* Initial HTML is mostly empty
* Data fetched after page load
* SEO depends heavily on client-side execution
---
Architecture After Migration (Next.js Hybrid Rendering)
bashflowchart LR: User --> Browser Browser --> NextServer[Next.js Server] NextServer --> Render[SSR / SSG Rendering] Render --> API[API Routes or External APIs] API --> DB[(Database)]
Characteristics
* Server-side rendering
* Static generation for public pages
* API routes colocated with UI
* Improved SEO and performance
---
High-Level Migration Strategy
Instead of rewriting the application, we chose a **phased migration strategy**.
This allowed both applications to run side-by-side during development.
---
Step 1: Creating the Next.js Project
We started by scaffolding a new Next.js application.
bashnpx create-next-app@latest
Configuration choices:
* TypeScript enabled
* ESLint enabled
* App Router enabled
* Import alias enabled (`@/*`)
We then recreated a structure similar to the existing Vite project.
src/
components/
lib/
utils/
public/This made it easier to move files without large refactors.
---
Step 2: Routing Migration
Routing in Vite (React Router)
tsximport { BrowserRouter, Routes, Route } from "react-router-dom"; import Home from "./pages/Home"; import About from "./pages/About"; ReactDOM.createRoot(document.getElementById("root")!).render( <BrowserRouter> <Routes> <Route path="/" element={<Home />} /> <Route path="/about" element={<About />} /> </Routes> </BrowserRouter> );
Routing in Next.js
Next.js uses **file-based routing**.
app/
page.tsx -> /
about/
page.tsx -> /aboutExample page:
tsxexport default function HomePage() { return <div>Home</div>; }
---
Link Migration
React Router:
tsximport { Link } from "react-router-dom"; <Link to="/about">About</Link>
Next.js:
tsximport Link from "next/link"; <Link href="/about">About</Link>
After migration, React Router was completely removed.
---
Step 3: Moving Shared Components
Most UI components migrated **without changes**.
Vite structure:
src/
components/
Button.tsx
Layout.tsx
pages/
Home.tsxNext.js structure:
src/
components/
Button.tsx
Layout.tsx
app/
page.tsxOnly components depending on React Router required changes.
Examples:
* `useNavigate`
* `useLocation`
These were replaced with:
* `next/navigation`
* `Link`
* layout-based navigation
---
Step 4: Handling Static Assets
In Vite:
tsximport logo from "/logo.svg";
In Next.js, assets are served from the `public` folder.
tsx<img src="/logo.svg" alt="Logo" />
Or using the optimized image component:
tsximport Image from "next/image"; <Image src="/logo.svg" alt="Logo" width={120} height={40} />
---
Step 5: Environment Variables
Vite uses:
import.meta.env.VITE_API_URLNext.js requires browser-exposed variables to start with `NEXT_PUBLIC_`.
Example `.env` change:
VITE_API_URL=https://api.example.combecomes:
NEXT_PUBLIC_API_URL=https://api.example.comUsage in code:
tsconst apiUrl = process.env.NEXT_PUBLIC_API_URL
---
Step 6: Replacing Vite Plugins
Many Vite plugins were no longer required.
Path Aliases
Configured in `tsconfig.json`.
json{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }
Usage:
tsimport Button from "@/components/Button"
---
Code Splitting
Next.js automatically performs:
* route-based code splitting
* bundle optimization
* lazy loading
This required no manual configuration.
---
SVG Handling
Options used in Next.js:
* store SVGs in `public/`
* import as components using loaders
* render using `next/image`
---
Step 7: Introducing SSR and Static Generation
One of the biggest advantages of Next.js is **hybrid rendering**.
We categorized routes into three types:
| Page Type | Rendering Strategy |
| ----------------- | ----------------------------------- |
| Marketing pages | Static generation |
| Blog pages | Static generation with revalidation |
| Dashboards | Server rendering |
| Interactive tools | Client-side rendering |
Example of static generation:
tsxexport async function generateStaticParams() { const res = await fetch("https://api.example.com/posts") const posts = await res.json() return posts.map((post: any) => ({ slug: post.slug })) }
Benefits:
* improved SEO
* faster first paint
* reduced client-side JavaScript
---
Step 8: Fixing Client-Side Assumptions
Migrating from a SPA exposed several issues.
Common examples:
* direct `window` access
* `localStorage` during render
* DOM-only libraries
Example fix.
Before:
tsxconst theme = localStorage.getItem("theme")
After:
tsxconst [theme, setTheme] = useState<string | null>(null) useEffect(() => { setTheme(localStorage.getItem("theme")) }, [])
---
Handling Browser-Only Libraries
For components requiring the DOM we used dynamic imports.
tsximport dynamic from "next/dynamic" const Map = dynamic(() => import("@/components/Map"), { ssr: false })
---
Step 9: Updating Build Scripts
Original Vite scripts:
json{ "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" } }
Next.js scripts:
json{ "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint" } }
CI pipeline changes:
* replace `vite build` with `next build`
* ensure Node version compatibility
* verify `.next` output works with hosting platform
---
Step 10: Testing for Behavioral Parity
Before fully switching, we ensured both apps behaved identically.
Testing included:
* authentication flows
* dashboards
* public pages
* API requests
During migration both apps ran simultaneously.
Vite app -> localhost:5173
Next app -> localhost:3000Once parity was achieved:
* Vite configuration was removed
* dependencies cleaned up
* old scripts deleted
---
Performance Improvements
| Metric | Vite SPA | Next.js |
| ---------------------- | ----------------- | --------------------- |
| First Contentful Paint | ~2.4s | ~1.2s |
| Time to Interactive | ~3.1s | ~1.8s |
| SEO Crawlability | Limited | Full HTML |
| Bundle Strategy | Single SPA bundle | Route-based splitting |
| Initial HTML | Minimal shell | Fully rendered |
Key improvements:
* faster first paint
* improved SEO indexing
* reduced client-side JavaScript
---
Final Project Structure
project-root/
app/
layout.tsx
page.tsx
dashboard/
page.tsx
blog/
[slug]/
page.tsx
src/
components/
Navbar.tsx
Button.tsx
Card.tsx
lib/
api.ts
utils.ts
hooks/
public/
next.config.js
tsconfig.json
package.json---
Lessons Learned
* Start with a clean Next.js project.
* Move framework-agnostic code first.
* Migrate routing early.
* Expect SSR compatibility issues.
* Introduce Next.js features incrementally.
---
When Should You Move From Vite to Next.js?
You should consider switching if:
* SEO matters
* your application has many routes
* you want server-side rendering
* you want backend logic near the UI
You should probably stay with Vite if:
* your application is a pure SPA
* SEO does not matter
* you already have a separate backend
* simplicity is more important than framework features
Both tools are excellent — they simply solve **different problems**.
---
Final Thoughts
Vite provided an incredible development experience and allowed us to move quickly in the early stages.
However, as our application grew, we needed:
* better SEO
* server-side rendering
* stronger routing conventions
* tighter frontend-backend integration
Next.js provided these capabilities **without forcing us to abandon React or rewrite our components**.
The migration was not a rewrite.
It was a **structured evolution of the architecture**.
Same React components.
Better rendering strategy.
A more scalable platform.