S

HomeBlogs

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

My ideal framework for building apps

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 for use 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=&#123;4&#125; />
  </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=&#123;4&#125; />

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 &quot;river:schema&quot;

# 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