Back to Insights
Software Engineering 11 min read

Abandoning Next.js for Astro: A Comprehensive Migration Guide & 100/100 Lighthouse Optimization

Case study migrating nicknguyen8.com from Next.js 14 to Astro 5: Islands Architecture, Nano Stores, and cutting JavaScript payload by 95%.

Overview

Next.js has long been the default choice for web projects, from complex B2B SaaS applications to personal portfolio pages. However, the architecture of the Next.js App Router and React Server Components (RSC) has measurable limitations for Content-Driven Sites, primarily due to the JavaScript payload delivered on every page load.

This article provides an in-depth technical analysis and comparison between Next.js and Astro – a framework currently highly regarded in the content-driven site development space for its superiority in optimizing Lighthouse scores, minimizing TTFB latency, and enhancing user experience.

This case study documents the entire refactoring process of the nicknguyen8.com system from Next.js 14 to Astro 5. This progression encompasses the standardization of architecture, routing, state management, and the CI/CD pipeline.

Tech Stack Used:

  • Astro 5.x
  • Vanilla CSS (Custom properties, no utility framework)
  • React 18 (Interactive islands only)
  • Nano Stores (React Context replacement)
  • Cloudflare Pages (Hosting & CDN)
  • Keystatic (Local Git CMS)

Part 1: Analyzing the Limitations of Next.js for Content Sites

1.1 The Hydration Waterfall Problem

Whether employing the Next.js Pages Router or App Router, for a page that predominantly consists of text and images (like a Blog), the framework still executes a client-side Hydration process by default.

Hydration is the process where the browser downloads a JavaScript bundle (usually containing the React Runtime), and subsequently runs a re-synchronization procedure to attach event listeners to the static HTML DOM tree previously rendered by the Server (or SSG step).

The diagram below illustrates the traditional rendering flow of React/Next.js compared to a static load flow:

sequenceDiagram
    participant Browser
    participant Server (Next.js)
    
    Browser->>Server (Next.js): GET /blog/article
    Server (Next.js)-->>Browser: Returns HTML (Header, Content, Footer)
    Note over Browser: User sees UI but cannot interact
    Browser->>Server (Next.js): GET frontend-bundle.js (React Runtime)
    Server (Next.js)-->>Browser: Returns large JS bundle
    Note over Browser: Redundant processing (TBT Spike)
    Browser->>Browser: Parse JS & Execute Hydration
    Note over Browser: Website becomes fully interactive

The consequences of this process on a blog page include:

  1. CPU Spikes: Low-end mobile devices consume CPU resources to parse and execute tens to hundreds of kilobytes of JavaScript, even though the user only needs to read text.
  2. High Total Blocking Time (TBT): The browser’s main thread is occupied processing the Hydration task, causing the interface to momentarily fail to respond to user clicks.
  3. Suboptimal Bandwidth Utilization: Distributing accompanying .rsc JS files and JSON payloads unnecessarily increases the download size of the static directory.

1.2 The Limitations of RSC (React Server Components)

Next.js App Router provides RSC to minimize client-side JavaScript. However, the system is still forced to download the React Runtime library and the Client Router to support Client-side Navigation inherent in the SPA model.

Conversely, with Astro, the default JavaScript payload downloaded for text-based pages is precisely 0 KB.


Part 2: Astro Islands – The Independent UI Component Architecture

Unlike Next.js, Astro’s design philosophy fundamentally anchors on a “Zero JavaScript by Default” premise.

The Astro Compiler renders the entire React/Vue/Svelte layer into static HTML during the Build phase. When a specific Component is required to handle User Interaction, these specific UI modules must be explicitly declared to isolate their JS execution scope – a configuration Astro technically defines as the Islands Architecture.

This structural loading mechanism is contrasted in the diagram below:

graph TD
    subgraph n1 ["Next.js SPA/RSC (Load Bundle JS)"]
        N_Root[Root Component]
        N_H[Header Nav]
        N_M[Main Content]
        N_P[Text Paragraph]
        N_I[Image]
        N_S[Search Bar]
        
        N_Root --> N_H
        N_Root --> N_M
        N_M --> N_P
        N_M --> N_I
        N_H --> N_S
        
        style N_Root fill:#ef4444,stroke:#991b1b,color:#fff
        style N_H fill:#ef4444,stroke:#991b1b,color:#fff
        style N_M fill:#ef4444,stroke:#991b1b,color:#fff
        style N_P fill:#ef4444,stroke:#991b1b,color:#fff
        style N_I fill:#ef4444,stroke:#991b1b,color:#fff
        style N_S fill:#ef4444,stroke:#991b1b,color:#fff
    end

    subgraph n2 ["Astro Islands (Max Static)"]
        A_Root[Astro Layout - Zero JS]
        A_H[Astro Header - Zero JS]
        A_M[Astro Content - Zero JS]
        A_P[Markdown Text - Zero JS]
        A_I[Astro Assets - Zero JS]
        A_S[React Island - search.tsx]
        
        A_Root --> A_H
        A_Root --> A_M
        A_M --> A_P
        A_M --> A_I
        A_H --> A_S
        
        style A_Root fill:#10b981,stroke:#047857,color:#fff
        style A_H fill:#10b981,stroke:#047857,color:#fff
        style A_M fill:#10b981,stroke:#047857,color:#fff
        style A_P fill:#10b981,stroke:#047857,color:#fff
        style A_I fill:#10b981,stroke:#047857,color:#fff
        style A_S fill:#3b82f6,stroke:#1d4ed8,color:#fff
    end

