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.
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?
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
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
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
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.
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 bundleAfter (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.
# Next.js
npm run build
Time: 18.3s
# Astro
npm run build
Time: 2.8s6.5x faster builds with Astro.
# 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.
MetricNext.jsAstroPerformance92100First Contentful Paint1.2s0.6sTime to Interactive2.8s0.8sTotal Blocking Time450ms0ms
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
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 differentNeed 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
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.
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.
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) => ...)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
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
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
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, check out the blog page here
No posts

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.