My ideal framework for building apps
can you believe I have to create a new language and framework to get there? hear me out. Introducing Rill (language) + River (framework).
Published on August 16, 2025

Table of contents
- hear me out…
- what are Rill (language) & River (framework)?
- the routing model (path → URL, suffix → role)
- clean, traceable imports
- minimal JSX + Tailwind + real layouts
- the SSR 'data prelude' (with inline Loading/Error)
- components & attribute sugar
- REST that reads like docs (with a tiny schema + relations)
- CSR islands & friendly client data
- folder structure that scales across platforms
- friendly comparisons (JS, Rails, Laravel, Django, Elixir, Go)
- when you should (and shouldn't) use this
- trade-offs & what's next
- tl;dr & repo
hear me out…
If you've ever glued together routers, serializers, client caches, validators, migrations, two renderers, and three different "loading" states… you've earned that thousand-yard stare 😅
Rill (the language) + River (the framework) try a different approach:
- One project brain: path = URL, and file suffix decides the role.
- Minimal JSX with Tailwind classes.
- Top-level data prelude in pages; inline
Loading
/Error
(no extra files). - Schema-first: one definition → validators + DB helpers + OpenAPI.
- Client data that doesn't fight you: small query cache, local offline DB, simple replication.
- Realtime via a single
.ws
file. - SEO that's automatic (streaming SSR + clean HTML).
No, it won't solve your company's org chart, but it does make shipping features feel… normal.
what are Rill (language) & River (framework)?
- Rill is a tiny, readable app language:
use
imports,view
components, minimal JSX, Tailwind classes. - River is a web-only framework that routes and renders Rill files. Built-ins come from a
river:
module scheme (e.g.,use http
,use { db } from "river:schema"
). No npm sprawl.
Roles by suffix:
*.ssr
→ server-rendered page (streams)*.csr
→ client page/island (browser APIs allowed)*.rest
→ REST API*.ws
→ WebSocket channel*.layout
→ layout wrapper (auto-applied per folder)*.schema
→ DB schema + validators- Dynamic route:
[id].ssr
→/path/:id
- Convention: folder-per-route +
index.*
for base paths
the routing model (path → URL, suffix → role)
web/
_layout.layout
_shared/
chrome.view
products/
index.ssr # /products
[id].ssr # /products/:id
_island/
add-to-cart.csr
server/
api/
products/
index.rest # /api/products
[id].rest # /api/products/:id
ws/
notification.ws # /ws/notification
One routing strategy for pages, APIs, and websockets. Your brain can cache it.
clean, traceable imports
- Built-ins:
use http
(shorthand foruse http from "river:http"
),use { db, Product } from "river:schema"
. - Views/components:
use view
with app-root aliases. No barrels. Jump-to-def works.
use view { Header, Footer } from "@web/_shared/chrome"
use view ProductGrid from "@web/products/ProductGrid"
Aliases: @web/
→ web/
, @server/
→ server/
, @core/
→ core/
.
minimal JSX + Tailwind + real layouts
-- web/_layout.layout
use view { Header, Footer } from "@web/_shared/chrome"
view Root({ children, title }):
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>{ title ?? "River App" }</title>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
</head>
<body class="min-h-screen bg-gray-50 text-gray-900">
<Header/>
<main class="max-w-5xl mx-auto p-6">{ children }</main>
<Footer/>
</body>
</html>
end
Pages don't import<Root>
—River auto-wraps each.ssr/.csr
with the nearest.layout
.
the SSR 'data prelude' (with inline Loading/Error)
Top-level let
runs per request. If it awaits, Loading
shows; if it throws, Error
shows.
-- web/products/index.ssr
use http
use view ProductGrid from "@web/products/ProductGrid"
let products = http.get("/api/products?limit=24").json()
view Loading():
<div class="animate-pulse h-24 bg-gray-200 rounded"/>
end
view Error({ error }):
<pre class="text-xs text-red-600 font-mono">{ error.message }</pre>
end
view ProductsPage():
<section>
<h1 class="text-2xl font-semibold mb-4">Products</h1>
<ProductGrid data:products key:products.id cols={4} />
</section>
end
This replaces the "data loader function + suspense wrapper + error boundary + spinner component" dance.
components & attribute sugar
-- web/products/ProductCard.view
view ProductCard({ item }):
<a href={"/products/" + item.id} class="block bg-white border rounded p-3 hover:shadow">
<div class="font-medium">{ item.name }</div>
<div class="text-sm text-gray-600">${item.price.toFixed(2)}</div>
</a>
end
export { ProductCard }
-- web/products/ProductGrid.view
use view ProductCard from "@web/products/ProductCard"
view ProductGrid({ data, key, cols = 4 }):
<div class={"grid grid-cols-2 md:grid-cols-" + cols + " gap-4"}>
{ data.map(it => <ProductCard key={ key(it) } item:it />) }
</div>
end
export { ProductGrid }
Callsite sugar:
<ProductGrid data:products key:products.id cols={4} />
Compiles to data={products}
and key={(it) => it.id}
. Readable and explicit.
REST that reads like docs (with a tiny schema + relations)
This mirrors a classic Express controller, but in one file, with schema-derived validation and DB helpers.
-- server/api/products/index.rest
use { db, Product, Category, ProductCategory } from "river:schema"
# tiny schema with a many-to-many relation
schema Product table "products" {
id uuid primary auto
name text min(1)
price number gte(0)
stock int gte(0)
relations { categories many Category through ProductCategory(product_id, category_id) }
}
schema Category table "categories" { id uuid primary auto; name text min(1) unique }
schema ProductCategory table "product_categories" {
product_id uuid references products(id) on delete cascade
category_id uuid references categories(id) on delete cascade
primary (product_id, category_id)
}
# GET /api/products?q=&page=&size=
export GET list(req, res):
let q = req.query.q ?? ""
let pg = int(req.query.page ?? "1")
let sz = clamp(int(req.query.size ?? "20"), 1, 100)
let items = db.products.find_many({
where: { name: { ilike: "%{q}%" } },
limit: sz, offset: (pg - 1) * sz,
include:{ categories: true }
})
res.paginate({ page: pg, size: sz }).json(items)
end
# POST /api/products
export POST create(req, res):
let data = Product.insert.parse(req.json()) # validator from schema
let row = db.products.insert(data)
res.status(201).json(row)
end
# /api/products/:id
export "/:id" = {
GET(req, res):
let row = db.products.find_by_id(req.params.id, { include: { categories: true } })
if row == nil then res.status(404).json({ error: "not_found" }) else res.json(row) end
end,
PATCH(req, res):
let data = Product.update.parse(req.json())
res.json(db.products.update(req.params.id, data))
end,
DELETE(req, res):
db.products.delete(req.params.id)
res.status(204).send()
end
}
# /api/products/csv
export "/csv" = {
POST(req, res):
let file = req.file() # streamed multipart
# parse CSV -> insert rows in db.tx(...)
res.json({ imported: 42 })
end
}
It's the "what you wanted to write anyway" version of an API.
CSR islands & friendly client data
When you need browser APIs (storage, workers, IndexedDB), drop a .csr
island.
Add to cart (local storage):
-- web/products/_island/add-to-cart.csr
use storage, dom
fn cart(): any[] -> storage.json.get("cart") ?? []
fn save(xs): -> storage.json.set("cart", xs)
export default function mount(el):
el.on("submit", (ev) ->
ev.prevent()
let id = el.form("id").value
let qty = int(el.form("qty").value)
let xs = cart()
let item = xs.find(i => i.id == id)
if item then item.qty += qty else xs.push({ id, qty })
save(xs)
dom.toast("Added to cart ✔")
)
end
Offline-first list (query + ODB):
-- web/products/_island/list.csr
use query, http, odb, json
let t = odb.table("products")
let cur = t.where({}).orderBy("created desc").limit(50).listen()
let fill = query.create({
key: ["products",""],
fetch: () -> http.get("/api/products?limit=50").json(),
onSuccess: rows -> t.upsertMany(rows)
})
view Loading(): <div class="text-sm text-gray-500">Loading…</div> end
view List(): <pre class="text-xs">{ json.stringify(cur.value, 2) }</pre> end
Pattern: ODB renders instantly; query
fills in the background. No flicker, works offline.
folder structure that scales across platforms
You asked for platform folders + a server rail—yup:
core/ # shared runtime, schema engine, data, ui, plugins
web/ # site/app (route-based)
public/
_layout.layout
_shared/
chrome.view
products/
index.ssr
[id].ssr
_island/
add-to-cart.csr
android/
ios/
macos/
windows/
linux/
server/ # api + ws + jobs + db
api/
products/
index.rest
[id].rest
ws/
notification.ws
webhooks/
cron/
db/
migrations/
0001_init.sql
.env
.rill
.river
Imports stay clean with @web/
, @server/
, @core/
.
friendly comparisons (JS, Rails, Laravel, Django, Elixir, Go)
Node / Express / Next
// Express
router.get('/products', async (req, res) => {
const q = req.query.q || '';
const items = await db('products').where('name', 'ilike', `%${q}%`).limit(20);
res.json(items);
});
# River
export GET list(req, res):
let q = req.query.q ?? ""
let items = db.products.find_many({ where: { name: { ilike: "%{q}%" } }, limit: 20 })
res.json(items)
end
// Next SSR (sketch)
export async function getServerSideProps(){/* fetch → props */}
# Rill SSR
let items = http.get("/api/products").json()
view Loading(): <div class="animate-pulse h-24 bg-gray-200 rounded"/> end
view Page(): <ul>{ items.map(p => <li key={p.id}>{p.name}</li>) }</ul> end
Rails / Laravel / Django: controllers + serializers/requests collapse into one .rest
with schema-derived validation (Product.insert.parse
).
Elixir (Phoenix): Channels ≈ one .ws
file.
Go: Keep the speed, drop the glue—SSR/SEO/islands without wiring five libraries.
when you should (and shouldn't) use this
Use Rill × River if you want:
- SSR that streams + sane SEO by default.
- Minimal files to ship CRUD, realtime, and offline.
- One routing strategy across pages, APIs, and WS.
- Schema-first types/validators/DB helpers and automatic OpenAPI.
- Small, readable client islands (no SPA ceremony).
Maybe not (yet) if:
- You need a massive plugin ecosystem on day one (it's growing).
- You must target a non-web runtime with full parity (native folders are staged).
- You want to bring your favorite npm stack wholesale (River is web-only and minimal by design).
trade-offs & what's next
- Ecosystem depth: adapters/plugins (db, mail, payments, pub/sub) are growing.
- Editor tooling: jump-to-def/completions for
use view
, lint rules for suffix boundaries—rolling out. - Edge runtime: initial subset (
GET/HEAD
.rest
, slim.ssr
) before full parity. - Native targets: folders exist (android/ios/macos/windows/linux); adapters and shared UI will iterate.
- DevTools: cache inspector + ODB browser in the overlay.
tl;dr & repo
- Web-only by design (sites + apps): SSR, SEO, realtime, offline.
- Schema-first: one definition → validators, DB helpers, OpenAPI.
- Minimal JSX: inline
Loading
/Error
, Tailwind classes, simple composition. - One mental model: path = URL, suffix = role,
index.*
convention. - Client data that behaves: tiny query cache, local ODB, easy replication.
- Less code, fewer moving parts: faster features, smaller bundles, happier brains.
Repo (examples + starter): I'll attach the link here.
Quickstart:
river db plan
river db apply
river dev
# open http://localhost:3000