As depicted: While Next.js defaults to rendering JS for the entire component tree, Astro strictly encapsulates the JS payload to the precise module that requires it (e.g., the Search Bar).

Hydration Directives in Astro:

  • client:load: Loads and executes JS immediately when the page loads (High priority - widely used for Header Navbars or Theme Mode toggles).
  • client:idle: Loads and attaches JS when the browser’s main thread becomes idle (Optimal for Tracking tags, Analytics scripts).
  • client:visible: JS execution only fires when the Component enters the user’s viewport (Utilize lazy hydration for end-of-post Comment Sections or Image Carousels to maximize initial performance).
  • client:media="{max-width: 768px}": Restricts interactive module execution to specific device breakpoints (Optimal for preventing Hamburger Header payloads from loading on Desktop UIs).

Part 3: Practical Migration Guide

Transitioning frameworks inevitably requires rebuilding the Data Flow and Component pipelines. Below are practical techniques for shifting logic from Next.js to the Astro paradigm.

3.1 Data Fetching Conversion: Eliminating getStaticProps

In Astro, developers are not required to configure an exported asynchronous Fetch function outside the render scope as in Next.js. The Top-level Await directive can be executed directly within the Astro Component Script fencing (---).

Previous Next.js Schema (Pages Router):

export async function getStaticProps() {
  const res = await fetch('https://api.github.com/repos/nicknguyen8/nicknguyen8.com');
  const repo = await res.json();
  return { props: { stars: repo.stargazers_count } };
}

export default function RepoInfo({ stars }) {
  return <div>N8 Repo has {stars} stars</div>;
}

Replacement Astro Schema:

---
// src/pages/index.astro
// All scripts inside the `---` block only execute server-side during the Build phase. 
// Ensures backend API logic is shielded from leaking to the Browser.
const res = await fetch('https://api.github.com/repos/nicknguyen8/nicknguyen8.com');
const repo = await res.json();
const stars = repo.stargazers_count;
---

<!-- Pure DOM structure directly consuming environment variables -->
<div>N8 Repo has {stars} stars</div>

3.2 File-based Dynamic Routing

Next.js employs the [slug].tsx pattern with getStaticPaths. Astro uses the equivalent [slug].astro pattern with getStaticPaths(). The key advantage is that content data is passed directly through props at build time – no secondary queries from the page component.

---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';

export async function getStaticPaths() {
  const posts = await getCollection('insights');

  return posts.map(post => ({
    params: { slug: post.id },
    props: { post }, // Static data passed directly into build props
  }));
}

const { post } = Astro.props;
const { Content, headings } = await post.render(); 
---

<BlogLayout title={post.data.title}>
  <TableOfContents headings={headings} />
  
  <article class="prose dark:prose-invert">
    <Content />
  </article>
</BlogLayout>

3.3 Static Data Management Optimization: Content Collections

A prevailing challenge in maintaining Markdown-based sites using Next.js lies in administering MD/MDX Metadata files. Developers must depend on community packages like gray-matter, map File System Paths manually, and configure TS typing through tedious standards.

The next-generation Astro framework actively resolves this via its Content Collections Architecture, offering reliable Type-Safe data validation out-of-the-box leveraging the Zod analysis package.

Configuration file at src/content.config.ts (Astro 5):

import { z, defineCollection } from 'astro:content';

const insightsCollection = defineCollection({
  type: 'content', 
  schema: ({ image }) => z.object({
    title: z.string().max(100, "SEO Title overflow"),
    description: z.string().min(50).max(160),
    date: z.date(),
    draft: z.boolean().default(false),
    tags: z.array(z.string()).min(1),
    image: image().optional(), 
  }),
});

export const collections = {
  'insights': insightsCollection,
};

Note: If an MDX Document writes Frontmatter data with an incorrect Context Syntax (e.g., an invalid Schema Date value like 2026-03-XX), the Local OS Astro Build environment will return a Zod Validation Terminal error and instantly Cancel the Build Command. This transparency completely isolates syntax violations before they ever hit CI/CD Production.


Part 4: State Management Within a Static Environment

The architectural problem of Component Rendering with flowing Data presents a specific hurdle: How does a React Side Navigation block containing a State Hook signaling a Dark Mode Theme toggle update the project’s <Header /> layer in real-time?

Astro’s defining characteristic separates the page stream into exclusive, isolated Islands. When using Next.js, the Client Renderer wrap with the Context API Provider collapses completely when interactive Components are detached from the larger Virtual DOM tree.

The definitive industry-standard solution for Data Context Sharing inside Astro revolves around the Nano Stores platform. Nano Stores is a Framework-Agnostic library package accounting for a sub-1KB storage footprint.

