ENGINEERINGAugust 23, 20268 MIN READBY STAINLESS PERFORMANCE GROUP

Migrating a 450k-Line SaaS Frontend from Next.js 15 to Astro 5: How We Cut JavaScript Payloads by 89%

A step-by-step engineering audit on dismantling React hydration waterfalls, adopting Astro 5 server islands, and fixing Core Web Vitals at scale.

#Astro#Next.js Migration#Core Web Vitals#React 19#Web Performance#Zero-JS

Why Migrate from Next.js to Astro?

Migrating from Next.js to Astro replaces monolithic single-page client hydration with an isolated islands architecture. In Astro, marketing pages, technical documentation, and static dashboard shells render as pure HTML with zero client JavaScript by default. Dynamic components mount as independent React islands only where user interaction or personalized client state is required, reducing initial JavaScript payloads by 75% to 90% and dropping First Contentful Paint times to under 200 milliseconds.

Next.js 15 Monolith:
[Download 380KB Bundle] ──► [Parse & Compile JS] ──► [Hydrate Entire DOM Tree]
(Main thread blocked for 1.4s on mobile devices)
Astro 5 Islands Architecture:
[Instant Static HTML] ──► [Zero Main-Thread Blocking]
β”‚
β”œβ”€β”€β–Ί [Pricing Calculator Island (14KB) loaded on idle]
└──► [Auth Modal Island (8KB) loaded on interaction]

When client SaaS platform Vertex grew past 120,000 monthly active users, their marketing and onboarding surface began dropping search positions. Google Search Console flagged 64% of their public URLs for failing Interaction to Next Paint (INP) and Largest Contentful Paint (LCP) thresholds.

Vertex hired Stainless Studio to audit their frontend architecture and execute a complete migration from Next.js 15 to Astro 5 with React 19 components. Here is how we restructured the codebase and what the production metrics looked like before and after deployment.


The Hydration Waterfall Problem in Next.js

Next.js Server Components solve data fetching on the server, but client-side interactivity still pulls heavy runtime dependencies into the browser. Even with code splitting, a page that contains a pricing slider, a theme switch, and an interactive FAQ accordion forces the browser to download the entire React runtime, the React DOM renderer, and all associated component trees before settling.

On an average Android mobile connection (4G throttled, 150ms RTT), the Next.js bundle produced an initial JavaScript payload of 384 KB (gzipped), resulting in a 3.4-second Total Blocking Time (TBT).

Production Benchmark Comparison

Performance MetricNext.js 15 App RouterAstro 5 + React 19 IslandsDirect Delta
First Contentful Paint (FCP)3.2s0.18s17.7x faster
Largest Contentful Paint (LCP)3.4s0.24s14.1x faster
Total JavaScript Transferred384 KB42 KB89.1% reduction
Total Blocking Time (TBT)1,420ms0msZero main-thread lock
Mobile Lighthouse Score68 / 100100 / 100+32 points

Step-by-Step Migration Strategy

We completed the migration over a three-week window using four distinct architectural steps:

1. Extracting Layouts into Pure .astro Files

Static shell components like footers, navigation headers, grid systems, and typography wrappers do not need client-side React runtimes. We converted them into .astro components, which compile directly into clean HTML strings at build time.

src/layouts/MarketingLayout.astro
---
import BaseHead from "../components/seo/BaseHead.astro";
import Navbar from "../components/navigation/Navbar.astro";
import Footer from "../components/navigation/Footer.astro";
interface Props {
title: string;
description: string;
}
const { title, description } = Astro.props;
---
<!doctype html>
<html lang="en" class="dark">
<head>
<BaseHead title={title} description={description} />
</head>
<body class="bg-[#020409] text-slate-100 antialiased font-sans">
<Navbar />
<main class="relative z-10 max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-8">
<slot />
</main>
<Footer />
</body>
</html>

2. Scoping React Components to Isolated Islands

For interactive widgets, we retained React 19 components but wrapped them in explicit Astro client directives:

  • client:load: High-priority interactive elements above the fold (e.g., dynamic search bars).
  • client:idle: Medium-priority interactive tools (e.g., pricing sliders and currency toggles).
  • client:visible: Lower-page widgets (e.g., interactive testimonials and contact forms).
src/pages/pricing.astro
---
import MarketingLayout from "../layouts/MarketingLayout.astro";
import StaticPricingGrid from "../components/pricing/StaticPricingGrid.astro";
import PricingCalculator from "../components/pricing/PricingCalculator.tsx";
---
<MarketingLayout title="Transparent Pricing // Vertex" description="Calculate your infrastructure cost.">
<section class="py-16">
<h1 class="text-4xl font-mono uppercase tracking-wider">Infrastructure Pricing</h1>
<!-- Static HTML pricing table -->
<StaticPricingGrid />
<!-- Dynamic interactive calculator loads only when visible -->
<PricingCalculator client:visible />
</section>
</MarketingLayout>

3. Lightweight Cross-Island State with Nano Stores

Next.js apps frequently use Redux or heavy React Context providers that wrap the entire <html> tree, forcing every nested component into the client bundle.

In Astro, we replaced React Context with Nano Stores. Nano Stores are microscopic (under 1 KB), framework-agnostic reactive state atoms that sync state across isolated React islands without forcing surrounding HTML to re-render.

src/stores/cartStore.ts
import { atom } from "nanostores";
export interface PlanConfig {
tier: "starter" | "growth" | "enterprise";
seats: number;
annualBilling: boolean;
}
export const $planConfig = atom<PlanConfig>({
tier: "growth",
seats: 10,
annualBilling: true,
});
export function updateSeats(seats: number) {
$planConfig.set({ ...$planConfig.get(), seats });
}
src/components/pricing/PricingSummaryCard.tsx
import { useStore } from "@nanostores/react";
import { $planConfig } from "../../stores/cartStore";
export default function PricingSummaryCard() {
const plan = useStore($planConfig);
const baseRate = plan.tier === "starter" ? 29 : plan.tier === "growth" ? 79 : 249;
const total = baseRate * plan.seats * (plan.annualBilling ? 0.8 : 1.0);
return (
<div className="border border-[#1e293b] p-6 bg-[#030612]">
<div className="font-mono text-xs text-[#0052ff] uppercase">Active Plan: {plan.tier}</div>
<div className="text-3xl font-bold mt-2">${total.toFixed(0)}/mo</div>
</div>
);
}

Lessons Learned in Production

  1. Keep Global CSS Light: Astro allows you to scope component CSS automatically. Avoid importing giant CSS frameworks globally when standard utility classes handle 99% of layout needs.
  2. Pre-render Everything Possible: Set export const prerender = true; across all marketing and documentation pages to compile them into static files served from edge CDN caches.
  3. Use Starlight for Documentation: We moved Vertex’s technical documentation to Astro Starlight, which cut documentation build times from 4.8 minutes to 22 seconds on GitHub Actions.
STAINLESS PERFORMANCE GROUP
STAINLESS RESEARCH & ARCHITECTURE DIVISION
DISPATCH ARCHIVE→