Writing dbb1 apps
Models, components and routes in one small Rhai file.
dbb1 apps
A dbb1 app is one Rhai file (app.rhai). It declares models (each becomes a database
table, an admin view and a JSON API) and routes (pages built from components, or JSON).
Deploying it puts it live at https://<name>.apps.dbb1.dev.
app(#{ title: "Todo", nav: [["Tasks", "/"]] });
model Task #{
title: "string! max=200",
done: false,
};
expose(Task);
get("/", || [
hero("Things to do", "A tiny task list."),
form(Task, #{ fields: ["title"] }),
table(Task, #{ actions: [["Done", "/tasks/:id/done"], "delete"] }),
]);
post("/tasks/:id/done", |id| {
Task.update(id, #{ done: true });
redirect("/")
});
Deploy: dbb1 apps deploy (the first deploy creates the app and writes app.yaml; later
deploys update it), or paste the file into the app's Code page on apps.dbb1.dev, or describe
the app on New app and let the builder write it. dbb1 apps deploy --dry-run checks a file
without deploying. Errors come back with a line number.
Never write HTML by hand. Pages are built from components (below); every string you pass to them is escaped. Return an array of components from a route and it becomes a full page in the app's layout (navigation bar, styling, footer).
App settings
app(#{
title: "Reading list", // the app's name in the layout and page titles
description: "Books and reviews.", // meta description
layout: "sidebar", // "sidebar" (default), "top" or "site"
nav: [["Books", "/", "book"], ["Reviews", "/reviews", "chat-quote"]], // [label, path, optional icon]
});
Layouts. sidebar (the default) is a back-office app: a left rail with the nav links and
compact pages. top puts the nav in a slim bar across the top — good for one to three pages.
site is a public-facing look with a dark navigation bar, where hero() becomes a big banner.
Icons are Bootstrap Icons names ("list-check", "people").
Pages update without full reloads (htmx), and confirmations show as toasts — nothing to set up.
Models
model Name #{ field: spec, ... }; — Name is PascalCase. Each model gets id, created_at
and updated_at automatically (don't declare them). A field's value says what it is:
| Write | Meaning |
|---|---|
"string" "text" "int" "float" "bool" "datetime" "json" | a field of that type (text is long text; datetime holds 2026-10-11 or 2026-10-11T09:30) |
"string!" | required |
"string! max=200 unique" | options after the type: min= max= default= unique index required |
"int min=0 default=1" | numbers: min/max bound the value; strings: they bound the length |
false · 0 · 1.5 | bool / int / float field, defaulting to that value |
["draft", "live"] | one of these strings; defaults to the first |
Book | a reference to another model, stored as book_id |
"Book!" | a required reference |
string(#{ label: "Title", max: 200, required: true }) | long form, for a custom label |
Field names are lower_snake_case. Models may reference models declared later in the file.
JSON API. Every model has /api/<plural> (Task → /api/tasks, Category →
/api/categories): GET lists (?field=value&order=-id&limit=20&offset=0), POST creates,
GET/PATCH/DELETE /api/tasks/:id. It's private (needs an app API key from the app's Settings)
unless you expose it:
expose(Task);— public readsexpose(Task, "write");— public reads and writes
Changing models migrates the database when you deploy: new models and fields are added. Removing a field or model keeps its data (it's just no longer read); changing a field's type is refused — add a field with a new name instead.
Data
Models are global names inside routes, hooks and functions.
| Call | Returns |
|---|---|
Task.all() · Task.all(#{ done: false, order: "-id", limit: 20, offset: 0 }) | array of records |
Task.first(#{ ... }) · Task.find_by(#{ title: "x" }) | a record or () |
Task.find(id) | a record or () (id may be 5 or "5") |
Task.count() · Task.count(#{ done: true }) | int |
Task.create(#{ title: "x" }) | the new record |
Task.update(id, #{ done: true }) | the updated record, or () if missing |
Task.delete(id) | true / false |
A record is a map: t.id, t.title, t.created_at, and a reference as t.list_id.
Filters — plain keys mean "equals"; order, limit (max 1000, default 100) and offset
are options. Operators: #{ pages: #{ gt: 300 } }, gte, lt, lte, ne,
#{ status: #{ in: ["a", "b"] } }, #{ title: #{ ilike: "%rust%" } } (like is
case-sensitive), #{ due: #{ null: true } }. order: "-created_at" sorts descending; an array
sorts by several fields.
Validation runs on every write (required, min/max, choices, references must exist, unique).
A failed write in a route answers 422 with the field errors automatically, so
post("/tasks", |body| Task.create(body)) needs no error handling. To handle it yourself —
Rhai's try is a statement, so return from both branches:
model Task #{ title: "string!" };
post("/tasks", |body| {
try {
return Task.create(body);
} catch (e) {
return json(#{ problems: e.errors }, 400); // e.message, e.errors: #{ title: "is required" }
}
});
Deleting a record that others still reference is refused.
Hooks
model Book #{ title: "string!", slug: "string unique" };
before_save(Book, |b| {
b.title = b.title.trim(); // change the record in place
if b.slug == () { b.slug = b.title.to_lower().replace(" ", "-"); }
});
before_delete(Book, |b| {
if b.title == "keep" { throw "this one stays"; } // throw refuses the write
});
after_save(Book, |b| print(`saved ${b.id}`));
before_save runs on create and update (on update the record has an id), after_save,
before_delete, after_delete.
Routes
get, post, put, patch, delete(path, handler). Paths may have :params and a trailing
*rest. Handler arguments are matched by name:
- a route param —
get("/books/:id", |id| ...)(numeric params arrive as integers) body(parsed form or JSON),query(map of the query string),params,headersreq— everything:req.method,req.path,req.params,req.query,req.headers,req.body,req.raw_body
|| ... takes nothing. An unknown argument name is a deploy error.
Return
- components (or an array containing any) → a full page in the app layout;
page("Title", [...])sets the page title - a map, an array of plain values, or a record → JSON
- a string → sent as HTML as-is (prefer components)
json(v)·json(v, 201)·html(s)·text(s)·redirect("/")·status(404)·status(404, "message"), and.header("x-name", "value")on any of them- nothing →
204
/api/<plural> of declared models and /_dbb1/* are reserved.
Components
Components return markup and compose freely; arrays of them concatenate. Strings, numbers and maps passed as content are escaped (a map renders as a label/value list).
| Component | Renders |
|---|---|
page(title, content) | a page with a document title |
hero(title, subtitle) · hero(title, subtitle, actions) | the page header: title, subtitle, buttons on the right (a dark banner in the site layout) |
section(title, content) | a titled section |
card(title, content) · card(content) | a bordered panel |
grid([a, b, c]) · grid(items, cols) | side-by-side columns (stacks on phones) |
stat(label, value) · stat(label, value, hint) | a big number tile |
stats([stat(...), ...]) | a row of stat tiles |
heading(text) · heading(text, level) | a heading (level 1–4) |
p(content) · muted(content) | a paragraph · a grey paragraph |
markdown(text) | formatted text (raw HTML in it is shown as text) |
link(label, href) | a link |
button(label, href) · button(label, href, variant) | a link styled as a button |
action(label, path) · action(label, path, variant) · action(label, path, variant, question) | a button that POSTs to one of your routes; with a question (or a danger variant) it asks first in a dialog |
alert(content) · alert(content, kind) | a notice box |
badge(content) · badge(content, kind) | a small label |
empty(message) | an empty-state placeholder |
detail(map) | a label/value list, e.g. detail(book) |
list(items) · list(items, |item| ...) | a list; the closure renders each item |
table(Model, opts) · table(rows, opts) | a table (below) |
form(Model, opts) | a create/edit form (below) |
raw(html) | trusted HTML — avoid unless you must |
Variants: primary secondary outline-primary outline-secondary success danger
outline-danger link. Kinds: primary secondary success danger warning info.
text() is a response, not a component — use p() for text.
table(Task, opts) queries the model itself: columns: ["title", "list", "created_at"]
(default: the first six fields), plus any filters and order/limit (default newest first, 50
rows), actions, link: "/tasks/:id" (the first column links to each row's page), and
empty: "No tasks yet.". Reference columns show the other record's name;
choice fields show as badges. actions per row: ["Label", "/tasks/:id/done"] posts to your
route (:id and other :fields are filled from the row; optional third and fourth items are the
variant and a confirmation question — danger variants always ask), and "delete" deletes the
row after a confirmation dialog. Actions always POST — for navigation use link.
table(rows, opts) renders an array of maps you already have (columns, actions, link,
empty).
form(Task, opts) renders a form from the field definitions and saves it — no route
needed. Options: fields: ["title", "list"] (default: all), submit: "Add task",
title: "New task", redirect: "/thanks" (default: back to this page), record: task (edit
that record instead of creating), values: #{ book: id } (prefill shown fields; for fields
not shown, the value is fixed and signed — use this to attach a review to its book).
Validation errors appear next to the fields; success shows a toast. Putting a form on a
page is what allows the public to create or edit through it — only the listed fields.
Helpers
escape(s) · url_encode(s) · to_json(v) · from_json(s) · now() (UTC
2026-10-11T09:30:00Z) · today() (2026-10-11) · print(...) (goes to the app's logs).
Rhai in five minutes
- Maps are
#{ key: value }, arrays[1, 2], strings"..."; backticks interpolate:`Hi ${name}`. let x = 1;·if a { } else { }(an expression:let s = if done { "yes" } else { "no" };) ·for t in tasks { }·while·loop·switch.- Missing map keys read as
();a ?? bpicksbwhenais(). fn helper(x) { ... }defines a function. Functions can't see top-levelletvariables — pass them in, or use a closure (|x| ...), which captures them.defaultis a reserved word — that's why field defaults are written"int default=1"or as a literal.- Useful methods:
s.trim(),s.to_upper(),s.to_lower(),s.replace(a, b),s.contains(x),s.len(),s.split(","),parse_int(s),parse_float(s),arr.len(),arr.push(x),arr.filter(|x| ...),arr.map(|x| ...),arr.sort(),map.keys(). - Strings, not symbols:
status: "done".
Limits
Sandboxed: no files, network or imports. Each request gets about 2 million operations and 5 seconds; scripts up to 256 KB; request bodies up to 1 MB; up to 1000 rows per query, 50 models, 100 fields per model and 200 routes.
Recipes
A detail page with related records
model Book #{ title: "string!", author: "string" };
model Review #{ book: "Book!", rating: "int! min=1 max=5", notes: "text" };
get("/books/:id", |id| {
let book = Book.find(id);
if book == () { return status(404, "No such book"); }
page(book.title, [
heading(book.title, 1),
muted(`by ${book.author ?? "unknown"}`),
section("Reviews", table(Review, #{ book: id, columns: ["rating", "notes"] })),
form(Review, #{ fields: ["rating", "notes"], values: #{ book: id }, title: "Add a review" }),
])
});
A dashboard from computed numbers
model Order #{ total: 0.0, status: ["open", "paid", "refunded"] };
get("/", || {
let paid = Order.all(#{ status: "paid", limit: 1000 });
let revenue = 0.0;
for o in paid { revenue += o.total; }
[
hero("Sales", "Today at a glance"),
stats([
stat("Paid orders", paid.len()),
stat("Revenue", `$${revenue}`),
stat("Open", Order.count(#{ status: "open" })),
]),
table(Order, #{ columns: ["total", "status", "created_at"], limit: 10 }),
]
});
Search with a query parameter
model Contact #{ name: "string!", email: "string" };
get("/", |query| {
let term = query.q ?? "";
let rows = if term == "" { Contact.all() } else { Contact.all(#{ name: #{ ilike: `%${term}%` } }) };
[heading("Contacts"), table(rows, #{ columns: ["name", "email"], empty: "No matches." })]
});
A JSON endpoint
model Task #{ title: "string!", done: false };
get("/stats.json", || #{ total: Task.count(), open: Task.count(#{ done: false }) });
post("/tasks", |body| Task.create(body)); // 200 with the record, or 422 with field errors
A complete app
app(#{
title: "Reading list",
nav: [["Books", "/"], ["Reviews", "/reviews"]],
});
model Book #{
title: "string! max=200",
author: "string! max=120",
status: ["to read", "reading", "done"],
pages: "int min=1",
};
model Review #{
book: "Book!",
rating: "int! min=1 max=5",
notes: "text",
};
expose(Book);
before_save(Book, |b| { b.title = b.title.trim(); });
get("/", || [
hero("Reading list", "What I'm reading, what's next, and what I thought."),
stats([
stat("Reading", Book.count(#{ status: "reading" })),
stat("To read", Book.count(#{ status: "to read" })),
stat("Finished", Book.count(#{ status: "done" })),
]),
section("Currently reading", table(Book, #{
status: "reading",
columns: ["title", "author", "pages"],
link: "/books/:id",
actions: [["Finished", "/books/:id/done"]],
empty: "Nothing on the nightstand.",
})),
grid([
form(Book, #{ fields: ["title", "author", "status", "pages"], title: "Add a book" }),
card("Up next", list(Book.all(#{ status: "to read", order: "title" }), |b| `${b.title} — ${b.author}`)),
]),
]);
post("/books/:id/done", |id| {
Book.update(id, #{ status: "done" });
redirect("/")
});
get("/reviews", || page("Reviews", [
table(Review, #{ columns: ["book", "rating", "notes", "created_at"], actions: ["delete"] }),
]));
Working on an app
- Write or edit
app.rhai. dbb1 apps deploy --dry-run— fix anything it reports (each error has a line number; unknown functions come with a "did you mean").dbb1 apps deploy -m "what changed"— prints the URL and the database changes.- Open the pages (or
curlthem) to check they render;dbb1 apps logsshowsprintoutput and errors.
Other commands: dbb1 apps init (starter app), dbb1 apps pull <name> (download an app made
on the web), dbb1 apps list, dbb1 apps open [--admin], dbb1 apps destroy. On the web, each
app has Data, Schema, Versions (with what changed in each), Code, Build with AI and Logs pages.
This page is also a Claude skill. Install it and ask Claude Code to build you an app.
dbb1 skill install # → ~/.claude/skills/dbb1-apps/SKILL.md # or: mkdir -p ~/.claude/skills/dbb1-apps && curl -fsSL https://apps.dbb1.dev/skill.md -o ~/.claude/skills/dbb1-apps/SKILL.md