graph LR
    NS[(Nano Store Instance)]
    
    subgraph n3 ["Astro/React HTML DOM"]
        R_IS[React Module - Theme Toggle]
        H_IS[React Module - Header Navbar]
    end
    
    R_IS -- trigger state toggle() --> NS
    NS -- listening and updates --> H_IS
    
    style NS fill:#eab308,stroke:#a16207,color:#fff
    style R_IS fill:#61dafb,stroke:#008b8b,color:#222
    style H_IS fill:#61dafb,stroke:#008b8b,color:#222

Because the Store holds mutable state outside the React render cycle, all modifications sync across isolated Islands without a shared Provider:

1. Define Persistent Variables (Persistent Storage)

// src/store/themeStore.ts
import { persistentAtom } from '@nanostores/persistent';

export const theme = persistentAtom<'light' | 'dark'>('theme', 'dark');

2. Establish Client Island Wrapper (Astro)

---
import Toggle from '../components/ThemeToggle.tsx';
---
<aside>
  <!-- Mount React Node to Browser -->
  <Toggle client:load />
</aside>

3. Update Local Interaction Logic File

// src/components/ThemeToggle.tsx
import { useStore } from '@nanostores/react';
import { theme } from '../store/themeStore';

export default function ThemeToggle() {
  const $theme = useStore(theme);

  const toggle = () => {
    theme.set($theme === 'light' ? 'dark' : 'light');
    document.documentElement.classList.toggle('dark', $theme === 'light');
  };

  return (
    <button onClick={toggle} className="p-2 bg-gray-200 dark:bg-gray-800 rounded">
      {$theme === 'light' ? '🌙' : '☀️'}
    </button>
  );
}

Nano Stores keeps the JS footprint minimal while maintaining stable event handling across any framework integration.


Part 5: Build-Time Image Optimization

Astro handles the full image optimization pipeline at build time via its built-in astro:assets, eliminating the need for on-demand services like Cloudinary or Vercel’s Image Optimization quota.

During npm run build:

  1. Raw JPEG/PNG source files are read.
  2. Compressed and converted to AVIF or WebP.
  3. Responsive srcset breakpoints are generated for different device sizes.
  4. width and height dimensions are auto-set, eliminating Cumulative Layout Shift (CLS).

Compiled assets land in /dist as static files. The CDN serves them directly with no server-side computation on each request. Typical image weight reduction is 70–80% over the raw source.


Part 6: View Transitions API

An MPA architecture doesn’t mean sacrificing smooth page navigation. Astro integrates the browser’s native View Transitions API, enabling cross-page fade and slide transitions without a full-page reload.

Adding one import to the root layout enables this across the entire site:

---
// Import module from root System Layout
import { ViewTransitions } from 'astro:transitions';
---

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Nick Nguyen</title>
    <ViewTransitions />
  </head>
  <body>
    <!-- Process UI Transitions block -->
    <main transition:animate="fade">
      <slot />
    </main>
  </body>
</html>

When navigating, the browser snapshots the current page, fetches the next HTML, and performs a DOM diff replacement with the transition animation. JavaScript consumed is minimal – a few kilobytes for the transition logic only.


Part 7: SEO and Performance Benchmarks

Lighthouse CI data collected from the production nicknguyen8.com domain:

MetricNext.js SSGAstro StaticResult
Initial JS Payload~215 KB12 KB (React Islands only)94% reduction
First Contentful Paint (FCP)1.2s0.1s12× faster
Largest Contentful Paint (LCP)1.8s0.4sWell within Core Web Vitals threshold
Total Blocking Time (TBT)~300ms0msMain thread unblocked
Lighthouse Performance85–92100 / 100 / 100 / 100Perfect score

Practical SEO benefits of 100/100:

  1. Googlebot Crawl Budget: Minimal payload means Googlebot indexes new posts faster across content-heavy pages.
  2. Page Experience Signals: High Core Web Vitals scores directly improve Google rankings for content pages.
  3. Bandwidth efficiency: Edge requests to Cloudflare are minimal. High traffic volumes don’t proportionally increase hosting costs.

Part 8: Astro Limitations

Every framework has boundaries.

  1. Global SPA State Management: Applications requiring persistent state across navigation (financial dashboards, continuous media players) will hit friction with Astro’s isolated MPA model. React Context and Redux work better inside a proper SPA architecture like Next.js or React Router.
  2. Learning Curve on .astro Files: The .astro format separates server-side script scope from client-side DOM. Engineers coming from pure JSX need time to internalize how variable scope, event handlers, and component lifecycle differ from standard React patterns.

Conclusion

The migration from Next.js to Astro reflects a broader architectural trend: leaner delivery, static HTML by default, and JavaScript only where it’s actually needed.

Framework selection principles:

  • For interactive B2B dashboards, real-time data feeds, or persistent audio/media playback across navigation → SPA frameworks (Next.js, React Router/Remix) are the correct choice.
  • For technical blogs, portfolio sites, documentation, and content-heavy pages where Lighthouse scores and LCP matter → Astro delivers better default performance than any other production framework for this use case.

Astro’s constraint is its strength: it forces explicit decisions about what actually needs JavaScript, rather than hydrating everything by default.