When I sat down to rebuild this site I had a fairly firm hypothesis: it was a content site, four routes, almost no interactivity. The textbook case for Astro. Islands, zero JavaScript by default, plain HTML. Next.js looked oversized for what I was doing.
I built both and measured them. Astro won. I stayed with Next anyway. That gap is the interesting part.
What I measured
I built the same home page in both: same markup, same CSS, same fonts, same images. Production builds, served locally, Lighthouse 12 on the desktop preset.
The real difference was JavaScript. Astro shipped almost none. Next shipped the React runtime plus the RSC payload — tens of kilobytes that simply did not exist in the Astro build.
But the metrics Lighthouse actually scores came out level: both hit 100 on performance, TBT at 0 ms, CLS at 0, LCP under a second.
That tie is the first thing I learned, and it took me a while to accept: below a certain threshold, less JavaScript stops improving the numbers. If the critical HTML arrives in the first round trip and nothing is blocking the main thread, cutting 40 KB of JS that parses after first paint moves neither LCP nor TBT. Astro was winning a metric that was no longer this site's bottleneck.
The bottleneck was not the framework
While I was comparing frameworks I had a real performance problem, and neither of them was causing it.
The hero background was four animated blurred gradients. Roughly this:
@keyframes blob1 {
0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
33% { transform: scale(1.2) translate(30px, -20px); opacity: 0.8; }
}Four layers of that, each carrying filter: blur(60px) or more.
I had assumed the blur was the problem — that blurring is expensive and a large radius is more expensive. That is wrong. A blurred gradient is rasterised once, and the compositor reuses that bitmap from then on. It costs nothing per frame.
What costs is invalidating the bitmap. scale and opacity inside the keyframe do exactly that: every frame, the browser has to redo the blur, on the main thread, four times over.
The fix did not touch the blur at all:
@keyframes drift1 {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(6%, -4%, 0); }
}
.animate-drift1 { will-change: transform; animation: drift1 44s ease-in-out infinite; }Translate only. No scale, no opacity. The blurred bitmap is never invalidated and the compositor moves it on the GPU. I also went from four layers down to two and slowed them right down.
The blurs on the site today are larger than the ones I removed — 100px and 120px against 60px. The page performs better.
The rule I took from it: what matters is what animates, not how expensive the effect looks. And that problem was identical in the Astro build. No framework was going to fix it for me.
What actually broke the tie
I rebuilt the hero along the way. The old one was the one every portfolio has: a two-line headline with the second line in an accent colour, two buttons, a stat triad, a decorative code card with the three macOS traffic lights.
I replaced it with a status table. The five products I run, with their real HTTP status, real TTFB and real edge region, measured when the page is generated. If one is down, the page says it is down.
That is where the site stopped being static content.
I needed outbound requests at generation time, cached, revalidated every ten minutes, still serving prerendered HTML. In Next that is an async server component and two lines:
export const getServiceStatus = unstable_cache(
async () => Promise.all(projectsConfig.map(probe)),
["service-status"],
{ revalidate: 600, tags: ["service-status"] },
)Astro has answers for this — it is not that it cannot. But in Astro it was more hand-rolled, and above all it was one more piece I would be maintaining myself.
One detail here nearly ate me alive, and I am writing it down because it is exactly the kind of thing that gives you no warning. The probes use cache: "no-store". An uncached no-store fetch opts the route out of the static prerender: / and /es flipped from ○ to ƒ without a single warning, and every visit fired eight outbound requests. That unstable_cache above is not an optimisation, it is the thing holding the page static. I check the build's route table after touching anything in the hero now.
The other two reasons, less technical and more honest
The site is bilingual. Spanish and English on separate routes, with correct canonical and hreflang on every page. Both tools can do this. Next's route groups left me with a single component tree and two layouts, and the metadata part I cared most about — that no page inherits its parent's canonical and quietly points search engines at the wrong URL — came down to a twenty-line function every page goes through.
And the reason that weighed most in practice: the four products I have in production are Next. When the site shares a framework with what I work in every day, everything I learn in one carries to the other. Choosing Astro would have made this site the one place in my stack where things are done differently — and it is precisely the project I give the fewest hours per week. That is the last one that should be special.
When I would pick Astro
I do not want this to read as Next winning in general, because that is not what happened.
If this were a content blog and nothing else — posts, maybe a landing page, no live data, no dashboard behind it — I would reach for Astro without much deliberation. The island model is more honest about that problem: you ship zero JavaScript because there is none to ship, not because you optimised well. And it reads markdown, MDX and remote content today without you assembling the pipeline yourself.
What changed my case was the status table. The moment the home page started measuring real services, I no longer had a content site.
What I took away
I measured before deciding, and the measurement handed the win to Astro. I chose the other one anyway, because the metric it won was already saturated on this site: both scored 100.
Frameworks compete along the axis that gets talked about most. Decisions get made on the other ones: what the project needs to do a year from now, and which stack you already know how to hold up when something breaks on a Sunday.