DEV Community

Cover image for I Built a Web Framework From Scratch on Bun — Here's What I Learned
Ranjeet Kumar Jena
Ranjeet Kumar Jena

Posted on

I Built a Web Framework From Scratch on Bun — Here's What I Learned

Introduction

I kept running into the same problem across projects: pages that were, structurally, documents — a blog post, a portfolio, a product page — shipping a client-side JavaScript runtime whether or not anything on the page actually needed to be interactive. Even frameworks built around "islands" still start from the assumption that JavaScript is the default and you carve out exceptions. A hydration shim ships. A router ships. Something ships, even to a page that's just text and images.

That inversion bothered me enough to build my own answer to it. Stoneware is a Bun-native, server-first framework built on one rule: HTML is the default, and JavaScript is opt-in, enforced by where a file lives rather than a flag you have to remember to set.

The idea

Two decisions do almost all the work:

HTML by default. Every route renders to a complete HTML string on the server. A page with no interactive components ships zero bytes of JavaScript — not a small runtime, not a hydration shim, nothing. The absence of a script tag is the actual output, not an optimization applied after the fact.

JavaScript only where interaction is needed. A component becomes interactive if — and only if — it lives under islands/. Anything under routes/ never ships client code. There's no per-file directive to remember and no way to accidentally make a page interactive. The file's location is the decision.

Why Bun

I didn't want to bolt a framework onto Node and call it done. Stoneware is built directly on Bun's primitives — Bun.serve, Bun.build, Bun.escapeHTML, Bun.CSRF, Bun.FileSystemRouter — which means it's Bun-native, not "Node-compatible, running on Bun."

A few things fell out of that choice:

  • Runtime. Server, build, and test all run on one runtime. No separate process for bundling, no separate test runner to configure.
  • Build tooling. Bun.build handles island bundling directly — no reaching for a second bundler and reconciling two configs.
  • Fast development loop. stoneware dev reloads fast because there's no extra toolchain being shelled out to underneath it.
  • Single ecosystem. One lockfile, one package manager, one test runner (bun test). Fewer places for versions to drift out of sync.

Scaffolding a new project still works with plain npx create-stoneware my-site before Bun is even installed — everything after that (dev server, build) runs on Bun, and the CLI tells you if it's missing.

How Stoneware renders a page

Request
   ↓
Server
   ↓
SSR
   ↓
HTML
   ↓
Browser
   ↓
Interactive islands only
Enter fullscreen mode Exit fullscreen mode

A request comes in, Bun.FileSystemRouter resolves it to a route function, that function runs on the server and returns markup, Stoneware renders it to a complete HTML string, and that's what ships to the browser. The browser only hydrates the islands actually referenced on that specific page — everything else stays exactly as static HTML.

Islands

  • Static HTML stays static. If a page doesn't import anything from islands/, it ships no script tag at all. Not a minimal one — none.
  • Interactive components become islands. Drop a file under islands/ and it hydrates on the client. That's the entire mechanism.
  • Client JavaScript is opt-in. Hydration directives like client:visible, client:idle, and client:media control when an island hydrates. But whether a page ships any JS at all is already decided by file location before any directive comes into play.

Islands use Preact Signals directly, re-exported as stoneware/signals, rather than a bespoke reactivity engine — that's a deliberate scope boundary. Templates themselves are plain functions: props in, markup out. No classes, no hooks, no lifecycle methods to learn.

The numbers, measured on Stoneware's own docs site in production: the whole client runtime — signals, hydration, DOM — comes to roughly 3.4 KB gzipped. A single island (a counter component) adds about 0.2 KB. A page with no islands adds zero bytes and no script tag. In a benchmark building the same 16-page site with matching content and five interactive components across Stoneware, Astro 5.18, and Next.js 15.5, Stoneware shipped 14.2 KB of JS against Astro's 193.1 KB and Next.js's 346.0 KB — roughly 13–24x less for the same interactivity. LCP followed the same pattern (1217 ms vs. 2253 ms vs. 2965 ms), because Stoneware has no fixed client-runtime cost sitting on the critical path. Numbers like this are easy to cherry-pick, so treat the ordering as the finding and the exact figures as indicative — bytes here are uncompressed, and production gzip narrows the gap somewhat.

Why Stoneware isn't resumable — yet

I want to be straight about this one instead of dressing it up: Stoneware does not currently do resumable SSR. I considered it early, and deliberately deferred it rather than half-build it.

Resumability — in the sense frameworks like Qwik use the term — means the server can serialize enough of the application's state and event listeners into the HTML that the client can "resume" execution without replaying the component tree from scratch. It's a genuinely hard problem: you need a serialization format for closures and state, a way to lazily load only the code a given interaction needs, and a runtime that can reconstruct execution context from that serialized state on demand. Doing it properly touches nearly every other architectural decision in the framework — the build pipeline, the islands model, the signals runtime.

