RSS Amplifier

Notes from Abisoye · Dec 26, 2025

Migrating My Portfolio from Next.js to Astro: A Technical Deep Dive

0
Sign in to vote or save

Abisoye Alli-Balogun · Notes from Abisoye

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:

  1. Zero JavaScript by default - Ship only what you need

  2. Content-first - Built-in content collections for type-safe blog posts

  3. Framework agnostic - Use React, Vue, or Svelte only where needed

  4. 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:

  1. MDX plugin configuration

  2. Custom file-system routing

  3. Frontmatter parsing library

  4. 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 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.

# Next.js
npm run build
Time: 18.3s
# Astro
npm run build
Time: 2.8s

6.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

  1. Drastically Better Performance

    • 90% less JavaScript

    • Near-instant page loads

    • Perfect Lighthouse scores

  2. Developer Experience

    • Content collections are phenomenal

    • TypeScript support is first-class

    • Simpler mental model (server-first)

    • Faster builds = faster iteration

  3. Built for Content

    • MDX support out of the box

    • RSS feed generation

    • Sitemap generation

    • SEO-friendly by default

  4. Framework Freedom

    • Can use React where needed

    • Can use Vue, Svelte, etc.

    • Or just vanilla JS

  5. Cost Efficiency

    • Smaller bundle = less bandwidth

    • Faster builds = cheaper CI/CD

  1. 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

  2. Smaller Ecosystem

    • Fewer third-party integrations

    • Less Stack Overflow answers

    • Newer framework = more unknowns

  3. Learning Curve

    • .astro component syntax is different

    • Need to think “server-first”

    • Islands architecture takes getting used to

  4. 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) => ...)
  1. 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

  2. Performance Matters

    • Every KB of JavaScript impacts user experience

    • Static > Dynamic for content that doesn’t change

    • Measure before and after

  3. Developer Experience ≠ User Experience

    • React is great for developers

    • But users don’t care about your framework

    • They care about speed and usability

  4. 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

Read the original on abisoyeoladayo.substack.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.