Ship real HTML.
Hydrate on purpose.
Binsby is a server-first React framework. Pages are React components that run on the server. Anything interactive is an island — it's the only JS your visitors download.
Most React frameworks have the defaults backwards.
They render on the server, then re-run the whole app on the client to "hydrate" it. Binsby flips it: pages ship as HTML, and the only JS that reaches the browser is the components you marked interactive — loaded when they actually need to wake up. The bundle stays honest on day one, day three hundred, and after the cart team adds another widget.
No 'use client' to argue with
.server.tsx runs on the server. .island.tsx hydrates in the browser. .action.ts runs on the server, called like a function. No directives, no RSC graph, no compiler tricks — read the extension, you know where the code runs.
Islands wake up when they earn it
Pick when each island boots: visible, idle, media, or first interaction. A modal sleeps until you open it. A toast handler doesn't load until someone fires it. Each island is its own chunk — pages don't share a hydration budget.
Server actions, not endpoints
Write a typed handler in a .action.ts file. Import it in an island, await it, get the result. The Vite plugin swaps in a fetch stub with CSRF, validation, and end-to-end types. You write the business logic — and nothing else.
Skip the data layer
Hit the database, call your CMS, read cookies — then return JSX. No loaders, no internal API for your own pages, no /api route to maintain alongside every screen, no waterfall to debug.
Everything you need. Nothing you don't.
Zero JS by default
Pages render on the server and arrive as HTML. Your hero doesn't wait on a hydration pass that isn't doing anything.
Interactive where it counts
Mark a component an island and it ships its JS — and only its JS. Static content stays static, forever.
JS only when it earns it
visible · idle · media · interaction. A toast handler doesn't load until someone fires it. A modal sleeps until you open it.
Mutations without an API
Write a server function, call it like a function from the browser. Types, validation, and CSRF are handled — you write the business logic.
REST endpoints in one file
Need a webhook target or a JSON feed for mobile? Drop a file under /api, get a typed route. Dynamic params and OpenAPI included.
Different shell per route
Marketing pages, an authed dashboard, and a checkout flow can each get their own layout. Match by URL, stack as needed.
SPA feel, MPA bones
Link clicks fetch HTML and swap the DOM. No router state to sync, no bundle to keep alive — just navigation that feels instant.
Tailwind, but not required
Tailwind v4 wired up out of the box, with @theme tokens. Prefer Sass or plain CSS? Binsby doesn't care.
Types end-to-end
Action input and output, page data, layout props — all typed, all inferred. No codegen, no runtime drift.
Page, island, action. The three things you'll write.
1// A page is an async function. Fetch on the server, render on the server. 2import { Island } from "@bgunnarsson/binsby-islands/server" 3import { db } from "~/lib/db.server" 4 5export default async function Product({ slug }: { slug: string }) { 6 const product = await db.product.findBySlug(slug) 7 8 return ( 9 <article> 10 <h1>{product.name}</h1> 11 <p>{product.description}</p> 12 13 {/* the only piece of this page that ships JS */} 14 <Island name="AddToCart" props={{ id: product.id }} when={{ type: 'visible' }} /> 15 </article> 16 ) 17}
1// .island.tsx — runs in the browser. Pulls in only what it imports. 2import { useState } from "react" 3import { addToCart } from "./cart.action" 4 5export default function AddToCart({ id }: { id: string }) { 6 const [pending, setPending] = useState(false) 7 8 async function onClick() { 9 setPending(true) 10 await addToCart({ id, qty: 1 }) // typed call → server action 11 setPending(false) 12 } 13 14 return <button onClick={onClick} disabled={pending}>Add to cart</button> 15}
1// .action.ts — runs on the server, callable from islands as a typed function. 2import { defineAction } from "@bgunnarsson/binsby-actions/server" 3import { z } from "zod" 4import { cart } from "~/lib/cart.server" 5 6const Input = z.object({ id: z.string(), qty: z.number().int().min(1) }) 7 8export const addToCart = defineAction({ 9 id: "cart.add", 10 input: { parse: (raw) => Input.parse(raw) }, 11 async handler({ ctx, input }) { 12 await cart.add(ctx, input.id, input.qty) 13 return { ok: true } 14 }, 15})
// No "use client", no "use server". The filename ending tells Binsby where the code runs.
From request to interactive — what your visitor actually downloads.
One island wakes up. The rest stays HTML.
// try +/−. the recommended rail and review form
// never download until they're actually needed.
How it differs.
| Binsby | Next.js | Remix | |
|---|---|---|---|
| Default JS per pageWhat an empty page costs | ●0 KB | ◐~80 KB | ○~70 KB |
| Interactive bitsHow you opt in to client JS | ●Tag a component | ◐'use client' directives | ○Everything hydrates |
| Picking when JS loadsvisible / idle / media / on click | ●Per component | ○On page load | ○On page load |
| Forms & mutationsCalling server code from the browser | ●Typed actions | ●Server actions | ●loader/action |
| API endpointsDrop a file, get a route | ●Yes | ●Yes | ◐Resource routes |
| Smooth navigationNo full reload between pages | ●DOM swap | ●Streaming nav | ●Client nav |
| BuildWhat runs your dev server | ●Vite | ◐Turbopack/webpack | ●Vite |
| Mental modelWhat you have to learn | ●React + 3 file types | ○RSC + boundaries | ◐loader/action API |
// ● yes · ◐ partial / opt-in · ○ no — comparison written by Binsby; mileage may vary
Build pages that don't ship a megabyte of JS.
The starter gives you a working app: pages, layouts, an API, a sign-in flow, and Tailwind ready to go. About thirty seconds to pnpm dev.