Skip to content

Choose deliberately

There is no winner. There is a fit.

See the strengths and the gaps of four approaches on the same ground. We are not hiding the rows JSkelet loses.

ÖlçütJSkeletNext.js (App Router)Astro + adapterExpress + EJS (by hand)
Client JS on an empty page One entry; nothing else without islands React plus runtime, even on a static page None, unless you pick a UI framework None (and no interaction either)
Framework JSON in the HTML None — the document is the page __NEXT_DATA__ (or RSC flight) alongside HTML None by default None
Interaction model Vanilla islands with a mount function Component tree with a server/client boundary Islands in the UI framework of your choice Script tags you wire up yourself
HTML caching In-process TTL with stale-while-revalidate ISR, tied to the platform and its storage Static build or the adapter's CDN None; you build it yourself
Targeted invalidation Path, pattern or RegExp via invalidateHtmlCache() Supported by tag and by path A rebuild, or whatever the adapter offers None
Routing An explicit table; paths are written down File system File system An explicit table
Streaming and partial render Missing; slow sections move to fragments Supported through streaming boundaries Server islands and deferred content By hand
Build chain esbuild and Tailwind v4; optional steps skip themselves A powerful bundler with a wide config surface Vite Whatever you assemble
Types Plain JS with JSDoc; no compile step TypeScript as a first-class citizen TypeScript as a first-class citizen Up to you
Per-session page HTML Not cacheable; move it to a fragment Supported Supported in SSR mode Supported

+ an advantage of this approach a deliberate or structural gap neutral, simply different

Right now, in this browser

Watch the cache difference live.

Same template, same bytes, same network — and an 80 ms simulated API inside the producer. A hit skips that wait; a miss pays it every time.

live

Served from cache

measuring…

 

 

 

/_fragment/render-demo-cached — HIT skips the 80 ms upstream sleep; only the round trip remains.

every request

Rendered every time

measuring…

 

 

 

/_fragment/render-demo — no-store — pays the simulated API on every request, then renders.

This section measures nothing when JavaScript is disabled; the rest of the page is unaffected.

Round trip dominates both totals — that is why two empty renders looked tied. The honest signal is server produce: ~0 ms on a hit, ~80 ms+ when the producer runs. On a real page that sleep is your database or CMS; the cache stops charging visitors for it.

View Source

No __NEXT_DATA__. No framework JSON in the document.

What ships to the crawler and the first paint is the page — not a second copy of your props serialized into a script tag.

typical App/Pages HTML payload tax
<main>…</main>
<script id="__NEXT_DATA__" type="application/json">
{"props":{"pageProps":{"posts":[…],
"user":null,"locale":"en"}},
"page":"/","query":{},"buildId":"…"}
</script>
<script src="/_next/static/chunks/…js">
</script>

Content is sent twice: once as HTML, again as JSON for hydration. Crawlers and humans both download the tax.

JSkelet HTML page only
<main>
  <h1>Latest posts</h1>
  <article>…</article>
  <div data-island="newsletter"></div>
</main>
<script type="module" src="/assets/js/main….js">
</script>

One document. Islands load only when a marker is on the page — no hidden state dump, no second representation of the tree.

Measured weight

This site's own build output

What the 'client JS on an empty page' row above actually looks like here — then an estimated Next.js App Router first-load for the same kind of page.

This page's own build output, read from disk and measured as the request came in. The right column is after gzip. Island chunks download only when their element scrolls into view; a first load never fetches all of them.
Raw gzip
Stylesheet (Tailwind v4 output) 77.4 kB 13.1 kB
Client entry (island loader) 564 B 301 B
Icon sprite 12.1 kB 3.9 kB
Every island (on demand, separate chunks) 72.0 kB 27.7 kB
Total — if every island loads 162.0 kB 45.0 kB
  • Stylesheet (Tailwind v4 output) 13.1 kB
  • Client entry (island loader) 301 B
  • Icon sprite 3.9 kB
  • Every island (on demand, separate chunks) 27.7 kB

This site (JSkelet)

measured

45.0 kB

gzip · first-load client assets

  • Stylesheet (Tailwind v4 output) 13.1 kB
  • Client entry (island loader) 301 B
  • Icon sprite 3.9 kB
  • Every island (on demand, separate chunks) 27.7 kB

Sample Next.js App Router page

estimate

115.2 kB

gzip · first-load client assets

  • React + ReactDOM 43.0 kB
  • Next.js client / App Router runtime 46.9 kB
  • Page + shared app chunks 9.8 kB
  • Stylesheet (Tailwind-scale) 15.6 kB

Estimates, not a build from this repo: rounded gzip figures for a minimal App Router page (React ~44 kB, Next client runtime ~48 kB, thin page chunk, Tailwind-scale CSS). Your Next app may be leaner or heavier — the point is the framework floor you pay before your own UI.

Decide

Apply the table to your own project

Most rows are neutral. The decision usually comes down to the last two.

Good fit

  • Content sites, blogs and documentation
  • Marketing and campaign pages
  • Product listings, catalogues and classifieds
  • Any page where SEO is a revenue line

Wrong choice

  • Visitor-facing dashboards and app UIs
  • Editor-like, state-heavy interfaces
  • Pages that change with every signed-in user
  • Real-time screens that update every second