Vite to Next.js 16 Migration: What I Learned (54% Smaller Bundle, 96 Lighthouse)
Last month, I made a decision that changed everything about how my portfolio performs: I migrated from Vite to Next.js 16.
Was it worth the 15 hours of refactoring? Absolutely. Here's why.
Why I Left Vite (And Why You Might Too)
Don't get me wrong Vite is incredible. The dev server is blazing fast, hot module replacement is instant, and the DX is phenomenal. But I started noticing problems when I ran Lighthouse audits.
My Performance score was stuck at 78. My blog posts took 1.8 seconds to show content. And Google Search Console was showing inconsistent indexing for new posts.
The root cause? My site was a Single Page Application (SPA). Everything happened in the browser:
- Browser downloads an empty HTML file
- Browser downloads 185KB of JavaScript
- Browser parses and executes React
- React renders the UI
- Components call
useEffectto fetch data - Finally, content appears
That's a lot of waiting for users on slow connections.
The Numbers That Convinced Me
Before I touched a single line of code, I established baseline metrics. Here's what changed after the migration:
JavaScript Bundle
Before: 185 KB → After: 84 KB | ↓ 54%
Time to First Content
Before: 1.8s → After: 0.8s | ↓ 55%
Lighthouse Performance
Before: 78 → After: 96 | +24%
SEO Score
Before: 85 → After: 100 | +18%
Build Time
Before: 8.2s → After: 2.6s | ↓ 68%
These aren't vanity metrics. Google uses Core Web Vitals as a ranking signal. Sites that load faster get prioritized in search results.
What Next.js 16 Does Differently
The fundamental shift is this: components run on the server by default.
Instead of shipping JavaScript to the browser to render content, Next.js renders HTML on the server (or at build time) and sends that. The browser gets a fully-formed page immediately.
Before: Client-Side Rendering (Vite)
Here's what my blog list looked like in Vite:
// This entire file becomes part of the JavaScript bundle
import { useState, useEffect } from 'react';
export default function BlogPage() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// This runs AFTER the page loads
const modules = import.meta.glob('../content/blog/*.mdx', { eager: true });
const postData = Object.entries(modules).map(([path, mod]) => ({
slug: path.split('/').pop().replace('.mdx', ''),
...mod.frontmatter,
}));
setPosts(postData);
setLoading(false);
}, []);
if (loading) return <div>Loading...</div>;
return (
<div>
{posts.map(post => (
<article key={post.slug}>
<h2>{post.title}</h2>
</article>
))}
</div>
);
}
Problem: Users see "Loading..." while JavaScript fetches and processes blog posts. Google's crawler might not wait for that.
After: Server Components (Next.js 16)
// This runs on the SERVER, not in the browser
import { getAllPosts } from '@/lib/blog';
export default function BlogPage() {
const posts = getAllPosts(); // Direct file system access
return (
<div>
{posts.map(post => (
<article key={post.slug}>
<h2>{post.title}</h2>
</article>
))}
</div>
);
}
Result: The HTML arrives with blog posts already rendered. Zero loading states. Zero JavaScript needed for this component.
The Migration Process: What I Actually Did
Step 1: Set Up Next.js
I didn't delete my Vite config immediately. I created a new branch and started fresh:
# Removed Vite dependencies
npm uninstall vite @vitejs/plugin-react react-router-dom
# Added Next.js
npm install next@latest react@latest react-dom@latest
# Updated package.json scripts
"dev": "next dev --turbo"
"build": "next build"
Step 2: Restructure Routes
Next.js uses file-system routing. Instead of defining routes in code, the folder structure is the router.
Migration mapping:
src/pages/Home.tsx→src/app/page.tsxsrc/pages/Blog.tsx→src/app/blog/page.tsxsrc/pages/BlogPost.tsx→src/app/blog/[slug]/page.tsx
I deleted my entire react-router-dom setup. The routing logic is now implicit.
Step 3: Mark Interactive Components
This was the trickiest part. In Next.js 16, components are Server Components by default. If a component uses useState, useEffect, or browser APIs, you must add 'use client' at the top.
I went through every component and asked: "Does this need to run in the browser?"
Stayed as Server Components:
- Footer (static content)
- Experience section (just rendering data)
- Blog post content (MDX rendered on server)
Became Client Components:
- Navbar (handles menu toggle state)
- FAQ accordion (uses
useStatefor open/close) - Code copy button (uses
navigator.clipboard)
Step 4: Replace Data Fetching
I replaced all useEffect data fetching with server-side file reading:
// Old: Client-side fetching
useEffect(() => {
fetch('/api/posts').then(res => res.json()).then(setPosts);
}, []);
// New: Server-side reading
import fs from 'fs';
const posts = getAllPosts(); // Reads from file system directly
The blog posts are now generated at build time using generateStaticParams:
export async function generateStaticParams() {
const posts = getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
Next.js pre-renders every blog post as a static HTML file. When someone visits /blog/migrating-vite-to-nextjs-16, they get an instant response from the CDN.
The Gotchas I Ran Into
1. params is Now Async
In Next.js 15+, accessing route parameters requires await:
// ❌ This breaks
export default function Page({ params }) {
const post = getPostBySlug(params.slug);
}
// ✅ This works
export default async function Page({ params }) {
const { slug } = await params;
const post = getPostBySlug(slug);
}
I spent an hour debugging build failures before I realized this.
2. window Doesn't Exist on the Server
This crashed my build:
function scrollToTop() {
window.scrollTo(0, 0); // ❌ ReferenceError: window is not defined
}
The fix: wrap browser code in useEffect:
'use client';
import { useEffect } from 'react';
function ScrollToTop() {
useEffect(() => {
window.scrollTo(0, 0); // ✅ Only runs in browser
}, []);
return null;
}
3. Image Paths Changed
Vite uses src/assets/, but Next.js serves static files from public/:
<!-- Old -->

<!-- New -->

I had to move all images to public/assets/ and update references in every blog post.
The Results: Lighthouse Scores
Here's the side-by-side comparison:
Vite (Before):
- Performance: 78
- Accessibility: 92
- Best Practices: 83
- SEO: 85
Next.js 16 (After):
- Performance: 96 🚀
- Accessibility: 95
- Best Practices: 100 ✅
- SEO: 100 ✅
Perfect SEO score means Google can crawl and index my content instantly. No JavaScript execution required.
What I Learned: Practical Takeaways
1. Start with the data layer, not components
Don't refactor UI first. Fix how you fetch data. Move from useEffect to server-side fetching. The component changes will follow naturally.
2. Be aggressive about Server Components
I initially marked too many components with 'use client' out of fear. After auditing, I realized 60% of them didn't need it. Each removal saved kilobytes.
3. Static generation = game changer
For blogs, documentation, portfolios—anything that doesn't change per-user—generate static HTML at build time. It's the fastest possible solution.
4. Turbopack is genuinely fast
Build time dropped from 8.2s to 2.6s. Dev server starts instantly. This alone improves developer productivity.
Should You Migrate?
Yes, if your site is:
- Content-heavy (blog, portfolio, docs)
- Struggling with SEO
- Loading slowly on mobile
- Built as an SPA but doesn't need to be
No, if your site is:
- A highly interactive web app (like Figma or Notion)
- Already server-rendered with another framework you like
- Small enough that bundle size doesn't matter
The Bottom Line
The migration took me 15 hours spread over 3 days. The payoff:
- Users see content 2x faster
- Google crawls my site perfectly
- My Lighthouse score went from "okay" to "excellent"
- My JavaScript bundle is half the size
For any content-driven site, Next.js 16 isn't just an upgrade—it's the new standard for web performance.
If you're on the fence, try migrating one page first. Start with your blog or homepage. See the difference yourself.
Have questions about your own migration? Reach out on LinkedIn - I'd love to help!