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.
- Blazing-fast dev server with instant HMR
- Minimal configuration and simple build setup
- Excellent developer experience for SPA-style applications
However, as the project grew, several requirements pushed us toward a more full-featured framework.
- SEO & social sharing: We needed server-side rendering (SSR) and static generation (SSG).
- Routing conventions: Maintaining large React Router configurations was becoming harder to scale.
- Backend logic near the UI: We wanted APIs closer to the frontend without running a separate backend service for everything.
- Deployment simplicity: A framework with clear production conventions simplifies CI/CD and hosting.
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)
flowchart 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)
flowchart 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.
- Create a new Next.js project alongside the existing Vite project.
- Mirror the component structure.
- Convert routing from React Router to file-based routing.
- Move reusable components first.
- Gradually migrate pages.
- Remove Vite after all critical flows were stable.
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.
npx 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)
import { 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 -> /about
Example page:
export default function HomePage() {
return <div>Home</div>;
}
Link Migration
React Router:
import { Link } from "react-router-dom";
<Link to="/about">About</Link>
Next.js:
import 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.tsx
Next.js structure:
src/
components/
Button.tsx
Layout.tsx
app/
page.tsx
Only components depending on React Router required changes.
Examples:
useNavigateuseLocation
These were replaced with:
next/navigationLink- layout-based navigation
Step 4: Handling Static Assets
In Vite:
import logo from "/logo.svg";
In Next.js, assets are served from the public folder.
<img src="/logo.svg" alt="Logo" />
Or using the optimized image component:
import 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_URL
Next.js requires browser-exposed variables to start with NEXT_PUBLIC_.
Example .env change:
VITE_API_URL=https://api.example.com
becomes:
NEXT_PUBLIC_API_URL=https://api.example.com
Usage in code:
const 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.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
Usage:
import 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:
export 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
windowaccess localStorageduring render- DOM-only libraries
Example fix.
Before:
const theme = localStorage.getItem("theme")
After:
const [theme, setTheme] = useState<string | null>(null)
useEffect(() => {
setTheme(localStorage.getItem("theme"))
}, [])
Handling Browser-Only Libraries
For components requiring the DOM we used dynamic imports.
import dynamic from "next/dynamic"
const Map = dynamic(() => import("@/components/Map"), {
ssr: false
})
Step 9: Updating Build Scripts
Original Vite scripts:
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}
Next.js scripts:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
}
}
CI pipeline changes:
- replace
vite buildwithnext build - ensure Node version compatibility
- verify
.nextoutput 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:3000
Once 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.