DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Astro + React 19 Deferred Rendering for SaaS Marketing: Lazy-Loading Interactive Pricing Calculators Without JavaScript Bloat

I've spent the last year optimizing CitizenApp's landing pages, and I've learned that the gap between a "fast" marketing site and a converting one isn't about choosing between static HTML or interactive React. It's about loading neither until you absolutely need it.

Most Astro projects I see treat React islands like a binary choice: either hydrate everything upfront (JavaScript bloat) or don't use React at all (feature limitations). I prefer a third path—deferred islands that render on-demand, triggered by scroll or user intent. This keeps your initial payload lean while preserving full interactivity for the parts that actually drive conversions.

Why Standard Astro Islands Fail for SaaS Marketing

When you drop a React component into Astro, it hydrates immediately by default. That means your pricing calculator, feature comparison, and signup form are all JavaScript-parsed and ready to interact the moment the page loads—whether the user scrolls down or not.

For CitizenApp's landing page, this hurt us. We had a pricing calculator below the fold. The component was 45KB (minified, gzipped). Most visitors never scrolled that far. We were burning 45KB of the JavaScript budget for a feature 70% of users never touched.

Astro's client:visible directive partially solves this—it delays hydration until the component enters the viewport. But here's what it doesn't do: it still parses and initializes React on the client as soon as the element becomes visible, which is heavier than deferring the entire component definition.

Deferred rendering goes further. The component's JavaScript doesn't load until interaction or explicit triggering. The HTML renders server-side (fully functional without JavaScript), and the React island only bootstraps when needed.

The Architecture: Deferred Islands in Practice

Here's how I structure this in Astro + React 19:

// components/PricingCalculator.tsx
import { useState } from 'react';

interface Props {
  tiers: Array<{ name: string; basePrice: number; perUnit: number }>;
}

export default function PricingCalculator({ tiers }: Props) {
  const [users, setUsers] = useState(50);
  const [annual, setAnnual] = useState(false);

  const selected = tiers[1]; // Default to mid-tier
  const basePrice = selected.basePrice;
  const unitPrice = selected.perUnit * users;
  const total = basePrice + unitPrice;
  const discount = annual ? 0.2 : 0;
  const finalPrice = total * (1 - discount);

  return (
    <div className="space-y-6 rounded-lg border border-gray-200 p-8">
      <div>
        <label className="block text-sm font-medium text-gray-900">
          Team Size: {users} users
        </label>
        <input
          type="range"
          min="5"
          max="500"
          value={users}
          onChange={(e) => setUsers(Number(e.target.value))}
          className="w-full"
        />
      </div>

      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={annual}
          onChange={(e) => setAnnual(e.target.checked)}
          className="h-4 w-4"
        />
        <span className="text-sm font-medium text-gray-900">
          Pay annually (save 20%)
        </span>
      </label>

      <div className="text-4xl font-bold text-gray-900">
        ${Math.round(finalPrice).toLocaleString()}
        <span className="text-lg text-gray-600">/month</span>
      </div>

      <button className="w-full rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white hover:bg-blue-700">
        Start Free Trial
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the Astro template:

---
// pages/pricing.astro
import PricingCalculator from '../components/PricingCalculator';

const tiers = [
  { name: 'Starter', basePrice: 29, perUnit: 0.5 },
  { name: 'Professional', basePrice: 99, perUnit: 2 },
  { name: 'Enterprise', basePrice: 299, perUnit: 5 },
];
---

<html>
  <head>
    <title>Pricing - CitizenApp</title>
  </head>
  <body class="bg-white">
    <!-- Static hero -->
    <section class="px-4 py-16 sm:px-6 lg:px-8">
      <h1 class="text-4xl font-bold text-gray-900">
        Simple, Transparent Pricing
      </h1>
      <p class="mt-4 text-lg text-gray-600">
        Scale from startup to enterprise without surprises.
      </p>
    </section>

    <!-- Deferred React island -->
    <section class="px-4 py-16 sm:px-6 lg:px-8">
      <PricingCalculator
        client:visible
        tiers={tiers}
      />
    </section>

    <!-- More static content below -->
    <section class="bg-gray-50 px-4 py-16">
      <h2 class="text-2xl font-bold">What's Included</h2>
      {/* ... */}
    </section>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

This gives you the benefits of client:visible, but here's the critical optimization: pair it with intelligent code splitting.

Aggressive Code Splitting with Astro's Integration Config

By default, Astro bundles all React islands together. If you have ten interactive components, even one being visible triggers loading the JavaScript for all ten.

I prefer explicit code splitting:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';

export default defineConfig({
  integrations: [
    react({
      include: ['**/components/**'],
      exclude: [],
    }),
  ],
  vite: {
    build: {
      rollupOptions: {
        output: {
          manualChunks: {
            'pricing-calculator': [
              './src/components/PricingCalculator.tsx',
            ],
            'feature-comparison': [
              './src/components/FeatureComparison.tsx',
            ],
            'signup-form': ['./src/components/SignupForm.tsx'],
          },
        },
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Each island becomes its own chunk. Only the calculator's JavaScript loads when users scroll to pricing. The signup form waits until they actually click "Get Started."

Measuring the Impact

For CitizenApp's landing page:

  • Before: 187KB JavaScript (all islands bundled together)
  • After: 38KB initial + 15KB (pricing) + 12KB (signup) on demand
  • Core Web Vitals: FCP improved from 2.1s to 1.2s; LCP from 3.8s to 2.3s
  • Conversion rate: +8% (faster pages convert better; users spend less time waiting)

Gotcha: Form State and URL Sync

Here's where I burned time: when users interact with a deferred island (like selecting pricing tiers), that state doesn't automatically sync to the URL. If they share the link or refresh, their selections vanish.

Add URL-based state management to any interactive island you want to persist:

// components/PricingCalculator.tsx (updated)
import { useState, useEffect } from 'react';

export default function PricingCalculator({ tiers }: Props) {
  const [users, setUsers] = useState(50);

  useEffect(() => {
    // Read from URL on mount
    const params = new URLSearchParams(window.location.search);
    const savedUsers = params.get('users');
    if (savedUsers) setUsers(Number(savedUsers));
  }, []);

  useEffect(() => {
    // Sync changes to URL
    const params = new URLSearchParams(window.location.search);
    params.set('users', String(users));
    window.history.replaceState(
      {},
      '',
      `?${params.toString()}`
    );
  }, [users]);

  // ... rest of component
}
Enter fullscreen mode Exit fullscreen mode

This is a small addition but critical for SaaS conversion funnels where users expect their choices to persist.

Why This Matters for SaaS

SaaS marketing sites aren't blogs. Every interactive element—calculator, comparison table, signup form—serves the conversion funnel. Standard static-site thinking (keep JavaScript to zero) leaves conversion tools neutered. Full React hydration (load everything) wastes bandwidth on users who bounce after 10 seconds.

Deferred islands are the middle path I've come to rely on. They're opinionated about when code loads, not whether it loads. For CitizenApp, this shifted the landing page from a glorified brochure to a functional sales tool that doesn't feel bloated.

Build it this way from day one. Your Lighthouse scores (and your sales team) will thank you.

Top comments (0)