Migrating to Next.js: From Vite to Server-Side Rendering

When I first built tomxxi.com, I chose Vite + React as my stack. It was a deliberate decision - I didn't need the heavyweight features of a full framework like Next.js. Vite provided lightning-fast development, excellent Hot Module Replacement, and a simple, straightforward setup that perfectly matched my needs at the time.
The site worked beautifully. It was fast, responsive, and had all the features I wanted. But then I tried to share a blog post on social media, and I discovered a critical limitation: the metadata didn't show up. No preview image, no description, no title - just a blank link. This was because the entire site was client-side rendered, and social media crawlers couldn't see the content.
That moment made me realize I needed Server-Side Rendering (SSR) and proper SEO support. While I could have added workarounds like pre-rendering services or meta tag injection, I decided to migrate to Next.js - a decision that has proven to be the right choice for this project.
In this post, I'll walk you through why I made this migration, the challenges I faced, and the benefits I've gained from moving to Next.js. If you're considering a similar migration or wondering whether you need SSR for your project, this deep dive should provide some valuable insights.
The Problem: Client-Side Rendering Limitations
The issue became apparent when I tried to share my first blog post. Social media platforms like Twitter, Facebook, and LinkedIn use crawlers that fetch the HTML of a page to extract metadata for link previews. These crawlers don't execute JavaScript - they just read the static HTML.
With a client-side rendered React app, the initial HTML response looks something like this:
1<!DOCTYPE html>
2<html>
3 <head>
4 <title>Tom Taylor</title>
5 <!-- No blog post metadata here! -->
6 </head>
7 <body>
8 <div id="root"></div>
9 <script src="/main.js"></script>
10 <!-- Content only appears after JavaScript executes -->
11 </body>
12</html>The crawler sees an empty page with no metadata, no content, and no way to generate a preview. By the time React hydrates and renders the content, the crawler has already moved on. This meant my blog posts would never show proper previews when shared, significantly reducing their visual appeal and click-through rates.
I could have solved this with services like Prerender.io or by using React Helmet to inject meta tags, but these are workarounds. I wanted a proper solution that would work reliably for SEO, social sharing, and search engine indexing.
Why Next.js?
Next.js wasn't my first choice initially because it felt like overkill. But when I needed SSR and SEO, it became the obvious solution. Here's why:
- Built-in SSR & SSG: Next.js provides Server-Side Rendering and Static Site Generation out of the box. Pages can be pre-rendered at build time or rendered on-demand, ensuring crawlers always see complete HTML.
- Metadata API: Next.js 13+ includes a powerful Metadata API that makes it trivial to add SEO-friendly meta tags, Open Graph data, and Twitter cards. No need for external libraries or manual tag management.
- File-Based Routing: The App Router provides intuitive file-based routing that's more powerful than React Router. Dynamic routes, layouts, and nested routes are all handled elegantly.
- Performance Optimizations: Automatic code splitting, image optimization, font optimization, and built-in performance monitoring. Next.js handles many optimizations that would require manual configuration in Vite.
- Production Ready: Next.js is battle-tested at scale. It's used by companies like Netflix, TikTok, and Hulu. The framework handles edge cases, optimizations, and best practices automatically.
The Migration Process
Migrating from Vite + React Router to Next.js required several key changes. The good news is that since I was already using React and TypeScript, most of my components could be reused with minimal modifications.
The main changes were:
- Routing System: Replaced React Router with Next.js App Router (file-based routing)
- Page Structure: Converted page components to Next.js page components with proper exports
- Metadata: Added metadata exports to every page for SEO
- Build Configuration: Replaced Vite config with Next.js config
- Client Components: Added 'use client' directives where needed for interactive components
Implementing SEO with Next.js Metadata API
One of the biggest wins from migrating to Next.js is the Metadata API. Instead of manually managing meta tags or using libraries like React Helmet, Next.js provides a clean, type-safe way to define metadata.
For blog posts, I use the generateMetadata function to dynamically create metadata based on the post data:
1// Generate metadata for SEO
2export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
3 const { slug } = await params;
4 const post = blogPosts.find((p) => p.slug === slug);
5
6 if (!post) return {};
7
8 const canonicalUrl = getCanonicalUrl(`/blog/${slug}`);
9 const imageUrl = post.image ? getAbsoluteImageUrl(post.image) : undefined;
10
11 return {
12 title: post.title,
13 description: post.description,
14 keywords: post.tags,
15 openGraph: {
16 title: post.title,
17 description: post.description,
18 url: canonicalUrl,
19 type: 'article',
20 publishedTime: post.publishedDate,
21 modifiedTime: post.updatedDate || post.publishedDate,
22 authors: ['Tom Taylor'],
23 tags: post.tags,
24 images: imageUrl ? [{
25 url: imageUrl,
26 width: 1200,
27 height: 630,
28 alt: post.title
29 }] : [],
30 },
31 twitter: {
32 card: 'summary_large_image',
33 title: post.title,
34 description: post.description,
35 images: imageUrl ? [imageUrl] : [],
36 },
37 alternates: {
38 canonical: canonicalUrl,
39 },
40 };
41}This metadata is automatically injected into the HTML at build time (for static pages) or request time (for dynamic pages), ensuring that crawlers always see complete, accurate information. The result? Perfect link previews on every social media platform.
I also added structured data (JSON-LD) for blog posts to help search engines understand the content better:
1<script
2 type="application/ld+json"
3 dangerouslySetInnerHTML={{
4 __html: JSON.stringify({
5 '@context': 'https://schema.org',
6 '@type': 'BlogPosting',
7 headline: post.title,
8 description: post.description,
9 image: post.image ? getAbsoluteImageUrl(post.image) : undefined,
10 datePublished: post.publishedDate,
11 dateModified: post.updatedDate || post.publishedDate,
12 author: {
13 '@type': 'Person',
14 name: 'Tom Taylor',
15 },
16 keywords: post.tags.join(', '),
17 url: getCanonicalUrl(`/blog/${slug}`),
18 }),
19 }}
20/>Key Benefits I've Gained
The migration has provided several tangible benefits beyond just fixing the social sharing issue:
- Perfect Social Sharing: Every blog post now shows rich previews with images, titles, and descriptions when shared on social media. This significantly improves engagement and click-through rates.
- Better SEO: Search engines can now properly index all content. Pages are pre-rendered with complete HTML, making them fully crawlable without JavaScript execution.
- Faster Initial Load: With Static Site Generation, pages are pre-rendered at build time. Users get instant HTML responses without waiting for JavaScript to execute, improving Core Web Vitals.
- Automatic Code Splitting: Next.js automatically splits code at the route level. Each page only loads the JavaScript it needs, keeping bundle sizes optimal.
- Image Optimization: Next.js Image component provides automatic image optimization, lazy loading, and responsive images. This improves performance without manual configuration.
- Type Safety for Metadata: The Metadata API is fully typed, catching errors at compile time. No more runtime errors from typos in meta tag names.
Architecture Changes
The migration required some architectural adjustments, but the core structure remained largely the same. Here's how the routing changed:
1Before (Vite + React Router):
2src/
3├── pages/
4│ ├── HomePage.tsx
5│ ├── BlogPage.tsx
6│ └── blog/
7│ └── BuildingThisSite.tsx
8└── router.tsx (React Router configuration)
9
10After (Next.js):
11app/
12├── page.tsx (Home)
13├── blog/
14│ ├── page.tsx (Blog list)
15│ └── [slug]/
16│ └── page.tsx (Dynamic blog post)
17└── layout.tsx (Root layout)The App Router's file-based routing is more intuitive than React Router's configuration-based approach. Dynamic routes are handled with folder names like [slug], and layouts are automatically applied through layout.tsx files.
One important change was the need to mark interactive components with 'use client'. Next.js uses Server Components by default, which can't use hooks or browser APIs. Components that need interactivity must be explicitly marked as Client Components.
However, this actually improved my architecture. By defaulting to Server Components, I was forced to think about which components truly need client-side interactivity, leading to better separation of concerns and smaller client bundles.
What Stayed the Same
The migration was smoother than expected because most of my codebase remained unchanged:
- React Components: All my custom UI components, animations, and layouts worked without modification. React is React, whether it's in Vite or Next.js.
- TypeScript Configuration: TypeScript setup remained largely the same. Next.js has excellent TypeScript support out of the box.
- Styling System: TailwindCSS configuration and custom CSS worked identically. No changes needed to the design system.
- Data Structure: All my data files (blog posts, projects) remained unchanged. The same TypeScript interfaces and helper functions work perfectly.
- PWA Features: Progressive Web App functionality continued to work. I just switched from vite-plugin-pwa to next-pwa.
Performance Improvements
Beyond fixing the SEO issue, the migration to Next.js has actually improved performance in several ways:
Static Site Generation: All blog posts and project pages are pre-rendered at build time. This means users get instant HTML responses without waiting for JavaScript to execute. The first contentful paint is significantly faster.
Automatic Code Splitting: Next.js automatically splits code at the route level. Each page only loads the JavaScript it needs. This is more granular than manual chunk splitting in Vite, resulting in smaller initial bundles.
Image Optimization: While I was already using WebP images, Next.js Image component provides additional optimizations like automatic format selection (AVIF when supported), responsive images, and lazy loading - all without manual configuration.
Font Optimization: Next.js automatically optimizes Google Fonts, reducing layout shift and improving loading performance. The font files are self-hosted and optimized at build time.
These improvements, combined with the existing optimizations I had in place, have resulted in even better Lighthouse scores and Core Web Vitals metrics.
Lessons Learned
This migration taught me some valuable lessons about choosing the right tools for the job:
- Start Simple, But Plan Ahead: Vite was the right choice initially - it was simple and met my needs. But I should have considered future requirements like SEO from the start. Sometimes a bit more framework is worth it.
- SEO Matters More Than You Think: Even for a personal portfolio, proper SEO and social sharing metadata is crucial. You never know when someone will share your content, and first impressions matter.
- Server Components Are Powerful: Next.js Server Components default to rendering on the server, which is more efficient. Learning to use them effectively reduces client bundle size and improves performance.
- Migration Can Be Smooth: Moving from Vite to Next.js was easier than expected because both use React. Most components worked without changes. The migration took a weekend, not weeks.
- Framework Features Add Value: While I initially wanted to avoid 'heavy' frameworks, Next.js features like the Metadata API, Image optimization, and automatic code splitting save significant development time.
Conclusion
Migrating from Vite to Next.js was driven by a specific need - proper SEO and social sharing support. But the benefits have extended far beyond that initial requirement.
The site now has better performance, improved SEO, perfect social media previews, and a more maintainable architecture. While Vite was the right choice when I started, Next.js is the right choice for where the project is now.
If you're building a site that needs SEO, social sharing, or better performance, Next.js is worth considering from the start. But if you're already on Vite and need these features, the migration is surprisingly straightforward - most of your React code will work without changes.
The key lesson here is that requirements evolve, and sometimes you need to make architectural changes to meet new needs. In this case, the migration was smooth, the benefits were immediate, and I'm confident it was the right decision.
If you're interested in the technical details of how I built the original site, check out my post on Building This Site. And if you have questions about the migration or Next.js in general, feel free to reach out via the contact page!
