An HTML-first frontend ships server-rendered HTML on the first request and hydrates only the interactive bits — buttons, forms, widgets — as isolated islands. In 2026 that means Astro islands, htmx, or plain server-rendered HTML with a sprinkle of Alpine or vanilla JS. It is not a nostalgia trip. A rewrite that landed on the front page of Hacker News last month reported user numbers doubling overnight after the team tore out their React SPA and replaced it with server-rendered HTML plus targeted islands. The comments were full of engineers admitting they had the same drop-off charts and had been ignoring them.
This piece unpacks what html-first actually means today, when the signals in your own metrics mean you have outgrown the SPA model, and how to pick between Astro, htmx, and rolling your own with a backend template engine.
What does HTML-first actually mean in 2026?
HTML-first means the server sends fully rendered HTML on the first response, and JavaScript is opt-in per component rather than a bundle-wide runtime. The browser gets a working document before a single JS file parses.
Three flavours dominate right now:
- Astro islands: Astro renders every page to static HTML at build or request time, then lets you mark specific components as interactive with
client:load,client:visible, orclient:idle. Each island ships its own tiny bundle. The rest is zero JS. - htmx: A ~14 KB library that lets any HTML element issue AJAX requests and swap HTML fragments back into the page. Your server returns HTML, not JSON. No client-side router, no virtual DOM, no state library.
- Plain server-rendered HTML + sprinkles: Rails, Django, Laravel, Phoenix, or a Node backend rendering templates, with Alpine.js or vanilla JS for the handful of interactive widgets. Turbo (from the Rails world) or Unpoly handle navigation without full page reloads.
All three share the same core bet: most of your app is not actually interactive. A product page has one interactive element (add to cart). A blog post has zero. A dashboard has a filter panel and a chart — the rest is text. Shipping 400 KB of React to render text is the waste.
Why did SPAs win in the first place — and why is the pendulum swinging back?
SPAs won because building interactive UIs in jQuery was genuinely miserable, and because the JAMstack era made "ship a static bundle to a CDN" the path of least resistance. React, Vue, and their routers gave you componentisation, state management, and hot reload in a coherent package.
What has changed since:
- Edge rendering is cheap. Cloudflare Workers, Vercel Edge, Fly.io — you can server-render HTML in 20ms from a datacentre near the user.
- Bundle sizes kept growing. The median React app in 2026 ships more JavaScript than a Windows 95 install. Real users on mid-range Android phones on 4G feel every kilobyte.
- Core Web Vitals became a ranking signal that actually bites. LCP over 2.5s tanks your SEO. INP (Interaction to Next Paint) replaced FID in March 2024 and is brutal to hydration-heavy SPAs.
- React Server Components made "render on the server" fashionable again inside the React ecosystem itself. Once the framework authors admit it, the debate is over.
Which signals mean you have outgrown the SPA model?
Check your production metrics before you rewrite anything. If three or more of these are red, an html-first migration will pay for itself.
- TTFB > 600ms on the initial document. If your SPA is hosted as a static shell and TTFB is still slow, it is CDN misconfiguration. But if you are server-rendering React and TTFB is bad, the framework is the bottleneck.
- JavaScript bundle > 250 KB gzipped on the critical path. Anything above this ships slower than the HTML equivalent on a mid-tier Android over 4G. Check the Network tab, filter by JS, sum the transfer sizes.
- LCP > 2.5s at p75. Google's threshold. Below this you are penalised in search rankings. Above 4s and you are in the red zone. Pull the number from Search Console's Core Web Vitals report.
- INP > 200ms at p75. Hydration blocks the main thread. Interaction feels sticky. INP catches this where FID never did.
- SEO drop-off after a redesign. If organic traffic tanked when you migrated to a client-rendered SPA, Google is not seeing your content on first render. This still happens in 2026 despite everyone claiming Googlebot renders JS. It renders it eventually. Not always in time.
- Bounce rate > 60% on mobile with sub-3s desktop load. The gap is your mobile bundle tax.
If your app is a Figma clone, a spreadsheet, or a real-time collab editor — none of this applies. Keep your SPA. Islands architecture is for content-heavy apps with sparse interactivity, which is 80% of the web.
Astro vs htmx: which one should you pick?
Verdict: Astro if you are coming from React/Vue and want to keep writing components. htmx if your backend is Rails, Django, Laravel, Phoenix, or Go and you want to delete your frontend build step entirely.
Astro
Astro is a build tool and framework. You write .astro files that look like JSX with frontmatter, and you can drop in React, Vue, Svelte, or Solid components as islands. Content collections give you type-safe Markdown. The output is static HTML plus per-island JS chunks. If you have already read our Astro 5 vs Next.js 15 comparison, you know the shape of it. Deploy anywhere static — Cloudflare Pages, Netlify, Vercel.
---
import Counter from '../components/Counter.tsx';
const posts = await Astro.glob('./posts/*.md');
---
<html>
<body>
<h1>Blog</h1>
<ul>{posts.map(p => <li>{p.frontmatter.title}</li>)}</ul>
<Counter client:visible />
</body>
</html>Only the Counter ships JS, and only when it scrolls into view.
htmx
htmx is a single script tag. Your server returns HTML fragments; htmx swaps them in based on attributes.
<button hx-post="/cart/add/42" hx-target="#cart" hx-swap="outerHTML">
Add to cart
</button>
<div id="cart"><!-- server renders new cart HTML here --></div>No build step. No npm install. No bundler debate. If your backend already generates HTML (any templating engine), you are 90% of the way there. The tradeoff: you now do UI work in server templates, which is a step backwards if your designers hand off Figma to React devs.
Plain server-rendered HTML + Alpine
The nuclear option. Use your backend's template engine, add Alpine.js for the x-show/x-data sprinkles, and call it a day. Rails 8 with Turbo is the polished version of this. Basecamp and GitHub have shipped serious products this way for years.
How do you actually migrate an existing React SPA?
Do not rewrite the whole app in a weekend. Strangler-fig it.
- Identify your leaf routes. Marketing pages, blog, docs, about, pricing — these are pure content. They are the easy wins.
- Set up a parallel Astro or htmx project on a subpath (e.g.
/blog/*) behind your reverse proxy. - Migrate one route at a time. Measure LCP before and after. You will see the numbers move immediately.
- Reserve the SPA for the genuinely app-shaped parts — the authenticated dashboard, the editor, the checkout flow. These may stay React forever, and that is fine.
- Kill the shared layout duplication. The main pain point is header/footer drift between the two stacks. Use a shared web component or server-include for the chrome.
Ship measurement before you ship the migration. If you cannot show LCP going from 4.2s to 1.1s in a Grafana panel, you have no way to defend the work in a retro. Sentry and DebugBear both track Core Web Vitals from real users — wire one up before you touch the code.
The honest downsides
HTML-first is not free. The tradeoffs:
- Client-side state across pages is harder. A React SPA can hold state in a context provider forever. An html-first app reloads state per page (unless you use Turbo/Unpoly for morphing navigation).
- Rich interactivity gets awkward. Drag-and-drop, complex forms with cross-field validation, real-time features — these belong in an island or a full SPA route.
- Hiring pool is smaller. Every frontend dev knows React. Fewer know Astro's island model or htmx's swap semantics well enough to debug production issues.
- Backend coupling. htmx especially couples your frontend to your backend framework. Splitting the team into frontend and backend becomes harder because there is no clean API boundary.
The verdict
If you are building a content site, a blog, a marketing page, a docs site, an e-commerce catalogue, or a mostly-read dashboard — start html-first. Use Astro if your team knows React. Use htmx if your backend is not JavaScript. Reserve full SPAs for genuinely app-shaped products.
The HN story that kicked this off was not a fluke. Doubling users overnight is what happens when your median page load goes from 4.5s to 1.2s and Google finally serves your content to people on 4G. The signals were in your dashboards all along.
FAQs
Is HTML-first the same as server-side rendering?
No. SSR still ships a full JavaScript framework runtime to hydrate the entire page. HTML-first ships zero JS by default and only hydrates specific interactive islands. An SSR React app can still weigh 300 KB gzipped; an Astro page with one island might weigh 8 KB.
Does htmx work with a Node.js backend?
Yes. htmx is backend-agnostic — any server that returns HTML fragments works. Express, Fastify, Hono, or Elysia all pair fine with htmx. Bun's built-in templating or a lightweight engine like Eta covers the rendering side.
Will Google index htmx-loaded content?
The initial page load is static HTML, which Googlebot indexes normally. Content loaded via htmx swaps after user interaction is not indexed — same as any AJAX-loaded content. Anything you need in search results must be in the initial server response.
Can I use React components inside an Astro site?
Yes. Astro supports React, Vue, Svelte, Solid, Preact, and Lit as first-class integrations. You add @astrojs/react, mark the component with a client:* directive, and it ships as an island. The rest of the page stays zero-JS.
When should I NOT go HTML-first?
Skip html-first if your product is a real-time collaboration tool (Figma, Notion), a heavy in-browser editor (Photoshop clones, DAWs), a spreadsheet, or anything with persistent client state across dozens of routes. The SPA model exists for a reason — use it where the reason applies.