Why Astro Won Me Over: Web Architecture for Performance
I spent three years building everything with Next.js. App Router, SSR, the whole ecosystem. It worked fine. But "fine" isn't good enough when you're running a production SaaS with paying customers and a PostgreSQL database groaning under query load.
Then I rebuilt CitizenApp's marketing site and two internal dashboards with Astro. The performance improvements weren't marginal. They were architectural.
Here's what changed my mind—and the actual numbers that made me commit.
The Problem: Next.js Hydration Overhead
Next.js is fundamentally built around client-side JavaScript. Even with Server Components, your entire application ships as a React runtime to the browser. For CitizenApp's public-facing pages (docs, pricing, case studies), this felt wasteful.
A simple pricing page with static content, zero interactivity on most sections, and one interactive comparison table was shipping ~150KB of minified React code. The Time to Interactive (TTI) was 4.2 seconds on a Pixel 4 over 4G.
That's not acceptable for conversion funnels.
Next.js wasn't wrong—it was overengineered for this use case. I needed a framework that assumed "static by default, interactive when necessary." That's Astro's entire pitch.
Why Astro's Islands Architecture Actually Matters
Astro's mental model is inverted from React-based frameworks. Instead of "everything is a component that hydrates," Astro asks: "What absolutely needs JavaScript?"
Here's a practical example. Our pricing page has:
- Static hero section
- Static feature grid
- Interactive plan comparison switcher (toggle between monthly/annual)
- Static testimonial carousel (we don't auto-rotate)
- Interactive CTA buttons with Stripe integration
In Next.js, the entire page hydrates. In Astro, only the comparison switcher and CTA buttons hydrate as separate islands.
Here's what that looks like:
---
// src/pages/pricing.astro
import { PricingComparison } from '../components/PricingComparison';
import { CTAButton } from '../components/CTAButton';
import StaticFeatures from '../components/StaticFeatures.astro';
---
<Layout>
<Hero />
<StaticFeatures />
<!-- This island hydrates React in isolation -->
<PricingComparison client:load />
<!-- Another island, separate from the first -->
<CTAButton client:idle />
<Testimonials />
</Layout>
The client:load and client:idle directives are the magic. client:load hydrates immediately (needed for the switcher). client:idle hydrates after the browser finishes critical tasks—perfect for CTAs that don't need instant interactivity.
The React component itself is unchanged:
// src/components/PricingComparison.tsx
import { useState } from 'react';
export function PricingComparison() {
const [isAnnual, setIsAnnual] = useState(false);
const plans = [
{ name: 'Starter', monthly: 29, annual: 290 },
{ name: 'Pro', monthly: 99, annual: 990 },
{ name: 'Enterprise', monthly: 'custom', annual: 'custom' },
];
return (
<section className="py-12">
<div className="flex justify-center mb-8">
<button
onClick={() => setIsAnnual(!isAnnual)}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
{isAnnual ? 'Annual' : 'Monthly'}
</button>
</div>
<div className="grid grid-cols-3 gap-6">
{plans.map(plan => (
<div key={plan.name} className="border rounded-lg p-6">
<h3 className="text-xl font-bold">{plan.name}</h3>
<p className="text-3xl font-bold mt-4">
${isAnnual ? plan.annual : plan.monthly}
</p>
</div>
))}
</div>
</section>
);
}
Result: 42KB of JavaScript instead of 150KB. TTI dropped to 1.1 seconds. That's a 75% reduction.
Real Caching Wins with Cloudflare
Where Astro really shines is static asset caching. Because most of your HTML is static, Cloudflare can cache entire pages at the edge for minutes or hours.
I deploy CitizenApp's marketing site to Cloudflare Pages with this configuration:
# astro.config.mjs
export default defineConfig({
output: 'static',
integrations: [react()],
vite: {
build: {
minify: 'terser',
}
}
});
Combined with Cloudflare's cache rules:
If (URI Path contains "/pricing")
Cache Level: Cache Everything
Browser Cache TTL: 1 hour
Edge Cache TTL: 24 hours
The pricing page now lives at Cloudflare's edge globally. A request from Singapore hits Cloudflare's local POP, not our origin. TTFB dropped from 180ms to 32ms.
Compare this to Next.js on Vercel: ISR (Incremental Static Regeneration) is useful, but you're still regenerating on-demand and the cache key management is more complex. Astro's static-first approach means I just deploy, and Cloudflare handles distribution.
When NOT to Use Astro
I'm not claiming Astro is universally superior. It has real tradeoffs.
Don't use Astro for:
- Real-time dashboards (heavy client state, frequent updates)
- Complex SPAs where most content is interactive
- Apps where you need React's ecosystem heavily (recharts, react-query, etc.)
For CitizenApp's internal admin dashboard—where we're querying PostgreSQL constantly, updating user RBAC rules in real-time, and managing multi-tenant state—I kept Next.js. It's the right tool.
But for anything public-facing or content-heavy? Astro wins.
The Gotcha: SSR and Secrets
Here's what burned me: Astro's output: 'static' mode doesn't do SSR. All your pages must be prebuilt. If you need SSR (dynamic routes based on URL parameters), you need output: 'server' and deploy to a Node runtime like Render or Vercel.
I initially set up the pricing page as output: 'static' but needed dynamic plan pricing based on ?region=us query params. Had to switch to hybrid mode:
// astro.config.mjs
export default defineConfig({
output: 'hybrid',
});
Now most pages are static, but specific routes can opt into SSR:
---
// src/pages/pricing.astro
export const prerender = false; // This page always renders on-demand
---
The other gotcha: secrets. In static mode, environment variables are baked into the build. Never commit .env.production to Git. I use:
# .env.local (never committed)
STRIPE_PUBLIC_KEY=pk_live_xxx
ANTHROPIC_API_KEY=sk-ant-xxx
And in my Cloudflare/Render deployments, I set these as secrets:
# GitHub Actions
- name: Build and deploy
env:
STRIPE_PUBLIC_KEY: ${{ secrets.STRIPE_PUBLIC_KEY }}
run: npm run build
The Verdict
Astro isn't a Next.js killer. It's a different architecture for a different problem. Use the right tool:
- Astro: Marketing sites, docs, dashboards with islands of interactivity, static blogs, anything with Cloudflare edge caching
- Next.js: Heavy client-side apps, real-time dashboards, complex SPAs, anything that's "mostly interactive"
For CitizenApp, I use both. The SaaS product itself? Next.js. The marketing and docs? Astro. Performance matters to conversion, and Astro gives me the architectural constraints that prevent over-engineering.
For further actions, you may consider blocking this person and/or reporting abuse
