stoneware

Bun-native · server-first · v0.2.0

Shape your web application at build/server time

HTML by default. JavaScript by choice.

Build content-heavy sites without shipping a client runtime to pages that do not need one. Interactivity is opt-in per directory, and the safe path is the default one.

Page with no islands
0 B
Page with one island
4.8 KB
Each island after that
0.2 KB
Runtime dependencies
1

This site is the documentation for stoneware-dev/stoneware-core — the framework's own source, issues, and releases live there.

Plain clayBisqueGlazeVitrified

The problem

The web ships JavaScript to sites that are documents

Stoneware inverts the default: HTML first, JavaScript only where you ask for it. Four specific problems that follow from it.

You ship a runtime to render a document
A page with no islands ships zero bytes — no runtime, no hydration shim, no script tag. Asserted by the test suite, not just intended.
The client/server boundary drifts
The boundary is a directory, enforced by the build. Files under routes/ are never handed to the bundler, so they cannot reach the client.
You hydrate things that will never change
The header, footer and article have nothing to hydrate. They are strings the server produced, and they stay that way.
Security is opt-in, and CSP is what everyone skips
Stoneware never emits inline executable script, so script-src 'self' just works. This site runs under the default policy, unmodified.

All seven, and the ones it deliberately does not solve →

The separation

Two directories, two destinations

Nothing scans your files for a directive and decides. The boundary is a directory, and the build enforces it — a route is fired once and is finished, an island is fired again for the surface that ships.

Stoneware, in section

routes/blog/[slug].tsx

one firing

<article>…</article>0 B ships

islands/Counter.tsx

the same firing

<button>…</button>0 B ships

then a glaze firingCounter-a1b2c3.js0.2 KB ships

Both shelves come out of the first firing as finished HTML, which is why an island is never an empty box waiting for its script. Only the glaze is compiled for the browser, and only the island has one. The whole pipeline, step by step →

Install

One command, either runner

Scaffolding runs on plain Node, so it works before Bun is installed. Everything after that runs on Bun.

bunx create-stoneware my-site

Both runners scaffold the project. Stoneware itself runs on Bun: the generated app needs it for stoneware dev and stoneware build.

Principles

Six decisions, held consistently

Each of these is a constraint the framework enforces rather than a convention it suggests.

01

Server-first

Every route renders to a complete HTML string. A page with no islands ships zero bytes of JavaScript — not a small runtime, not a hydration shim, nothing.

02

No component model

Templates are plain functions: props in, markup out. No classes, no hooks, no lifecycle. Logic lives in ordinary functions, not inside UI definitions.

03

Signals, not an engine

Islands use Preact Signals directly. Stoneware does not implement a reactive graph — that is a deliberate scope boundary, not an oversight.

04

Interactivity by location

A file under islands/ hydrates. A file under routes/ never does. No per-file directive to remember, and no way to make a page interactive by accident.

05

Safe before configured

Auto-escaping, automatic CSRF verification, and a restrictive CSP need no configuration to be on. The unsafe path requires typing more.

06

Bun's own APIs

Bun.serve, Bun.build, Bun.escapeHTML, Bun.CSRF, Bun.FileSystemRouter. No npm package reimplementing something the runtime already ships.

Islands

Interactivity you opt into

islands/Counter.tsxtsx
// islands/Counter.tsx — the only file here that ships JS
import { signal } from "stoneware/signals";

const count = signal(0);

export default function Counter() {
  return (
    <button onClick={() => count.value++}>
      Clicked {count} times
    </button>
  );
}

Live, on this page

Server-rendered as static HTML, then hydrated. Clicking updates one text node — the tree is never re-run and nothing is diffed.

Content sites

Built for pages that have to be found

None of this is an SEO feature bolted on. It follows from rendering the whole document on the server and shipping no runtime alongside it.

The document is complete in the first response
A crawler that never runs JavaScript still sees every word, because nothing is assembled on the client. What you get with curl is what gets indexed.
One call writes the whole head
seo() covers canonical, robots, hreflang alternates, Open Graph with article metadata, X cards and JSON-LD. Call it from the wrong place and the tags land in <body>, where nothing reads them — so the dev server warns you, naming the route.
A page with no islands has no script to block on
No runtime to fetch and parse, and no hydration pass between the HTML arriving and the page being usable. Not a small runtime — no script tag at all.
The export names your broken internal links
Links pointing at pages the static export did not write are reported at build time, rather than found by a crawler weeks after they shipped.

The full seo() reference →

Measured

Numbers, and where they came from

Twenty articles and an index — the same content, the same markup, built three ways and served by each framework's own production server.

JavaScript on an article page

0 B

Astro 0 B · Next.js 576 KB

Pages shipping no JavaScript

20 of 21

Astro 20 of 21 · Next.js 0 of 21

JavaScript on the one interactive page

4.8 KB

Astro 0.3 KB · Next.js 173.7 KB

Peak memory during the build

88 MB

Astro 356 MB · Next.js 1143 MB

Median time to first byte

1.13 ms

Astro 1.74 ms · Next.js 1.84 ms

Requests per second, 100 connections

2236

Astro 1984 · Next.js 920

The third row is a loss and it stays on the page: a plain script tag beats a hydrated island for one text box, and 0.3 KB against 4.8 KB is not close. The full study, and what varies between runs →

Documentation

Read on

What changed in 0.2.0, and every release before it →