Migrating My Portfolio from Next.js to Astro: A Technical Deep Dive
After running my portfolio on Next.js for over a year, I decided to migrate to Astro. This wasn’t just a framework switch, it was a fundamental rethinking of how I wanted to deliver and present my thoughts. Here’s the full story, complete with code samples and my honest thoughts.
Why Migrate?
The Problem with Next.js (For My Use Case)
Don’t get me wrong, Next.js is an incredible framework. But for a static portfolio with a blog, I was shipping way more JavaScript than necessary:
# Next.js Bundle Sizes
/_next/static/chunks/main-app.js 99.3 kB
/_next/static/chunks/framework.js 29.4 kB
/_next/static/chunks/page.js ~45 kB
# Total: ~174 kB of JavaScript (gzipped)
My website is 95% static content, this size felt excessive. Every page load meant:
- Hydrating React components
- Loading the entire React runtime
- Running client-side navigation logic
I wanted to add a blog, and I knew Next.js would make it work, but I kept asking: Do I really need React for this?
Why Astro?
Astro provides:
- Zero JavaScript by default - Ship only what you need
- Content-first - Built-in content collections for type-safe blog posts
- Framework agnostic - Use React, Vue, or Svelte only where needed
- Faster builds - Static generation without the overhead
More compelling arguments here on the official docs: Why Astro
The Migration Process
1. Component Conversion: React to Astro
Here’s a real example from my navigation component:
Before (Next.js + React):
"use client";
import { useState } from "react";
import { Menu, X } from "lucide-react";
export default function Navigation() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return (
<nav className="fixed top-0 w-full z-50 bg-slate-900/80">
<button onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
{mobileMenuOpen ? <X /> : <Menu />}
</button>
{mobileMenuOpen && <div className="mobile-menu">{/* menu items */}</div>}
</nav>
);
}
After (Astro):
---
// Server-side - runs at build time
const navLinks = [
{ href: '/', label: 'Home' },
{ href: '/#about', label: 'About' },
{ href: '/blog', label: 'Blog' },
];
---
<nav class="fixed top-0 w-full z-50 bg-slate-900/80">
<button id="mobile-menu-toggle">
<svg id="menu-icon"><!-- menu icon --></svg>
<svg id="close-icon" class="hidden"><!-- close icon --></svg>
</button>
<div id="mobile-menu" class="hidden">
{navLinks.map((link) => (
<a href={link.href}>{link.label}</a>
))}
</div>
</nav>
<script>
// Client-side - only what's needed
const toggle = document.getElementById('mobile-menu-toggle');
const menu = document.getElementById('mobile-menu');
toggle?.addEventListener('click', () => {
menu?.classList.toggle('hidden');
});
</script>
Key Differences:
- No useState hook or React runtime needed
- Vanilla JavaScript runs only when necessary
- Component structure is server-first
- JavaScript bundle: ~300 bytes vs ~45 KB
2. Layout Migration
Before (Next.js layout.tsx):
import type { Metadata } from "next";
import { Analytics } from "@vercel/analytics/react";
import "./globals.css";
export const metadata: Metadata = {
title: "Abisoye Alli-Balogun",
description: "Full Stack Product Engineer",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Analytics />
</body>
</html>
);
}
After (Astro BaseLayout.astro):
---
import { ViewTransitions } from 'astro:transitions';
interface Props {
title?: string;
description?: string;
}
const {
title = 'Abisoye Alli-Balogun - Full Stack Product Engineer',
description = 'Building scalable distributed systems...',
} = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<title>{title}</title>
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html>
Benefits:
- More explicit and easier to understand
- Built-in View Transitions for smooth navigation
- No need for separate metadata API
- Type-safe props with TypeScript
3. Adding the Blog: Content Collections
This was the game-changer. Astro’s content collections made blogging incredibly simple:
Content Collection Schema (src/content/config.ts):
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
publishDate: z.coerce.date(),
tags: z.array(z.string()).optional(),
draft: z.boolean().default(false),
featured: z.boolean().default(false),
}),
});
export const collections = { blog };
Blog Post Template:
---
title: "My Post Title"
description: "Post description"
publishDate: 2025-12-17
tags: ["astro", "web-dev"]
featured: true
---
## Content
With full MDX support, including React components if needed!
Generating Pages:
---
// src/pages/blog/[...slug].astro
import { type CollectionEntry, getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: post,
}));
}
type Props = CollectionEntry<'blog'>;
const post = Astro.props;
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
In Next.js, I would have needed:
- MDX plugin configuration
- Custom file-system routing
- Frontmatter parsing library
- Manual TypeScript types for posts
Astro handles all of this out of the box with full type safety.
4. Animation Migration: Framer Motion → View Transitions
Before (Next.js with Framer Motion):
import { motion } from "framer-motion";
export default function Home() {
return (
<motion.section
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<h1>About Me</h1>
</motion.section>
);
}
// Added ~73 KB to bundle
After (Astro with View Transitions):
---
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<ViewTransitions />
</head>
<body>
<section>
<h1>About Me</h1>
</section>
</body>
</html>
<style>
section {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
View Transitions provide smooth page transitions with zero JavaScript for the animation itself.
Performance Comparison
Build Times
# Next.js
npm run build
Time: 18.3s
# Astro
npm run build
Time: 2.8s
6.5x faster builds with Astro.
Bundle Sizes
# Next.js (homepage)
Total JavaScript: ~174 KB (gzipped)
First Load JS: ~220 KB
# Astro (homepage)
Total JavaScript: 15.33 KB (gzipped)
First Load JS: ~18 KB
~90% reduction in JavaScript shipped to the browser.
Lighthouse Scores
| Metric | Next.js | Astro |
|---|---|---|
| Performance | 92 | 100 |
| First Contentful Paint | 1.2s | 0.6s |
| Time to Interactive | 2.8s | 0.8s |
| Total Blocking Time | 450ms | 0ms |
Pros and Cons
Astro Pros
-
Drastically Better Performance
- 90% less JavaScript
- Near-instant page loads
- Perfect Lighthouse scores
-
Developer Experience
- Content collections are phenomenal
- TypeScript support is first-class
- Simpler mental model (server-first)
- Faster builds = faster iteration
-
Built for Content
- MDX support out of the box
- RSS feed generation
- Sitemap generation
- SEO-friendly by default
-
Framework Freedom
- Can use React where needed
- Can use Vue, Svelte, etc.
- Or just vanilla JS
-
Cost Efficiency
- Smaller bundle = less bandwidth
- Faster builds = cheaper CI/CD
Astro Cons
-
Limited Interactivity Patterns
- Client-side state management is more manual
- No built-in React hooks equivalent
- Need to use
<script>tags or islands for interactivity
-
Smaller Ecosystem
- Fewer third-party integrations
- Less Stack Overflow answers
- Newer framework = more unknowns
-
Learning Curve
.astrocomponent syntax is different- Need to think “server-first”
- Islands architecture takes getting used to
-
Not for Complex Apps
- If you need heavy client-side interactivity, stick with Next.js
- Real-time features are harder
- Complex state management is more involved
Migration Challenges
1. Component Interactivity
Moving from React hooks to vanilla JavaScript required rethinking state:
// Before: React state
const [activeTab, setActiveTab] = useState(0);
// After: Vanilla JS
const tabs = document.querySelectorAll(".tab");
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => {
// Update UI directly
});
});
Solution: Embrace vanilla JS or use Alpine.js for complex interactions.
2. Vercel Deployment Detection
Vercel kept detecting Next.js from cached files:
// vercel.json
{
"buildCommand": "npm run build",
"framework": "astro",
"installCommand": "npm install"
}
Solution: Explicit framework configuration + clearing .next cache.
3. TypeScript Strictness
Astro’s strict TypeScript caught issues I didn’t know existed:
// Error: Implicit any types
allPosts.map((post) => ...)
// Fix: Explicit typing
allPosts.map((post: any) => ...)
Lessons Learned
-
Choose the Right Tool
- Next.js is incredible for web apps
- Astro is perfect for content sites
- Don’t use a sledgehammer to crack a nut
-
Performance Matters
- Every KB of JavaScript impacts user experience
- Static > Dynamic for content that doesn’t change
- Measure before and after
-
Developer Experience ≠ User Experience
- React is great for developers
- But users don’t care about your framework
- They care about speed and usability
-
Migration Is Iterative
- Start with one page
- Test thoroughly
- Migrate incrementally if possible
Should You Migrate?
Migrate to Astro if:
-
Your site is mostly static content
-
You’re adding a blog or documentation
-
Performance is a top priority
-
You want faster builds
-
You’re comfortable with vanilla JS
Stick with Next.js if:
- You need heavy client-side interactivity
- You’re building a web application
- You need server-side rendering with dynamic data
- You rely heavily on the React ecosystem
- Your team is deeply invested in React
Final Thoughts
Migrating from Next.js to Astro was the right choice for my portfolio and blog. I achieved:
- 90% reduction in JavaScript bundle size
- 6.5x faster builds
- Perfect 100 Lighthouse score
- Better developer experience for content management
But remember: there’s no one-size-fits-all solution. Next.js and Astro solve different problems. The key is understanding your requirements and choosing the tool that fits best.
For me, that was Astro. And I couldn’t be happier with the results.
Have questions about migrating your own site? Send a DM on twitter
Written by Abisoye Alli-Balogun
Full Stack Product Engineer building scalable distributed systems and high-performance applications. Passionate about microservices, cloud architecture, and creating delightful user experiences.
Enjoyed this post? Check out more articles on my blog.
View all posts
Comments