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.
A step-by-step engineering audit on dismantling React hydration waterfalls, adopting Astro 5 server islands, and fixing Core Web Vitals at scale.
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.
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).
| Performance Metric | Next.js 15 App Router | Astro 5 + React 19 Islands | Direct Delta |
|---|---|---|---|
| First Contentful Paint (FCP) | 3.2s | 0.18s | 17.7x faster |
| Largest Contentful Paint (LCP) | 3.4s | 0.24s | 14.1x faster |
| Total JavaScript Transferred | 384 KB | 42 KB | 89.1% reduction |
| Total Blocking Time (TBT) | 1,420ms | 0ms | Zero main-thread lock |
| Mobile Lighthouse Score | 68 / 100 | 100 / 100 | +32 points |
We completed the migration over a three-week window using four distinct architectural steps:
.astro FilesStatic 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.
---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>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).---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>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.
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 });}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> );}export const prerender = true; across all marketing and documentation pages to compile them into static files served from edge CDN caches.