Islands already solve the version of this problem I actually had: most of a page needs no JavaScript at all, and the parts that do only need it once, on load. Resumability solves a different problem — deferring when that JavaScript executes, down to the level of individual event listeners — and it's a real one for a certain class of app. It's just not a problem Stoneware's target use case (content-heavy sites) hits often enough to justify the complexity right now. It's on the list, deliberately unshipped rather than accidentally missing, and it'll get built when I'm confident I can do it without compromising the parts of the architecture that already work.

Static export

stoneware export prerenders every route to static HTML. The output is plain files — no server process required to serve them — which means they deploy to any static host or CDN: Cloudflare Pages, Netlify, GitHub Pages, S3 behind CloudFront, whatever you've already got.

That's a separate path from stoneware build --target vercel, which emits a server bundle with Vercel's Bun-preset entrypoint for projects that need the full server runtime rather than a static export. If your site doesn't need server-side logic per request, export is the simpler and cheaper option — it's also the fastest build path measured so far, at well under a second for a 16-page site.

Security

Auto-escaping, CSRF verification, and a restrictive CSP are on before you write a line of configuration:

  • CSRF verification runs automatically via Bun.CSRF, tied to a per-environment secret you set once.
  • CSP is restrictive by default rather than opt-in.
  • Secure defaults extend to how server and client agree on what's safe: one shared module decides what a given attribute is allowed to contain, so something like a javascript: URL gets refused both on first render and on every subsequent client-side update — not just at the initial SSR pass.

The unsafe path exists, but it's intentionally named to stand out in a code review: raw() and dangerouslySetInnerHTML are both greppable on purpose. If you see either in a diff, that's the point where you look twice.

What building a framework taught me

This is the part I'd actually want another framework author to read.

The concrete one: my own portfolio site — built with Stoneware, using an Odisha temple-inspired stone-and-terracotta design — surfaced a production CSS bug in the framework itself. Styles that worked fine in dev broke under the production build path, which meant the bug wasn't in my application code at all — it was in how Stoneware handled asset output between the two modes. Finding it meant accepting that the thing I was debugging wasn't my site; it was the tool I'd built to make the site.

That's the pattern that repeated in different shapes throughout the project:

  • Asset tracing problems — figuring out which assets a given build actually depends on gets harder than it sounds once islands, co-located CSS, and static export all need to agree on the same dependency graph.
  • Deployment problems — a static export and a server bundle are genuinely different artifacts with different failure modes, and testing "does this deploy correctly" means testing both paths, not one.
  • Static export edge cases — routes that behave correctly under stoneware dev don't always behave the same way once prerendered, especially anything touching dynamic data at build time.
  • CSP integration — a strict-by-default CSP is easy to state as a feature and hard to keep true once real pages need real inline styles or third-party embeds without quietly weakening the policy.
  • Dependency boundaries — deciding what belongs in stoneware/signals versus the framework core versus application code is a boundary you have to keep re-drawing as the framework grows, not a decision you make once.
  • Framework bugs vs. application bugs — the hardest debugging sessions were the ones where I didn't yet know which side of that line I was on. Building the docs site as a real Stoneware consumer (not a special-cased internal example) was what forced that distinction to actually matter.

None of this is dramatic. It's the ordinary tax of building the tool and the thing built with the tool at the same time, and I think that tax is underrated in how "just build a framework" gets talked about online.

Where Stoneware is today

Stoneware is open source under MIT, currently at v0.1. There's a test suite (bun test) alongside the source. The documentation lives in its own repository and is itself built with Stoneware — it consumes the framework from npm the same way any other project would, so the docs can't quietly depend on unreleased behavior.

Real usage so far is my own portfolio site, which is also where the production CSS bug turned up.

What's deliberately not in v0.1: streaming SSR, resumability (see above), multi-framework islands, edge/serverless targets, and a local-first data layer. Each of those was considered and set aside rather than overlooked — the reasoning for each is in the framework's CLAUDE.md.

Try it / contribute

bunx create-stoneware my-site   # npx create-stoneware my-site also works
cd my-site
bun install
bun run dev
Enter fullscreen mode Exit fullscreen mode

If you want to see how the pieces fit together — the request pipeline, the render model, and the reasoning behind the parts that are easy to undo by accident — that's in ARCHITECTURE.md in the core repo.

Stoneware is still early. I'm not trying to claim that another framework is needed for every project. I'm interested in seeing whether this architecture makes sense to other developers.

If you're interested in Bun, SSR, islands, resumability, or framework internals, I'd love technical feedback.

Stoneware Core on GitHub
Stoneware Documentation

Top comments (0)