About Author
A formerly masked, now weaponized ASD-1 nihilist with an well-honed hatred for bullshit, who architects software systems just enough to fund a life spent exposing nepo-babies, working on taxing billionaires into extinction, and teaching everyone with a paycheck that their soul isn't a subscription.
You don’t really choose a framework. You choose its defaults, and then the defaults choose everything else. Next.js has a default answer to every question, and the answer is React: the server renders it, the client hydrates it, and you opt out of interactivity with "use server" or by not adding handlers. The whole page is a React tree whether or not any of it needs to be. After years inside that model I’d stopped noticing it was a choice—which is exactly the state a default wants you in.
This is part 8 of 10 in the RORBT field notes—dispatches from building a governance platform on Cloudflare. The whole series lives on the RORBT case study.
Astro inverts the default, and the inversion is the entire product. Pages are static HTML. Interactivity is opt-in per component, and the components you don’t mark ship zero JavaScript.
---
// This block runs at BUILD time. Node context, full filesystem, no client cost.
import { siteConfig } from "../../site.config";
import { brandMark } from "../../lib/brand-icons";
const channels = siteConfig.social;
const live = channels.filter((c) => c.handle && c.url).length;
---
<section class="channels">
<div class="ch-count"><b>{live}</b> / {channels.length} live</div>
{channels.map((c) => { … })}
</section>
That page imports simple-icons—a 663 KB package—to render brand logos, and ships none of it. The import runs at build time; only the resulting <path d="…"> reaches the browser. I didn’t take this on faith, either—I verified with a grep over the built output, because I’ve been burned by too many frameworks whose marketing tense differs from their runtime tense. In Next.js the equivalent needs a Server Component and constant vigilance that nothing pulls it across the boundary.
Islands, concretely
<PreferencesPanel client:load /> <!-- hydrate immediately -->
<SignupModal client:load turnstileSiteKey={KEY} />
<UnsubscribeConfirm client:load />
Everything else on those pages is inert HTML. The dashboard—genuinely app-like, with dropdowns, modals, live proposals—is React, because it should be. The marketing site, the help center, and the docs are not, and their JS payload is whatever the islands need and nothing else.
The directives: client:load (immediate), client:idle, client:visible (IntersectionObserver), client:media. client:visible on a below-the-fold widget is one line and genuinely useful—the Next.js version is next/dynamic plus ssr: false plus an intersection observer you write yourself and then maintain forever.
The MPA decision
This project deliberately uses real navigations—<a href>, full page loads—rather than a client router. Coming from Next.js’s <Link> and soft navigation, this felt regressive for about a day. Then it stopped mattering entirely: prerendered HTML on a CDN edge arrives fast enough that the soft-navigation advantage largely evaporates.
What you get back is enormous simplification. No router state, no scroll restoration bugs, no “why did my effect run twice”, no client-side data cache to invalidate. Each page is a document. The web spent thirty years getting very good at documents; it turns out you’re allowed to just use that.
The one place it bites is where the app genuinely needs dynamic routes. Which brings me to the bug.
The bug: a catch-all that ate my page
The site has no SSR adapter—deliberately, because an Astro _worker.js would supersede the entire /functions API. But /dashboard/<orbit-id> can’t be prerendered; orbit ids are runtime values.
The workaround is a Pages Function catch-all that serves a prerendered shell for any depth, letting a client island read the id from the path:
// functions/dashboard/[[path]].ts
const target =
segments.length === 0 ? '' : segments[0] === 'create' ? 'create/' : 'orbit/';
const res = await env.ASSETS.fetch(new Request(`${origin}/dashboard/${target}`, request));
Read that ternary carefully, because I wrote it and then didn’t: anything that isn’t create gets the orbit shell.
So when I added a real prerendered page at /dashboard/preferences, the catch-all intercepted it and served the orbit shell instead. The client then tried to resolve "preferences" as an orbit id and failed. The page built fine, the file existed, and it was unreachable—with no error anywhere in the build. Not a warning. Not a log line. A page in perfect health that nobody could visit.
flowchart TD
A["GET /dashboard/preferences"] --> B["functions/dashboard/[[path]].ts"]
B --> C{"segments[0]?"}
C -->|"'create'"| D["serve /dashboard/create/"]
C -->|"anything else"| E["serve /dashboard/orbit/"]
E --> F["client island: resolve 'preferences' as orbit id"]
F --> G["fails—page exists but is unreachable"]
style E fill:#7f1d1d,color:#fff
style G fill:#7f1d1d,color:#fff
The fix—and the comment matters more than the code:
// Pages that own a real prerendered asset must be listed here, or the
// catch-all serves them the orbit shell and the loader tries to resolve
// "preferences" as an orbit id.
const OWN_PAGE = new Set(['create', 'preferences']);
const target =
segments.length === 0 ? '' : OWN_PAGE.has(segments[0]) ? `${segments[0]}/` : 'orbit/';
Any catch-all is a routing precedence problem waiting to happen, and the failure is silent.
That’s the general lesson. In Next.js the file-based router resolves specificity for you—/dashboard/preferences/page.tsx beats /dashboard/[id]/page.tsx automatically, and you never think about it, which is precisely why you never think about it. Here, precedence is code I wrote, so it’s precedence I can get wrong. I added the page to the set and left a comment in the Astro page pointing back at the router, because the coupling is non-obvious from either side, and a coupling that’s invisible from both ends is a bug with a return ticket.
The other silent-failure trap: CSS specificity
Different flavour, same category: correct-looking code whose failure mode is an absence. The docs site has an audience filter that hides sections with no matching cards:
.docs-root[data-aud="organizer"]
.docs-section:not(:has(.doc-card[data-audience="organizer"], …)) { display: none; }
I nearly gave a new contact section the class .docs-section. It has no doc cards—so it would have vanished for anyone who touched the filter, and only then. A page that disappears only for users who do one specific thing is the kind of bug report that reads like a ghost story. Used .docs-contact instead and verified no rule targets it.
A related one, caught by the same twitchy instinct. Moving a paragraph into a different container changed which rule won:
/* `p.ch-foot` (not `.ch-foot`) so it outranks `.ch-lead p`—the closing line
sits inside the lead column and would otherwise inherit its tighter spacing. */
.home-root p.ch-foot { margin-top: 30px; … }
.ch-lead p is (0,2,1); .ch-foot is (0,2,0). The element selector wins, and the symptom is 16px of missing margin that looks like a design choice. That’s what makes this category dangerous: the failure doesn’t look like failure. It looks like taste.
Where Astro is worse
The typechecking story is weaker. The root tsconfig here covers functions/** and auth/**. Astro/Vite transpiles .tsx without type checking, so nothing in the pipeline typechecked the React components. I discovered this the way you discover most process gaps—right as a UI change was about to ship unverified. astro check exists but wants @astrojs/check installed; in the meantime I ran tsc over the components with a scratch config, which is the tooling equivalent of holding the door shut with a chair.
In Next.js, next build typechecks by default. That’s a real advantage and I missed it.
Ecosystem depth. Next.js has a solution for everything. Astro has a solution for most things, and occasionally you write it.
Islands need explicit prop contracts. Data crosses the server→client boundary as serialized props, which is a discipline Next.js Server Components blur (for better and worse).
Where Astro is better
Payload. Marketing pages, docs, and blog ship no JS. Not “less”—none.
The build/runtime boundary is obvious. Frontmatter is build, islands are client. I never once wondered which environment a line of code ran in—a question I asked constantly with the App Router, usually at the worst possible moment.
No framework in the request path. The site is static files on a CDN with an API beside it. There’s no server component renderer, no RSC payload, no streaming protocol to reason about when something’s slow.
Content collections are excellent. Type-safe frontmatter, and Markdown/MDX that just works.
Choosing
| Next.js | Astro | |
|---|---|---|
| Default | Everything is React | Everything is HTML |
| JS payload | Framework + your code | Only marked islands |
| Content sites | Fine | Excellent |
| App-like UI | Excellent | Fine (islands) |
| Typechecking in build | Yes | Needs astro check |
| Routing precedence | Framework-resolved | Yours to get right, if you hand-roll |
| Mental overhead | Server/client boundary is subtle | Build/runtime boundary is obvious |
This project is ~80% content—manifesto, docs, help center, blog—and ~20% dashboard. Astro is the right call by a wide margin. Invert those numbers and I’d pick Next.js without a second thought, and I want that on the record before anyone files this post under framework tribalism.
But the deciding factor wasn’t performance, and it wasn’t the table. It was that on Cloudflare Pages, using Astro’s SSR adapter would have replaced my /functions directory with an Astro _worker.js—and the API is the interesting part of this system.
Pick the framework that stays out of the part of the system you actually care about.
Static output plus Pages Functions keeps the site and the API cleanly separate, and that separation is what made everything in posts 03 through 06 possible. The best thing Astro did for this project was refuse to be in the request path at all.
Previous: better-auth After Auth0 · Next: Six Security Findings from One Day of Code Review
Related
Observability on the Edge
Retrofitting structured, request-correlated logging onto Cloudflare Pages Functions: why winston won't run in workerd and pino loses its speed there, how AsyncLocalStorage carries request ids through 58 orphaned console.error calls without touching a signature, and a request-id scheme that survives system boundaries.
Testing Workers Against Real Bindings
Getting @cloudflare/vitest-pool-workers running tests inside workerd against real D1 and R2: an ESM-only config trap, per-file (not per-test) storage isolation and the resetStorage that fixes it, seeding real better-auth sessions, and the rate limiter that fought back.
The Interface Tax
A frontend-leaning staff engineer on the unwritten rule that makes UI work culturally illegible as 'real engineering'—and why the machines, having automated the backend, just proved the prejudice backwards.