# Sluurp
> Sluurp is a single executable that serves a backend and its app: SQLite collections with rules enforced in SQL, REST, realtime and sync APIs, auth, an admin UI at `/_/`, server-rendered pages with islands, and a UI kit. No build step, no `npm install`.
Each page below links to its Markdown source. `/llms-full.txt` holds every page's text in one file.
## How to work with Sluurp
- **An app is a folder**, served with `sluurp serve --public ./my-app`. File location decides behaviour: `routes/` for server-rendered pages, `islands/` for components hydrated in the browser, and `functions/`, `hooks/`, `jobs/`, `agents/` and `views/` for server code. The data model lives in `schema.json`. No config file.
- **No build.** TypeScript and JSX are compiled on the fly. Imports use bare names from the import map Sluurp injects into every page: `sluurp` (client), `sluurp/reactive` (signals), `sluurp/ui` (JSX runtime), `sluurp/kit` (shadcn-style components), `sluurp/sync`, `sluurp/pages`, and more. Add npm packages with `sluurp add `, which vendors their source into `vendor/`. `tsconfig.json` is generated.
- **Styling is Tailwind** in markup; the stylesheet is generated from used classes. Theme colours are CSS variables, as in shadcn.
- **Data** goes through the `sluurp` client: `sluurp.collection("todos").list({ filter, sort })`, `.create()`, `.update()`, `.delete()`. Use a Sync shape (`sluurp/sync`) for lists that stay current. Every call is checked against the collection's rules, compiled into SQL. Server code uses the same API via `server`, acting as the caller.
- **Server code** runs in a QuickJS sandbox: no file system, no `process`, network only via `fetch` to public addresses, with a memory limit and timeout per call.
## How to write a website with Sluurp
- **Markdown pages** are `.md` files under `routes/`; `routes/docs/sync.md` is `/docs/sync`. Frontmatter sets `title`, `description`, and optionally `section` and `order` for a sidebar. Code fences accept `title="file.ts"` and are highlighted server-side.
- **TSX pages** are `routes/*.tsx`; the default export is rendered on the server. Load data with `export async function load()` and embed islands.
- **Layouts** are `_layout.js` files: `render(state, html)` wraps every page in that folder and below. `state.pages` lists sibling Markdown pages with frontmatter, for building a sidebar.
- **Islands** are `islands/.tsx`, embedded with ``. Only islands ship JavaScript.
- **Publishing:** `sluurp static --public ./site --out dist` exports static files for any host, islands included. Or run `sluurp serve` behind a TLS proxy.
- **Markdown sources** are served at each page's URL plus `.md`, and `/llms.txt` is generated from them, like this file.
## Start
# Getting started
Sluurp is a single executable. It stores data in a folder of SQLite files and serves an API, an admin UI and your app. Nothing else to install.
Sluurp is free for non-commercial use: personal projects, study, schools, clubs and charities. Using it in a business needs a [business licence](/business), one per server.
## Install
On macOS and Linux:
```sh title="Terminal"
curl -fsSL https://raw.githubusercontent.com/SluurpHQ/releases/main/install.sh | sh
```
On Windows, in PowerShell:
```sh title="PowerShell"
irm https://raw.githubusercontent.com/SluurpHQ/releases/main/install.ps1 | iex
```
Both install the `sluurp` binary to `~/.sluurp/bin`. Set `SLUURP_VERSION=v0.2.0` to pick a release, or `SLUURP_INSTALL` for another folder.
## Run it
```sh title="Terminal"
sluurp superuser you@example.com a-long-password
sluurp serve --public ./my-app
```
| Path | What |
|---|---|
| `/` | your app (the `--public` folder) |
| `/api/` | the REST, realtime and sync API |
| `/_/` | the admin UI: collections, records, rules, logs, SQL, backups, jobs |
| `/sluurp.js` | the browser client, imported as `"sluurp"` |
## From a git repository
`--public` also accepts a repository URL. It's shallow-cloned into the current folder and served from there; on the next run the clone is pulled first.
```sh title="Terminal"
sluurp serve --public https://github.com/you/your-app
```
Append a subfolder (`…/repo/reports`) or a branch or tag (`…/repo@v2`). Without a subfolder, the repository root is the app, or its `app/` folder if the root isn't one. Your own `git` does the fetching, so private repositories work wherever `git clone` does. Local changes in the clone are never overwritten.
## A first collection
Create one in the admin UI, or define it in `schema.json`:
```json title="schema.json"
{
"collections": [{
"name": "todos",
"schema": [
{ "name": "title", "type": "text", "required": true },
{ "name": "done", "type": "bool" },
{ "name": "author", "type": "relation", "relation": "users" }
],
"rules": {
"list": "author = @request.auth.id",
"create": "author = @request.auth.id",
"update": "author = @request.auth.id"
}
}]
}
```
`sluurp serve --public ./my-app` applies the app's `schema.json` (inside the folder or next to it) on startup: new collections and fields are added, nothing is dropped. `sluurp schema apply schema.json` does the same manually; add `--drop` to also remove what's no longer in the file.
## A first page
No bundler, no `npm install`. The page imports `sluurp` through the import map Sluurp injects.
```html title="my-app/index.html"
```
Next: a server-rendered page in [routes/](/docs/server-components), a function the browser can call with ["use server"](/docs/server-functions), or live data with [sync](/docs/sync).
## Ship it
`sluurp compile` builds a single binary containing the server and your app. `sluurp push` and `deploy` store an app as an immutable bundle and point a channel at it; `rollback` reverts a channel to the previous version.
# An app's shape
An app is a folder. Where a file lives decides what it does, so there's no config to write.
```text title="my-app/"
index.html # static page, served as is
pages/ app.tsx # browser modules, TypeScript compiled on the fly
routes/ # server-rendered pages: docs/sync.md is /docs/sync
islands/ # interactive components inside server pages
functions/ # /api/fn/, run in the sandbox
hooks/ # .ts: beforeCreate, beforeUpdate…
jobs/ # scheduled: schedule = "0 7 * * *"
agents/ # react to changes in a collection
views/ # live views, state on the server
i18n.json # translations
sluurp-deps.json # packages added with sluurp add, with hashes
vendor/ # their sources
schema.json # collections, fields and rules
```
## Where server code runs
Functions, hooks, jobs, agents, views and server pages run in a QuickJS sandbox. It has no file system, no network and no `process`, and each call has a memory limit and a timeout. Server code accesses data only through `server`, the same collections API the browser uses, acting as the caller, so every rule still applies.
Server files can import the app's own modules by relative path (`../lib/marks.ts`, `./room`, a folder's `index.ts`) and JSON files. Sluurp bundles them into the single module the sandbox runs; imports don't grant any extra access.
## Several apps, one server
```sh title="Terminal"
sluurp serve --public ./school --public reports=./reports --public wiki=./wiki
```
The first app is served at `/`, each named one at its own path (`/reports/`, `/wiki/`) with its own functions and jobs.
## The UI kit
`sluurp/kit` is a shadcn-style component library: Button, Card, Dialog, Select, Tabs, Table, Command, Sidebar and more, built on `sluurp/ui`, a small signals + JSX runtime. Tailwind works without a build step: the stylesheet is generated from the classes the app uses. See [UI kit](/docs/ui-kit) for the full list and a live demo.
# Why SQLite
Sluurp's pitch is one binary, one data file, nothing else to run. That requires an embedded database: a backend that starts with "first, set up Postgres" has already broken that promise.
## What you get
- **No network hop.** A query is an in-process function call, not a round trip. For the small indexed reads most pages do, that removes most of the cost.
- **Nothing to operate.** No database server to install, upgrade, tune, secure or get paged about. `sluurp serve` is the whole deployment.
- **A project is a file.** Each project is a separate SQLite file. Multi-tenancy is a folder of files, a backup is a file copy (see Backups in the admin UI), and moving a tenant is moving a file.
- **Concurrent reads.** In WAL mode, readers don't block each other or the writer. The connection pool is sized to the CPU count, so reads scale with cores.
- **Same engine in the browser.** [Sync](/docs/sync) can store rows in browser SQLite (the official WebAssembly build), so SQL works the same on both sides, `FOR SYSTEM_TIME AS OF` included.
- **Collections are real tables,** with real indexes, unique constraints and SQLite's query planner, not rows in a generic key-value blob. Rules and filters compile to that SQL with bound parameters.
## The tradeoff
**One writer at a time.** SQLite serialises writes: reads scale with cores, writes don't. A Sluurp write is a short transaction (the row, its history if enabled, the rule re-check), so the queue moves fast. For most apps (a school, a shop, an internal tool, a SaaS with one file per tenant) that's far more headroom than needed. If you need tens of thousands of writes per second into one table, use something else.
## Why not something else
| | Why not |
|---|---|
| **Postgres** | A separate server, which is exactly what Sluurp exists to eliminate. It remains the escape hatch for installs that outgrow a single file: storage is behind a trait, so Postgres support is a new driver, not a rewrite. |
| **CockroachDB** | Written in Go, with no embeddable library. Since v24.3 the self-hosted Core edition is gone, and the source-available licence restricts redistribution. |
| **pgrust** | Postgres rewritten in Rust, moving fast. But it's AGPL-3.0, warns against storing important data, and the embeddable build is still on the roadmap. |
| **Turso** | The Rust rewrite of SQLite: MIT, in-process, file-format compatible, with async I/O and concurrent writes. The likely future engine; switching would be a driver change, not a migration, which is why storage sits behind a trait. |
## How fast
`sluurp bench` benchmarks collection access through Sluurp's storage layer, exactly as a request handler calls it: rule compilation, filter parsing and binding, SQLite, and JSON serialisation. It uses a throwaway data directory and reports throughput plus median and p99 latency per operation.
```sh title="Terminal"
sluurp bench # table and score
sluurp bench --json # machine-readable
```
On AMD Ryzen 7 9800X3D 8-Core Processor, 16 threads, Windows, 10,000 rows (Sluurp 0.1.0):
| Operation | Callers at once | Per second | Median | p99 |
|---|---|---|---|---|
| Create, one caller | one | 6,308 | 0.124 ms | 0.336 ms |
| Create | 32 | 3,939 | 4.656 ms | 60.301 ms |
| Read one row by id, one caller | one | 21,728 | 0.04 ms | 0.098 ms |
| List 20, filtered on an index, one caller | one | 7,975 | 0.116 ms | 0.199 ms |
| Read one row by id | 32 | 25,847 | 0.66 ms | 14.915 ms |
| List 20, filtered on an index | 32 | 23,222 | 1.354 ms | 3.417 ms |
| Count by group (10000 rows) | 32 | 24,041 | 1.084 ms | 5.323 ms |
| List 20 under a row rule | 32 | 41,618 | 0.681 ms | 2.423 ms |
| Update one field | 32 | 7,721 | 2.186 ms | 41.342 ms |
| Create with history kept | 32 | 2,946 | 6.399 ms | 72.496 ms |
| List 20 as it stood (asOf) | 32 | 6,513 | 4.044 ms | 29.912 ms |
| Delete | 32 | 1,912 | 10.319 ms | 83.838 ms |
**Score: 2,048.** The geometric mean of each operation's speed relative to a reference run, × 1,000. Every operation weighs equally regardless of scale; a machine twice as fast at everything scores 2,000. Run-to-run noise is a few percent. This machine scores about 2× the reference because the reference predates the `asOf` speed-up and the write-lock retry fix.
### Against bare SQLite
The same operations directly on SQLite (rusqlite, same settings, one caller, prepared statements), with no rules, filter parsing, pool or JSON. This is Sluurp's overhead over the raw engine:
| Operation, one caller | Per second | Median | p99 |
|---|---|---|---|
| Create, one at a time | 11,404 | 0.065 ms | 0.161 ms |
| Read one row by id, one caller | 214,210 | 0.005 ms | 0.005 ms |
| List 20, filtered on an index, one caller | 47,475 | 0.021 ms | 0.03 ms |
| Count by group (10000 rows), one caller | 4,651 | 0.21 ms | 0.348 ms |
Reading a row by id takes ~5 µs in bare SQLite and ~45 µs through Sluurp: the difference is the compiled view rule, dispatch to a pooled connection, and JSON serialisation. A create takes ~0.07 ms bare and ~0.11 ms through Sluurp, which also re-checks the create rule inside the transaction.
Takeaways:
- **Reads scale.** Get-by-id, an indexed filtered page, a page under a row rule and an aggregate over 10,000 rows each take about a millisecond, at tens of thousands per second across cores.
- **Writes queue.** One caller creates a row in ~0.1 ms. Concurrent writers share SQLite's single writer, so total throughput stays about the same and each waits its turn.
- **Time travel is fast.** An `asOf` list is rebuilt by SQLite in one statement from the change log and cached on the connection until something at or before that point changes. It used to take about a second on this data.
- **Deletes keep up.** A delete also writes to the recycle bin and still runs about as fast as a create with history. Waiting writers used to sleep up to 100 ms between retries while the lock sat mostly free; they now retry within a fraction of a millisecond, which made 32 concurrent deletes 2.5× faster (from 778/s) and updates 2× faster.
## Beyond one machine
Several Sluurp nodes can serve one data directory behind a load balancer. Each node gets `--advertise` and `--node-id`, and the balancer pins each tab to a node with a sticky cookie. All nodes need the same `--dir` on shared storage, which is where the real constraints are; see "Sharing the data directory" in the README.
Since projects are separate files, spreading them across machines later is a routing problem (move a file, update a map), not a distributed-join problem.
## Data
# Collections and rules
A collection is a real SQLite table. Its rules are compiled into the same query that fetches its rows, not checked afterwards.
## Fields
The field types are `text`, `number`, `bool`, `email`, `url`, `date`, `json`, `select`, `relation` and `file`. A collection may declare composite and unique indexes. A collection with `"extends": "_users"` holds app-specific profile fields, keyed by the same id as the user.
## Five rules
```json title="schema.json"
"rules": {
"list": "@request.auth.id != null",
"view": "@request.auth.id != null",
"create": "author = @request.auth.id",
"update": "author = @request.auth.id",
"delete": "author = @request.auth.id || @request.auth.staff = true"
}
```
- No rule means superusers only; `""` means anyone.
- A list rule filters: callers get only the rows they may see, and totals stay correct.
- View, update and delete return 404 rather than 403, so rules can't be used to probe whether a row exists.
- After a write, the row is re-checked inside the transaction. A write that would put a row out of the writer's own reach is rolled back.
Fields can have their own rules too, e.g. who may read a grade or change a status. They're enforced in the same SQL.
## The API
```http title="HTTP"
GET /api/collections/todos/records?filter=done=false&sort=-created
POST /api/collections/todos/records
PATCH /api/collections/todos/records/:id
DELETE /api/collections/todos/records/:id
// server-sent events, filtered by your rules
GET /api/realtime?subscribe=todos
```
```ts title="Browser"
import { Sluurp } from "sluurp";
const sluurp = new Sluurp();
await sluurp.collection("todos").create({ title: "Milk", author: sluurp.auth.id });
const open = await sluurp.collection("todos").list({ filter: "done = false" });
```
Filters are parsed and compiled to SQL with bound parameters. A filter referencing an unknown field is rejected before any SQL is built.
## Views
A view collection's rows come from a SQL `SELECT`: a report that joins or groups other collections, read like any collection. You can list, filter, sort, `expand` and aggregate it, and its own list and view rules decide who sees what. It's read-only: creating, updating or deleting its records is refused.
```json title="schema.json"
{
"name": "absence_counts",
"type": "view",
"query": "SELECT user AS id, count(*) AS absences FROM absences GROUP BY user",
"rules": { "list": "@request.auth.staff = true", "view": "@request.auth.staff = true" }
}
```
- Write collection names in the query; Sluurp maps them to their tables.
- The query must be one `SELECT` that only reads, and each row needs an `id`.
- Fields come from the columns the query returns: numbers where the values are numbers, text otherwise. Declare a field in `schema` to give it a type of your own, such as a relation, so `expand` works through it.
- The query reads the underlying tables directly, not through their rules. The view's own rules are what apply, so write them for whoever the view is meant for.
- A collection a view reads can't be deleted while the view exists. To change a view's query, delete the view and make it again.
- A view isn't kept current by sync: a synced list of it is its rows as they were when read.
## Projects
Each project is a separate database file, and no query spans two. An app is pinned to one project, so its functions can't touch another app's data.
# Browser client
Every Sluurp server serves its client, already in each app's import map: import { Sluurp } from "sluurp". Nothing to install. It wraps the REST API and manages auth.
```ts title="app.ts"
import { Sluurp } from "sluurp";
const sluurp = new Sluurp();
const todos = sluurp.collection("todos");
const { items } = await todos.list({ filter: "done = false", sort: "-created" });
const todo = await todos.create({ title: "Milk" });
await todos.update(todo.id, { done: true }, { reason: "bought" });
```
`new Sluurp()` talks to the page's own server. Pass an origin, `new Sluurp("https://school.example.com")`, for another, and `{ project }` for a non-default project.
## Collections
`sluurp.collection(name)` has:
| | |
|---|---|
| `list({ page, perPage, sort, filter, asOf })` | One page: `{ items, page, perPage, totalItems, totalPages }` |
| `listAll(options)` | All pages as one array (served from [sync](/docs/sync#every-list-kept-current) after the first call) |
| `getOne(id)`, `getFirst(filter)` | A single record |
| `create(data)`, `update(id, data)`, `delete(id)` | Writes. Each accepts `{ reason }`, stored with the change |
| `history(id)`, `version(id, seq)`, `restore(id, seq)` | Past versions, if history is on |
| `changes(since)` | All changes after a sequence number, in order |
## Files
| | |
|---|---|
| `upload(id, field, file)` | Upload a `File` or `Blob` to a field |
| `createWithFile(data, field, file)` | Create the record and upload in one step |
| `fileUrl(id, field, { w, h, fit, format })` | A URL for an ``, resized |
| `srcset(id, field, [400, 800, 1200])` | A `srcset` at those widths |
| `fileObjectUrl(id, field)`, `fileText(id, field)` | Fetch a private file with auth |
## Signing in
On the auth collection, usually `users`:
```ts title="app.ts"
const users = sluurp.collection("users");
await users.authWithPassword(email, password);
sluurp.authStore.isValid; // signed in?
sluurp.authStore.record; // the user
sluurp.logout();
```
Also: `signUp`, `requestPasswordReset`, `requestSigninLink` (magic link), `verifyTwoFactor`, `authRefresh`, and `oauthUrl("google")` with `captureOAuthToken(sluurp)` on the return page. The session is stored in `localStorage` and shared across the site's pages. `sluurp.onAuthFailure` fires when the server rejects the token.
## Permissions
```ts title="app.ts"
const may = await sluurp.permissions();
may.can("grades", "update"); // for some rows at least
may.certainly("grades", "delete"); // for every row
```
Evaluates the collections' [rules](/docs/rules) up front, so you can show only buttons that will work.
## Errors
Failed calls throw a `SluurpError` with `status`, `message` (from the server), `body`, and the helpers `isAuthError` (401) and `isForbidden` (403).
## Live
`sluurp.socket({ subscribe: ["messages"] })` opens a WebSocket that streams every change to those collections, filtered by your rules. `on(type, listener)` subscribes; `join(topic)` / `leave(topic)` handle presence; `emit(topic, event, data)` broadcasts without storing. For lists that stay current automatically, use [Sync](/docs/sync).
## Instant first render
`kept` renders immediately from the last response cached in this browser, then updates from the server's response if it differs (stale-while-revalidate):
```js title="app.js"
sluurp.kept("feeds", () => sluurp.social.feeds(), (r) => feeds.set(r.items));
```
A reload then shows the complete screen in the first frame instead of filling in piece by piece. The cache is per user, per browser, and cleared on `logout()`.
## And the rest
`sluurp.conversation(id)` and `sluurp.conversations` for chat, `sluurp.pages` for [Pages](/docs/pages), `sluurp.payments` and `sluurp.billing` for [Payments](/docs/payments), `sluurp.ai` for [AI](/docs/ai), `sluurp.social` for feeds. `sluurp.send(path, { method, body, query })` calls any endpoint with auth and app headers attached.
# Rules
A rule is a filter expression that can reference the caller. It's compiled into the SQL that reads or writes the rows, so rows a user can't see never leave SQLite.
## Where rules go
Each collection has five rules, one per action. A missing rule means superusers only; `""` means anyone.
```json title="migrations/V1__init.json"
"rules": {
"list": "@request.auth.id != null",
"view": "@request.auth.id != null",
"create": "author = @request.auth.id",
"update": "author = @request.auth.id",
"delete": "author = @request.auth.id || @request.auth.staff = true"
}
```
Fields can have two rules of their own: `visible` (who can read it) and `writable` (who can set it). Both default to the collection's rules.
```json title="migrations/V1__init.json"
{ "name": "mark", "type": "number",
"visible": "@request.auth.id = pupil || @request.auth.staff = true",
"writable": "@request.auth.roles ~ \"examiner\"" }
```
## The language
Same syntax as [filters](/docs/api#filters):
| | |
|---|---|
| Compare | `=` `!=` `>` `>=` `<` `<=` |
| Contains / doesn't contain | `~` `!~` |
| Combine | `&&` `\|\|` and parentheses |
| Values | `"text"`, numbers, `true`, `false`, `null` |
| The record | field names: `author`, `org`, `status` |
| The caller | `@request.auth.…` |
| Time | `@now`, `@days_ago.30`, `@years_ago.13` |
## The caller
- `@request.auth.id`, `collection`, `email` and `superuser` come from the token and are free.
- Any other field of the caller's record works too: `@request.auth.staff`, `@request.auth.org`. The record is loaded by id, in the same transaction, only when a rule uses it.
- `@request.auth.roles` is a set: `@request.auth.roles ~ "admin"` tests membership, so it never matches "superadmin".
- For anonymous callers all of these are `null`, so a members-only rule just evaluates to false, never an error.
```text title="rule"
@request.auth.staff = true && org = @request.auth.org && id != @request.auth.id
```
Staff can delete users in their own organisation, but not themselves.
## How they are enforced
- **Lists filter.** Callers get only rows they may see, with correct totals.
- **View, update and delete return 404** when denied, so rules can't be used to probe whether a row exists.
- **Writes are checked twice:** before the change and again inside the transaction after it. A change that would put the row out of the writer's own reach is rolled back.
- **Caller-only conditions are resolved before the query.** `@request.auth.roles ~ "admin"` becomes a constant; conditions on the record go into the `WHERE` clause.
- **Field rules** work the same way: a field the caller can never see isn't selected; one that depends on the row is nulled per row, in SQL.
## Checking permissions up front
`GET /api/acl` returns, for the signed-in user, what each collection allows: `allow`, `deny`, or `conditional` (only for some rows, e.g. their own). Use it to show only buttons that will work. It evaluates the same rules, so it can't disagree with enforcement.
# REST API
Every collection gets a REST API as soon as it exists. The browser client (sluurp.js) is a thin wrapper, so anything it does can be done with plain HTTP.
Authenticate with `Authorization: Bearer ` (from sign-in); without it, requests are anonymous. Either way, the collection's [rules](/docs/rules) decide what's allowed.
## Records
| Method and path | What it does |
|---|---|
| `GET /api/collections/{name}/records` | List records (paged) |
| `POST /api/collections/{name}/records` | Create; returns `201` and the record |
| `GET /api/collections/{name}/records/{id}` | Get one record |
| `PATCH /api/collections/{name}/records/{id}` | Update only the given fields |
| `DELETE /api/collections/{name}/records/{id}` | Delete (to the recycle bin); returns `{ "bin": … }` |
| `GET /api/collections/{name}/aggregate` | Count, sum, avg, min or max, grouped |
List query parameters:
| Parameter | |
|---|---|
| `filter` | e.g. `status = "open" && priority > 2` (see below) |
| `sort` | Comma-separated fields, `-` for descending: `-created,title` |
| `page`, `perPage` | Paging |
| `search` | Full-text search in searchable fields |
| `asOf` | Read the collection as of a date, if it has [history](/docs/history): `2026-06-30` |
| `near`, `on` | [Semantic search](/docs/search): most similar first; `on` picks the fields |
| `expand` | Related records embedded: `expand=pupil,pupil.class` (see below) |
```http title="HTTP"
GET /api/collections/grades/records?filter=value%20%3E%3D%208&sort=-date&perPage=20
Authorization: Bearer eyJ…
```
The response contains `items`, `page`, `perPage`, `totalItems` and `totalPages`.
## Filters
Filters are a small expression language compiled to SQL; they never reach the database as raw text. Field names are validated against the collection, and values are always bound parameters.
| | |
|---|---|
| Compare | `=` `!=` `>` `>=` `<` `<=` |
| Contains / doesn't contain | `~` `!~`: `title ~ "trip"` |
| Starts / ends with | `^=` `$=`: `title ^= "Re:"`, `file $= ".pdf"` |
| In a list / not in it | `status in ("open", "waiting")`, `status not in ("done")` |
| Any of a list field's values | `?=` `?!=` `?~`: `tags ?= "urgent"` (a JSON array; a plain value counts as a list of one) |
| Combine | `&&` `\|\|`, parentheses, and `!(…)` to negate a group |
| Values | `"text"` or `'text'`, numbers, `true`, `false`, `null` |
```text title="filter"
status in ("open", "waiting") && priority >= 2 && !(title ~ "test" || tags ?= "draft")
```
### Through relations
A dot follows a relation field to a field of the record it points at, up to four relations deep:
```text title="filter"
pupil.class.name = "4A" && pupil.first_name ^= "Li"
```
It compiles to a subquery, not one query per row. Each related collection's list rule applies as it would to the caller listing it directly: a path through records they can't list finds nothing, and a path can't be used to probe fields they can't see. Paths work in `filter` for lists, `/aggregate` and `deleteMany`.
## OpenAPI
`GET /api/openapi.json` describes the API as it is now, as an OpenAPI 3.1 document:
- every collection's endpoints, with its fields as a schema and its rules written out;
- signing in;
- the app's own functions, with their method and rule.
It's generated from the live schema, so it can't drift. It's for superusers, since it describes every collection. Point a client generator at it for a typed client in any language; see [OpenAPI and client generators](/docs/openapi).
The admin UI's **API** screen reads it. Pick an endpoint, fill in its parameters and body, and send it as yourself: you see the status, the time and the response, and can copy the request as `curl`.
## Related records
`expand` embeds the records a relation field points at, under `expand`, next to the ids. Dotted paths go further, up to four relations deep:
```http title="HTTP"
GET /api/collections/grades/records?expand=pupil,pupil.class
```
```json
{ "id": "…", "value": 9, "pupil": "u1",
"expand": { "pupil": { "id": "u1", "name": "Lisa", "class": "c4a",
"expand": { "class": { "id": "c4a", "name": "4A" } } } } }
```
It costs one query per relation, not one per row. Related records go through their own collection's list rule: one the caller couldn't list directly is left out, and only its id is there. `expand` works on a single record too (`/records/{id}?expand=pupil`), and in the SDK: `list({ expand })`, `getOne(id, { expand })`, `listAll({ expand })`.
In a [synced list](/docs/sync), expanded rows stay current: when a related record changes, the rows showing it are sent again.
## Search by meaning
`near=museum trips` returns the most similar rows first, each with a `_score` (1 = identical). It compares searchable fields, or those in `on=title,body`. Rules and `filter` apply as usual. See [Search by meaning](/docs/search) for details and embeddings setup.
```js title="client"
const { items } = await sluurp.collection("notes").list({ near: "museum trips" });
```
## Counting and summing
`GET /api/collections/{name}/aggregate` takes `op` (`count`, `sum`, `avg`, `min`, `max`), `field` (except for `count`), `group` (one or more fields), plus `filter` and `asOf` as for lists:
```http title="HTTP"
GET /api/collections/grades/aggregate?op=avg&field=value&group=subject
```
## History and the recycle bin
For collections with history enabled:
| Method and path | |
|---|---|
| `GET …/records/{id}/history` | All versions of a record, newest first, with who and why |
| `POST …/records/{id}/history/{seq}/restore` | Restore a version |
| `GET /api/collections/{name}/changes?since=N` | All changes after sequence `N`, in order |
Pass a reason for any write in the `X-Sluurp-Reason` header; it's stored with the version. Deleted records go to the recycle bin; `POST /api/bin/{bin}/restore` restores them along with anything the delete cascaded to.
## Files
| Method and path | |
|---|---|
| `GET /api/files/{collection}/{id}/{field}` | Download. For images, `?w=`, `?h=`, `?fit=`, `?format=` and `?q=` resize and convert |
| `POST /api/files/{collection}/{id}/{field}` | Upload, multipart with a part named `file` |
| `DELETE /api/files/{collection}/{id}/{field}` | Delete |
## Signing in
| Method and path | |
|---|---|
| `POST /api/collections/{name}/auth-with-password` | `{ "identity", "password" }` → token and record |
| `POST /api/collections/{name}/auth-refresh` | Exchange a token for a fresh one |
| `POST /api/collections/{name}/signup` | Create an account, if sign-up is allowed |
| `POST /api/collections/{name}/request-password-reset` | Email a password reset link |
## Errors
Errors are a status and a message: `{ "status": 404, "message": "record grades/x not found" }`. `400` bad request, `401` not signed in, `403` denied by a rule, `404` not found (or not visible to you), `409` conflict, `429` rate limited (retry shortly).
## And more
Transactions across writes: [`POST /api/batch`](/docs/batch). Server functions: `GET` or `POST /api/fn/{name}` ([Server functions](/docs/server-functions)). Live data over WebSocket: [Sync](/docs/sync). External events: [`POST /api/events/{name}`](/docs/hooks-and-events).
# OpenAPI and client generators
Every Sluurp app describes its own REST API as an OpenAPI 3.1 document. Any tool that reads OpenAPI can use it to make a typed client in your language, show interactive docs, or test the API.
## The document
`GET /api/openapi.json` is made from the app as it is right now:
- every collection's endpoints, with its fields as a schema and its rules written out;
- signing in, signing up and refreshing a token;
- the app's own [functions](/docs/server-functions), with their method and rule.
Because it's made on each request, it can't drift from the app. Add a field, and the document has it at once.
It describes every collection, so only a superuser may read it. Sign in as one, and pass the token:
```sh title="Terminal"
TOKEN=$(curl -s http://localhost:8090/api/collections/_superusers/auth-with-password \
-H 'Content-Type: application/json' \
-d '{"identity":"you@example.com","password":"…"}' | jq -r .token)
curl -s http://localhost:8090/api/openapi.json -H "Authorization: Bearer $TOKEN" > openapi.json
```
Or open the admin's **API** screen and download `openapi.json` from there. The same screen lets you try any endpoint as yourself, and copy the request as `curl`.
Save the file in your project and generate from it. Fetch it again when the schema changes, since generated code only knows what was there when you made it.
## Typed clients
### TypeScript
For a web app on Sluurp you rarely need one, since the [client](/docs/client) knows your collections already. For another codebase, [openapi-typescript](https://openapi-ts.dev) turns the document into types, and `openapi-fetch` calls the API with them:
```sh title="Terminal"
npx openapi-typescript openapi.json -o src/sluurp-api.d.ts
```
```ts title="src/api.ts"
import createClient from "openapi-fetch";
import type { paths } from "./sluurp-api";
const api = createClient({ baseUrl: "https://example.com", headers: { Authorization: `Bearer ${token}` } });
const { data } = await api.GET("/api/collections/tasks/records", { params: { query: { filter: "done = false" } } });
```
[Orval](https://orval.dev) and [Hey API](https://heyapi.dev) make clients too, including hooks for TanStack Query.
### Other languages
[OpenAPI Generator](https://openapi-generator.tech) makes clients for more than 50 languages from the same file:
```sh title="Terminal"
npx @openapitools/openapi-generator-cli generate -i openapi.json -g python -o clients/python
```
Change `-g` for other languages: `go`, `java`, `kotlin`, `swift5`, `csharp`, `dart`, `php`, `ruby` or `rust`. Tools made for one language are often nicer to use:
| Language | Tool |
|---|---|
| Python | [openapi-python-client](https://github.com/openapi-generators/openapi-python-client) |
| Go | [oapi-codegen](https://github.com/oapi-codegen/oapi-codegen) |
| C# and .NET | [NSwag](https://github.com/RicoSuter/NSwag), or Kiota |
| Swift | [Swift OpenAPI Generator](https://github.com/apple/swift-openapi-generator) |
| Kotlin and Java | OpenAPI Generator's `kotlin` and `java` |
| Many | [Kiota](https://learn.microsoft.com/openapi/kiota/), from Microsoft |
## Docs to browse
Any OpenAPI viewer shows the file as browsable docs, with a form to try each endpoint: [Swagger UI](https://swagger.io/tools/swagger-ui/), [Redoc](https://redocly.com/redoc) or [Scalar](https://scalar.com). To look at it without installing anything, paste the file into [editor.swagger.io](https://editor.swagger.io).
The file describes your whole schema and its rules. Share it with people who build against your API, but don't publish it where anyone can read it.
## Tools that import it
- **Postman, Insomnia, Bruno and Hoppscotch** import the file as a collection of requests.
- **Schemathesis** reads it to test every endpoint with generated input, looking for crashes and answers that don't match the schema.
- **AI assistants and agents** can be given the file, so they know every endpoint and field of your app.
## Related
- [The REST API](/docs/api): every endpoint, filters, files and errors.
- [Browser client](/docs/client): the typed JavaScript client for apps on Sluurp.
# Sign-in and users
Users are a regular collection with auth built in. No separate auth service: accounts, sessions and permissions live in the same binary as the data they protect.
## Signing in
```ts title="app.ts"
const users = sluurp.collection("users");
await users.authWithPassword(email, password);
sluurp.authStore.record; // the signed-in user
sluurp.logout();
```
Sign-in returns a token valid for 14 days; `authRefresh()` exchanges it for a fresh one. Passwords need at least 8 characters and are stored as Argon2 hashes.
| | |
|---|---|
| **Magic link** | `requestSigninLink(email)`: passwordless. Single-use, and expires |
| **Password reset** | `requestPasswordReset(email)` emails a link; `confirmPasswordReset(token, password)` sets the new password |
| **OAuth** | Google, GitHub, Microsoft, GitLab, or any OpenID Connect provider, configured under **Sign-in** in the admin UI. Redirect with `location.href = users.oauthUrl("google")` and call `captureOAuthToken(sluurp)` on the return page |
| **Two-factor** | A 6-digit TOTP code after the first factor. Five wrong attempts end the attempt |
There are no recovery codes: a user who loses their phone asks an admin, who disables 2FA on their record and signs them out everywhere.
## Who may join
An app-level setting, enforced by the server:
- `invite` (default): invitation only.
- `open`: anyone can sign up, subject to the collection's create rule.
- `closed`: no sign-ups, for accounts synced from a directory.
An invitation has two parts. The inviter sets fields like organisation and role, which the invitee can't change. The invitee fills in the rest: name, password.
```ts title="app.ts"
await users.invite("parent@example.com", {
fields: { org: school.id, roles: ["parent"] },
collect: ["first_name", "last_name"],
});
```
## Roles
A user's `roles` is a list that [rules](/docs/rules) can check: `@request.auth.roles ~ "teacher"`. Only whole roles match, so "admin" never matches "superadmin".
## For administrators
- **Impersonate** a user from their record in the admin UI to see the app as they do.
- **Sign out everywhere**: revoking a user's tokens ends all their sessions immediately.
- **Superusers** are separate from app users: create one with `sluurp superuser EMAIL PASSWORD`.
# Files and images
A file field stores an upload with its record, under the same rules. Images are resized, cropped and converted on request, so a thumbnail only costs a thumbnail's bytes.
## Uploading
```ts title="app.ts"
const photos = sluurp.collection("photos");
await photos.upload(record.id, "image", input.files[0]);
// or create the record and upload in one step
await photos.createWithFile({ caption: "Sports day" }, "image", file);
```
Over HTTP: `POST /api/files/{collection}/{id}/{field}`, multipart, with a part named `file`. Uploading counts as updating the record (update rule applies); downloading counts as viewing it (view rule applies).
## Resized images
```ts title="app.ts"
img.src = photos.fileUrl(record.id, "image", { w: 320, h: 320, fit: "cover" });
img.srcset = photos.srcset(record.id, "image", [400, 800, 1200]);
```
| Parameter | |
|---|---|
| `w`, `h` | Width and height, up to 4000 |
| `fit` | `contain` (default: fit inside the box) or `cover` (fill the box, cropped) |
| `format` | `jpeg`, `png` or `webp` |
| `q` | Quality, for `jpeg` and `webp` |
Each variant is generated once and cached, keyed by file and size, so a new upload never serves a stale image. The cache can be cleared at any time; variants are regenerated on demand.
Private files need auth: `fileObjectUrl(id, field)` returns a URL an `` can use, and `fileText(id, field)` returns the contents as text.
## Storage
Files are stored on disk next to the database, in the data folder's `storage/`, or wherever `SLUURP_FILES` says; see [Storage](/docs/deploying#storage). For multiple servers, configure an S3-compatible bucket (AWS, Cloudflare R2, MinIO, Backblaze) via environment variables. Uploads are then copied to the bucket, and a server missing a file fetches it from there:
```sh title="Terminal"
SLUURP_S3_BUCKET=school-files
SLUURP_S3_ENDPOINT=https://.r2.cloudflarestorage.com
SLUURP_S3_REGION=auto
SLUURP_S3_ACCESS_KEY_ID=…
SLUURP_S3_SECRET_ACCESS_KEY=…
```
## Quotas
Set a storage quota per project and per user in the admin UI. Uploads that would exceed it are rejected before being written. No quota means no limit.
The admin UI previews images in their records and lets you replace or remove them.
# Realtime
Subscribe to a collection and get every create, update and delete as it happens. Each event is filtered through the subscriber's own rules, so nobody hears about a row they couldn't fetch.
## Server-sent events
One request with the browser's built-in `EventSource`:
```ts title="app.ts"
const events = new EventSource("/api/realtime?subscribe=messages,notices");
events.addEventListener("record", (e) => {
const { action, collection, record } = JSON.parse(e.data);
// action: "create", "update" or "delete"
});
```
Omit `subscribe` to get every collection the caller can read. The first event is `connected` and lists the subscriptions. `EventSource` can't send headers, so a signed-in page passes its token in the URL: `&token=…`.
## A WebSocket
`sluurp.socket()` in the [browser client](/docs/client) does the same over a single WebSocket (`/api/ws`), plus presence and ephemeral messages:
```ts title="app.ts"
const socket = sluurp.socket({ subscribe: ["messages"] });
socket.on("record", ({ action, record }) => show(record));
socket.join("room:4b"); // presence
socket.emit("room:4b", "typing", { by: me }); // broadcast, not stored
socket.send("messages", { body: "Hello" }); // a write, same rules as over HTTP
```
It reconnects automatically and rejoins its topics.
## What it costs
Subscribers are grouped before anything is read. A rule that doesn't reference the caller gives everyone the same result, so it runs once for all of them: ten thousand anonymous subscribers cost one query. Only a rule like `author = @request.auth.id` runs per user, and connections from the same user share it.
## Usually you want Sync instead
Most screens don't need raw events; they need a list that stays up to date. [Sync](/docs/sync) keeps a filtered collection current in the browser from the same events, and [live views](/docs/live-views) do it for server-rendered HTML.
# Sync
A shape is a filtered view of a collection. The server sends its rows, then every row that enters, changes or leaves it, so you never fetch, refetch or patch lists by hand.
```tsx title="Browser"
import { Sync } from "sluurp/sync";
const sync = new Sync();
const marks = sync.shape("grades", { filter: 'class = "class4a0000000"' });
{() => marks.rows().map((g) =>
{g.value}
)}
// optimistic: shows immediately
await marks.update(id, { value: 8 }, { reason: "Re-marked" });
```
- **One WebSocket** (`/api/sync`) carries every shape on the page and reconnects automatically.
- **Rows are read as the signed-in user,** through the collection's rules. Each change is re-checked per client before sending, so an update can never leak a row the user can't see.
- **No stale overwrites:** the server sends the current row, not the event, so an older version never replaces a newer one.
- **Optimistic writes** go through the regular API and are rolled back if the server rejects them.
- **`keep: true` renders the last rows instantly on the next visit** from IndexedDB, until the server's arrive. Off by default, since data stays on the device. Only small shapes (up to 2,000 rows) are persisted. The cache is per user, so the next person on a shared computer never sees it, and signing out clears it.
## Every list kept current
In the browser, `listAll` is backed by a shape too. The first time a screen runs a query (collection + filter + sort), the rows arrive over the sync socket. After that, the same query is answered locally from rows that are kept up to date, with no request. So in a single-page app, going back to a screen shows its data instantly, including changes other people made in the meantime. Right after the page writes to a collection, the next read goes to the server so it includes the write.
Each kept query is a live subscription: it holds rows in memory and the server re-checks it on every write to that collection. So the cache is bounded by the memory its rows take (about 16 MB, or 32 MB on devices that report 8 GB of RAM or more; phones crash well before a desktop would) and by count (128). When either limit is hit, the least recently read query is dropped. Many small queries fit; a few large ones take the whole budget. Set `sluurp.syncedReads = false` to always go to the server.
To make UI follow the rows reactively, use `live`. A value derived from two collections recomputes when either changes, and only then:
```tsx title="Browser"
import { live } from "sluurp/sync";
import { computed } from "sluurp/reactive";
const marks = live(sluurp.collection("grades"), { filter: 'class = "class4a0000000"' });
const pupils = live(sluurp.collection("users"), { filter: 'class = "class4a0000000"' });
const average = computed(() => mean(marks().filter((m) => pupils().some((p) => p.id === m.pupil)).map((m) => m.value)));
```
## Related records, kept current
`expand` embeds each row's related records, as it does for [the API](/docs/api#related-records), and keeps them current. Rename a pupil and every mark showing that pupil updates, even though the marks themselves didn't change:
```ts
const marks = live(sluurp.collection("marks"), { filter: `class = "4A"`, expand: "pupil" });
// marks()[0].expand.pupil.name
```
The server tracks which related records each row shows. A change to one of them sends those rows again, read through the rules as you, so a related record you can't read never reaches you. (Supabase's realtime sends only the changed table's own row, and leaves this join to the client.)
## Row-level reactivity
Every row on the page has its own signal, shared by every list containing it. The server says which row changed, and only what read that row recomputes: changing one row never wakes code that read another.
```tsx title="Browser"
import { liveRow } from "sluurp/sync";
const a1 = liveRow(sluurp.collection("cells"), "demoa1000000000");
const doubled = computed(() => Number(a1()?.formula ?? 0) * 2);
```
`update` and `delete` apply immediately to every list and row that holds the record, before the server responds, and roll back if it rejects them. A created row appears once the server says which lists it belongs to, a moment later. `getOne(id)` is answered locally from any synced list holding that row, with no request, except right after a write to that collection, when it asks the server.
A row only receives changes while it's in a list the page holds (via `live`, `listAll` or a shape). `examples/sheet` is a spreadsheet built this way: each cell is a row, and formulas read the rows they reference. Change a value or formula anywhere and every browser recomputes just its dependents, transitively, and nothing else. See [Examples](/examples).
## SQL in the browser
By default rows live in an in-memory store. `sluurp/sync/sqlite` stores them in SQLite in the browser instead (the official WebAssembly build, vendored), with live queries.
```ts title="Browser"
// only on the page that needs it
const { openSqlite } = await import("sluurp/sync/sqlite");
const db = await openSqlite();
sync.shape("study_events", { filter: `page = "${id}"`, store: db.store("study_events") });
const recent = db.live("SELECT author_name, text FROM study_events ORDER BY created DESC LIMIT 20");
```
## The past, synced once
A shape with `asOf` is sent once as a snapshot into a table named `@` (e.g. `grades@2026-06-30`). Local SQL can then use `FOR SYSTEM_TIME AS OF`, like the server's console.
```ts title="Browser"
sync.shape("grades", { asOf: "2026-06-30", store: db.store("grades", { asOf: "2026-06-30" }) });
db.live("SELECT avg(value) FROM grades FOR SYSTEM_TIME AS OF '2026-06-30'");
```
An agent-driven chat is built this way: the conversation and its tables are live queries over synced rows, while [agents](/docs/agents) respond on the server.
## Cursors
`sluurp/cursors` shows everyone's pointer over a page, each in their own colour with a name label. Positions go over `/api/ws` (not stored), at most once per frame, and are interpolated so motion stays smooth on a jittery network. An idle pointer starts fading after three seconds until it disappears, and reappears on movement. Selected text (including in inputs) is quoted next to the pointer, but only within the tracked element.
Apps can share extra state: `state` is read each frame and sent when it changes, and `onState` receives everyone else's with their name and colour. The [Sheet](/examples) uses this to show each user's selection in their colour:
```ts title="Browser"
liveCursors("cells:*", { over: table, state: () => area(), onState: (others) => theirs.set(others) });
```
```ts title="Browser"
import { liveCursors } from "sluurp/cursors";
const stop = liveCursors("todos:*", { over: document.querySelector("main") });
```
A topic is a record (`lists:abc`) or a whole collection (`todos:*`); anyone who can list it can join.
# History and AS OF
With history on, every change is logged in the same transaction as the write: the new version of the record, who changed it, when, and why.
## Turn it on
Enable it in the admin UI or with `"history": true` in the schema. Existing rows are logged once as a baseline, so the history starts complete.
## Read the past
```ts title="Browser"
// the collection as it stood at the end of June
await sluurp.collection("grades").list({ asOf: "2026-06-30" });
// one record's versions, newest first, and restoring one
await sluurp.collection("grades").history(id);
await sluurp.collection("grades").restore(id, seq, { reason: "Undo" });
```
```http title="HTTP"
GET /api/collections/grades/aggregate?op=avg&field=value&group=subject&asOf=2026-06-30
// every change, in order
GET /api/collections/grades/changes?since=0
```
Each past version is checked against the collection's view rule as it applied to that version, so history never shows anything the reader couldn't have seen.
A past snapshot is rebuilt once and cached, so paging through it or querying it again is fast. The server caches a few snapshots per collection. If too many new snapshots are requested at once, the extra requests get `429 Too Many Requests` and can retry a second later, so time travel never slows down everyone else.
## Reasons
```ts title="Browser"
await sluurp.collection("grades").update(id, { value: 7 }, { reason: "Re-marked after appeal" });
```
Over HTTP, use the `X-Sluurp-Reason` header. A [batch](/docs/batch) applies one reason to all its changes.
## In SQL
The admin UI's SQL console supports SQL:2011 temporal syntax. Plain queries still read the latest rows.
```sql title="SQL"
SELECT subject, avg(value)
FROM grades FOR SYSTEM_TIME AS OF '2026-06-30'
GROUP BY subject;
```
Before the query reaches SQLite, each such table is swapped for its snapshot, rebuilt from the change log under the same name. This also works in the browser on rows [synced into SQLite](/docs/sync).
## Pages
Pages' version history uses the same log: who changed a page, when, and why. Restoring a page restores one of its versions.
# Batches and reasons
A batch makes several writes as one: either all of them happen or none do. A reason can go with them, and it is kept with every change in collections that keep history.
```http title="HTTP"
POST /api/batch
X-Sluurp-Reason: Term two timetable
{
"requests": [
{ "method": "POST", "url": "/api/collections/lessons/records", "body": { "day": "mon", "slot": 1 } },
{ "method": "PATCH", "url": "/api/collections/lessons/records/l0000000000001", "body": { "slot": 2 } },
{ "method": "DELETE", "url": "/api/collections/lessons/records/l0000000000002" }
]
}
```
- A batch is one transaction. If any request is refused, by a rule or by validation, nothing is written, and the answer says which request failed.
- Each request is checked as the caller, as it would be on its own.
- A batch refuses collections that have a write hook, because a hook runs outside the transaction.
- The reason, given as the header or as `"reason"` in the body, is kept with every change the batch makes.
## One write at a time
```ts title="Browser"
await sluurp.collection("grades").update(id, { value: 7 }, { reason: "Re-marked after appeal" });
await sluurp.collection("grades").delete(id, { reason: "Entered twice" });
```
Reasons show in a record's [history](/docs/history), beside who made the change and when.
# Search by meaning
Semantic search: query a collection with some text and get rows ranked by similarity. With an embeddings model, "excursion" finds the museum trip; without one, it falls back to fuzzy word matching. Vectors live in the same SQLite file as the rows, with no index to build.
```js title="client"
const { items } = await sluurp.collection("notes").list({ near: "museum trips" });
```
```http title="HTTP"
GET /api/collections/notes/records?near=museum%20trips
```
Each row gets a `_score` (1 = identical, lower = less similar). Everything else about `list` still applies: [rules](/docs/rules) limit which rows are considered, and `filter` narrows them before ranking:
```js title="client"
await sluurp.collection("notes").list({
near: "a pupil who is struggling",
filter: `class = "4B"`,
perPage: 10,
});
```
## Fields
By default, fields marked `"searchable": true` are used (the same ones `search=` uses). If there are none, text, email and URL fields are used. Override with `on`:
```js title="client"
await sluurp.collection("posts").list({ near: "school trip", on: "title,body" });
```
## Embeddings
How vectors are computed depends on the AI settings in the admin UI (**Platform → AI**).
- **Local model file:** Sluurp computes embeddings in-process, nothing else to run. Point it at a `.gguf` embeddings model such as [nomic-embed-text-v1.5](https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF) (84 MB); **Find models on this machine** lists ones already downloaded by LM Studio or Hugging Face. Supports BERT-style models (Nomic, MiniLM, BGE, E5) in F32, F16, Q8_0, Q4_K, Q5_K or Q6_K. Tens of milliseconds per text on a laptop.
- **Embeddings API:** any OpenAI-compatible embeddings endpoint (local Ollama, OpenAI, …). With Ollama: `ollama pull nomic-embed-text`, base URL `http://localhost:11434/v1`, model `nomic-embed-text`. Text is sent only to that server.
- **Neither:** vectors are built from words and character trigrams. "museum trips" still finds "Museum trip — 4B" and typos are tolerated, but "excursion" won't match. Nothing is sent anywhere.
Switching models is safe: vectors are stored per model and recomputed lazily.
## `near` and `search`
| | `search=` | `near=` |
|---|---|---|
| Matches | rows containing the words | rows about the same thing |
| Order | by `sort` | most similar first |
| Engine | SQLite full-text index | exact vector comparison |
| Good for | names, codes, exact phrases | questions, topics, "more like this" |
Use `search` when users know the exact words, `near` when they know the meaning.
## How it works
- **Vectors are computed lazily** the first time a row is searched, and stored with a hash of the source text; they're recomputed when the text changes. Writes cost nothing extra.
- **Vectors are ordinary rows** in the project database (`_vectors_`), backed up with everything else, and compared with [sqlite-vector](https://github.com/sqliteai/sqlite-vector) (Apache-2.0), compiled into the binary.
- **Search is exact, not approximate.** Every visible row (up to 5,000 after `filter`) is compared, so there's no index to tune and no missed rows. For bigger collections, narrow first with `filter` (a class, a term, the last year).
- **Max 500 rows per page**, as with any list.
## The social feed
Posts can be searched the same way, across feeds the user can read:
```js title="client"
const { items } = await sluurp.social.feed({ near: "lost property" });
```
```http title="HTTP"
GET /api/social/feed?near=lost%20property
```
A search palette can show both: semantic matches next to keyword matches.
# Migrations
An app's migrations/ folder holds versioned changes to its schema and data, one file per version, Flyway-style. Each runs once, in order, at startup: new installs get all of them, existing ones only what's pending.
```text title="migrations/"
V1__init.json # collections, same format as schema.json
V2__sample_data.json # records (omit the file for no sample data)
V3__school_year.js # generate or transform data in JavaScript
V4__trips_price.json # schema change, as a patch
V5__tidy.sql # plain SQL
R__views.sql # repeatable: re-runs whenever it changes
```
## Versions
Files are named `V__`: `V1`, `V2`, `V2_1` (between `V2` and `V3`). Versions compare numerically, with `_` or `.` as separators and leading zeros ignored: `V2_1` = `V2.01`, and `V2_2` sorts before `V2_10`. Applied migrations are recorded with a checksum. **Never edit a migration that already ran**: the server stops before running anything after it and names the file. Add a new version instead. `R__` files run after versioned ones, and again whenever their contents change.
`migrations/` goes in the app folder, or next to it to share it between apps in one repository (e.g. an app and its `reports/`); it then runs once for all of them.
## Kinds of file
**`.json`** is an import document, same format as `schema.json`: `collections`, then `records`. Records are inserted in dependency order, so referenced rows exist first.
It can also hold non-collection data: `settings`, `conversations` with messages, `pages` with blocks and rows, social `feeds` and their `posts`. A post needs `id`, `feed`, `author` and `body`, and can have `reply_to` (a reply, in the thread's feed), `created`, `likes` (user ids) and `pinned`. Posts are inserted through the same path as in-app posts, so counts, `#tags` and search stay consistent. Existing pages are left untouched unless the document sets `"merge": true`, which updates folder settings, properties, template, navigation, sign-ups and look while keeping content and rows.
```json title="migrations/V5__news.json"
{ "posts": [
{ "id": "welcome", "feed": "school", "author": "principal0001", "body": "Welcome back! #backtoschool", "pinned": true },
{ "id": "welcome-1", "reply_to": "welcome", "author": "parent0000001", "body": "Thank you!" }
] }
```
**A patch** changes the schema by name. It's a JSON merge patch: objects add or modify, `null` deletes (along with its data). It's the only migration type that removes anything, and only what it names.
```json title="migrations/V4__trips_price.json"
{ "patch": {
"trips": { "fields": { "price": { "type": "number" }, "old_note": null } },
"invoices": null
} }
```
A file can contain both: the patch runs first, then collections, then records, so schema and data for one version travel together.
**`.js` or `.ts`** files export a function that runs like a [job](/docs/jobs), e.g. `ctx.collection("trips").update(…)`. It can also return a document (`{ records: … }` or a `patch`), imported like a `.json` migration; that's the way to generate lots of data:
```js title="migrations/V3__school_year.js"
export default function () {
const grades = pupils.flatMap((p) => marksFor(p));
return { records: { grades } };
}
```
**`.sql`** runs as a single script in one transaction.
## Running them
Migrations run when `sluurp serve` starts, before the app's agents. To preview or run them without serving:
```sh title="Terminal"
sluurp migrate ./app --plan
sluurp migrate ./app
```
A database that already has what some of the files make, because it was set up by hand or before migrations existed, can say so. `--baseline` records every file up to that version as run, without running it. Later files then run as usual:
```sh title="Terminal"
sluurp migrate ./app --baseline V14
```
## All or nothing
Before running pending migrations, Sluurp snapshots the database to `snapshots/-migration/`. If any migration fails, the snapshot is restored (schema, data and migration history) and the error names the file.
Nothing is left half-applied: all writes from the failing file and from earlier migrations in the same run are rolled back. An app gets all of its pending migrations or none. Fix the file and restart.
The snapshot is kept either way as a pre-upgrade backup, listed under **Backups** in the admin UI.
Apps with only a `schema.json` work as before: on each start collections are added or changed, never removed.
# Existing SQLite databases
Already have a SQLite file? Attach it, and each of its tables becomes a collection, with an API, rules, live updates and the admin UI, read and written where it is. Nothing is copied, and nothing is added to the file.
```sh title="Terminal"
sluurp serve --attach shop=./northwind.db
```
That's all it takes to explore it: no app needed. Open the admin UI at `/_/`, where each table is a collection to browse, filter, edit and query with SQL. Add `--public ./app` when you also have a frontend to serve.
- `products` in `shop` becomes the collection `shop_products`; a column `ProductName` becomes the field `product_name`.
- Writes go straight to your table, so other programs using the file see them at once.
- A new collection starts closed: only administrators see it until you give it [rules](/docs/collections).
- Attach several files by repeating `--attach`.
- A table without a text `id` column keeps its ids in Sluurp's own file, beside each row's rowid. A row written by another program shows its rowid as its id.
The file is attached to each connection under its name, and each table is seen through a temporary view with triggers that write through to it, made as a connection opens and gone when it closes. That is why nothing is added to your file.
History, search and the recycle bin apply to what is written through Sluurp; a change another program makes to the file directly is seen, but not recorded.
## Server
# Functions and "use server"
Server code is JavaScript or TypeScript that runs in a sandbox as the calling user, so every rule still applies.
## "use server"
Mark a function in a page with `"use server"` and call it from the browser like any other function. In the code sent to the browser, its body is replaced with a call to `/api/rpc`.
```ts title="islands/add-note.tsx"
export async function addNote(title: string, at: Date) {
"use server";
// who may call it (default: anyone)
"allow: @request.auth.staff = true";
const made = await server.collection("notes").create({ title });
return { made, next: new Date(at.getTime() + 86_400_000), tags: new Set(["new"]) };
}
// in the browser
// Date and Set arrive as real Date and Set
const { next, tags } = await addNote("Milk", new Date());
```
- Arguments and return values travel as [devalue](https://github.com/sveltejs/devalue), so Dates, Maps, Sets, BigInts, `undefined` and repeated objects arrive intact.
- Without `"allow: …"`, anyone can call it. It runs as the caller, so collection rules still apply.
- Called from a server component during rendering, it runs directly, with no HTTP request.
- The body is extracted and run on its own, so top-level imports of the page aren't available inside it.
## Endpoints
Each file in `functions/` becomes an endpoint at `/api/fn/`, versioned and rolled back with the app.
```ts title="functions/register-summary.ts"
// the app's own modules
import { average } from "../lib/marks.ts";
import terms from "../data/terms.json" with { type: "json" };
export const rule = "@request.auth.id != null";
export const method = "GET";
export default async function (ctx) {
const page = await ctx.collection("grades").list({ filter: `student = "${ctx.auth.id}"` });
return { average: average(page.items.map((g) => g.value)), term: terms.autumn };
}
```
`export const runAs = "system"` bypasses the caller's rules, e.g. for aggregates everyone may see even though individual rows are private. The function then *is* the policy, so return aggregates, never rows.
## Hooks and jobs
A file in `hooks/` runs before every write to its collection, whatever the source of the write. Its reads bypass rules since it's policy, not user code, so it can enforce things rules can't, like counts or cross-row checks.
```ts title="hooks/classes.ts"
export async function beforeCreate({ record, db, reject }) {
const held = await db.count("classes", `responsible = "${record.responsible}"`);
if (held >= 2) reject(`already responsible for ${held} classes`);
// the record to write
return { ...record, name: record.name.trim() };
}
```
```ts title="jobs/nightly.ts"
export const schedule = "0 2 * * *";
export default async function (ctx) { /* … */ }
```
## The sandbox
Each call gets a fresh QuickJS context, with 64 MB of memory and 5 seconds of computation. It has `ctx`, and the web's standard globals as Deno and Bun have them: `fetch`, `URL`, `TextEncoder`, `crypto`, timers, `AbortController` and `structuredClone`. `fetch` reaches public addresses only, never this machine or your private network. There's no file system and no `process`. Imports of the app's own files are bundled in ahead of time.
## The event loop
Server code runs on an event loop, as it does in Node. `fetch` starts a request and returns straight away, so requests made together run side by side:
```ts title="functions/prices.ts"
export default async function () {
const [eur, usd] = await Promise.all([
fetch("https://example.com/rates/eur").then((r) => r.json()),
fetch("https://example.com/rates/usd").then((r) => r.json()),
]);
return { eur, usd };
}
```
A response's body is a stream, read as it arrives. That suits large downloads, server-sent events, and long polls:
```ts title="functions/follow.ts"
export default async function () {
const response = await fetch("https://example.com/events");
let lines = 0;
for await (const chunk of response.body) {
lines += new TextDecoder().decode(chunk).split("\n").length - 1;
if (lines > 100) break;
}
return { lines };
}
```
The 5 seconds count only the time spent running JavaScript. Time spent waiting, on a request or a timer, doesn't count, so a function may wait on a slow service for minutes. A loop that never ends is still stopped. A function that awaits a promise nothing can settle fails at once, rather than hanging.
While a function waits it holds no thread, so thousands can wait at once. JavaScript runs on its own pool of threads, one per core, apart from the threads that answer requests. A function that computes for a while never slows the rest of the app. `AbortController` cancels a request. When the client that called a function goes away, the function is stopped.
Hooks and views are meant to be quick. Their `fetch` waits in place and gives up after 5 seconds.
## QuickJS: small, safe, fast enough
Server code runs in [QuickJS](https://bellard.org/quickjs/), a small JavaScript engine built into the binary. It isn't V8 (Node, Deno, Chrome), JavaScriptCore (Bun, Safari) or SpiderMonkey (Firefox). Those engines compile hot code to machine code as it runs (a JIT); QuickJS interprets it. What that means for you:
- **Heavy computation is slower.** Tight loops and long calculations are about 2 to 18 times slower than in V8. Keep them out of server code, or let the database do them.
- **Each call starts fresh and small.** A new sandbox takes about 3 ms, so one call can't leave state for the next or reach another's. That's safer than one long-lived process running everyone's code.
- **Modern JavaScript, not every API.** ES2023 syntax works (async and await, classes, modules, optional chaining, `BigInt`), but not WebAssembly, threads (`Worker`, `SharedArrayBuffer`) or Node's modules.
For what server functions are for, it's plenty. They check input, read and write a few records through `ctx` (and the database work runs in SQLite, in native code), shape the answer, and perhaps call another service. That takes a few milliseconds.
Measured on one Windows laptop, the fastest of three runs, in milliseconds:
| | Node 24 | Deno 2.9 | Bun 1.4 | Sluurp (QuickJS) |
|---|---|---|---|---|
| A recursive function, `fib(25)` | 0.6 | 0.6 | 0.7 | 11 |
| 5,000 records to JSON and back, five times | 9.5 | 7.5 | 7.2 | 52 |
| Sort 100,000 numbers | 28.7 | 28.8 | 15.3 | 50 |
| Build, join, replace and split 50,000 strings | 5.0 | 5.1 | 5.7 | 42 |
| Filter, group and average 2,000 records, 50 times | 1.4 | 1.5 | 1.2 | 14 |
| A whole call to a function that returns `{ hello }`, over HTTP | – | – | – | 4.9 |
For a comparison of engines on standard benchmarks, see [QuickJS's own benchmarks](https://bellard.org/quickjs/bench.html). Code that needs a JIT, a thread or a native module belongs in a service of its own. It can use your data through the [REST API](/docs/api) and send Sluurp [events](/docs/hooks-and-events#events-from-outside).
# The server library
Functions, hooks, jobs and agents run in a sandbox with the same globals Deno, Bun and browsers have, plus a `Sluurp` object for what the web platform leaves out. Code and packages written for those runtimes mostly run unchanged. Nothing needs importing.
## The web's standard globals
| | |
|---|---|
| Network | `fetch`, `Request`, `Response`, `Headers`, `AbortController`, `AbortSignal` |
| Streams | `ReadableStream`, `WritableStream`, `TransformStream`, `CompressionStream`, `DecompressionStream` |
| Text and bytes | `TextEncoder`, `TextDecoder`, `atob`, `btoa`, `Blob`, `File` |
| Addresses | `URL`, `URLSearchParams` |
| Crypto | `crypto.getRandomValues`, `crypto.randomUUID`, `crypto.subtle` |
| Time | `setTimeout`, `setInterval`, `queueMicrotask`, `performance.now` |
| Other | `structuredClone`, `console`, `Event`, `EventTarget`, `DOMException` |
`fetch` runs requests side by side and streams bodies; see [the event loop](/docs/server-functions#the-event-loop).
`crypto.subtle` has these algorithms:
- **Hashes:** SHA-1, SHA-256, SHA-384 and SHA-512 (`digest`).
- **Signatures and MACs:** HMAC, ECDSA on P-256 and P-384, Ed25519, and RSA as RSASSA-PKCS1-v1_5 or RSA-PSS (`sign`, `verify`).
- **Encryption:** AES-GCM with 128- or 256-bit keys and a 12-byte IV (`encrypt`, `decrypt`).
- **Key derivation:** PBKDF2 and HKDF (`deriveBits`, `deriveKey`).
Keys come in as `raw`, `pkcs8`, `spki` or `jwk`, and go out the same ways. HMAC, AES, ECDSA and Ed25519 keys can be generated. RSA keys can't, so make them elsewhere and import them. There's no RSA-OAEP, AES-CBC, AES-CTR or ECDH.
```ts title="functions/token.ts"
export default async function () {
const { privateKey, publicKey } = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
const data = new TextEncoder().encode("hello");
const signature = await crypto.subtle.sign("Ed25519", privateKey, data);
return { ok: await crypto.subtle.verify("Ed25519", publicKey, signature, data) };
}
```
## Sluurp's own
### Files
`Sluurp.files` keeps files for server code, by path. They live in the app's database, so they're in its backups and replicas. A path can't reach anything outside the app.
```ts title="functions/report.ts"
export default async function (ctx) {
const orders = await ctx.collection("orders").list();
await Sluurp.files.write("reports/today.csv", Sluurp.CSV.stringify(orders.items));
return await Sluurp.files.list("reports");
}
```
| | |
|---|---|
| `write(path, data)` | Writes a string, bytes, a `Blob` or a `Response`. Folders are made as needed. |
| `read(path)` | Gives the bytes, or `null` when there's no such file. |
| `text(path)`, `json(path)` | Read as text, or parsed as JSON. |
| `exists(path)`, `stat(path)` | Whether it's there; its size and when it last changed. |
| `list(folder)` | Every file under a folder, with sizes and times. |
| `rename(from, to)` | Moves a file, or a folder with everything in it. |
| `remove(path)` | Removes a file, or a folder with everything in it. |
For files people upload, use a [file field](/docs/files) on a collection. Those have rules, thumbnails and links.
### Formats
| | |
|---|---|
| `Sluurp.CSV` | `parse(text, { header })` and `stringify(rows)`. Quoted fields, quotes inside them, and line breaks inside quotes all work. |
| `Sluurp.YAML`, `Sluurp.TOML`, `Sluurp.JSON5` | `parse` and `stringify`. |
| `Sluurp.JSONL` | One JSON value per line. |
| `Sluurp.Archive` | Reads and writes tar files, gzipped or not. |
| `Sluurp.encoding` | `hex`, `base64`, `base64url` and `base32`, each with `encode` and `decode`. |
### Compression and passwords
`Sluurp.compress(data, format)` and `Sluurp.decompress(data, format)` work on whole buffers. The format is `gzip` (the default), `deflate`, `deflate-raw` or `br` (brotli). For streams, use `CompressionStream`.
`Sluurp.password.hash(text)` hashes with Argon2id, as Sluurp's own sign-in does. `Sluurp.password.verify(text, hash)` checks a password against a hash in constant time.
## Compared with Deno and Bun
| | Deno | Bun | Sluurp |
|---|---|---|---|
| Web standard globals | Yes | Yes | Yes, as above |
| `crypto.subtle` | Every algorithm | Every algorithm | The common ones, as above |
| Files | The machine's disk, with permissions | The machine's disk | The app's own files, in its database |
| Formats | `@std/csv`, `@std/yaml`, `@std/toml` | `Bun.TOML`, YAML | CSV, YAML, TOML, JSON5, JSONL, tar |
| Passwords | From a package | `Bun.password` | `Sluurp.password` |
| Compression | Streams, and `node:zlib` | `Bun.gzipSync` and streams | `Sluurp.compress` and streams |
| Node's modules | Most | Most | None |
| Child processes, sockets, workers | Yes | Yes | No |
| One call's startup and memory | One long-lived process | One long-lived process | About 1 ms and 300 KB for each call, which then ends |
The difference is by design. Deno and Bun give code the machine, and you choose how far to trust it. Sluurp gives each call a fresh sandbox that can reach only your data, the web's public addresses and its own files. A mistake in one function can't leak into another, or into the server.
# Hooks and events
A hook runs before a write and can modify or reject it. When something notable happens it emits an event, and agents react to it: send an email, write an audit row, emit the next event. Each piece stays small and independent.
## Hooks
A file in `hooks/` named after a collection runs before each write to it. `beforeCreate`, `beforeUpdate` and `beforeDelete` receive the record and return it, modified if needed; `reject` aborts the write with a message.
```ts title="hooks/orders.ts"
export function beforeCreate({ record, reject, alert }) {
if (String(record.note ?? "").includes("
```
## AI components
The kit's AI components are the ones Sluurp's own AI screens use:
- **`ChatPanel`** holds a conversation. Feed it from `sluurp/ai`'s `assistant()`.
- **`AgentSidebar`** is where an AI sits: beside the page, resizable, and a sheet on a phone.
- **`AgentAskField`** is the field that floats at the foot of what the AI works on.
- **`AgentToolSteps`** shows what the AI did between two answers: "Used 3 tools (1 failed)", each opening to what it was given and what came back. Mark a step with `error: true` when its tool failed; it's shown in red, with its error.
```tsx
import { assistant } from "sluurp/ai";
import { ChatPanel } from "sluurp/kit";
const ai = assistant({ client: sluurp, system: () => "You help with homework.", load, save, words });
;
```
## A questionnaire
One question at a time: single choice, multiple choice or free text, with progress and Previous / Skip / Next. Letter keys pick choices, and `when` makes a question conditional on earlier answers.
```tsx title="islands/consent.tsx"
import { Questionnaire } from "sluurp/kit/questionnaire.js";
a.coming === "yes",
choices: [{ value: "allergy", label: "An allergy" }, { value: "travel", label: "Travel sickness" }] },
]}
onSubmit={(answers) => sluurp.collection("consent").create(answers)}
/>
```
Each question is a `fieldset` with the prompt as `legend` and native radios and checkboxes. Pass a signal as `answers` to prefill and track them.
## Data for components
A component shown on its own, in the component editor, can be given data four ways, as props:
- **Mock values**: plain JSON.
- **A snapshot of a collection**: `{"$collection": "posts", "limit": 5}`.
- **A live query**: `{"$live": "posts", "filter": "published = true"}`. The component gets the rows as a function and follows them as they change.
- **Both ways**: `{"$sync": "posts"}` gives rows that can also be written, with `create`, `update` and `remove`. `{"$sheet": "cells", "sheet": "budget"}` gives a Spreadsheet its cells in a collection.
In an app, list a collection with `Records`. Add `live` to keep the list current:
```tsx
import { Records } from "sluurp/kit/records.js";
Nothing yet.
}>
{(post) =>
{post.title}
}
```
To keep a Spreadsheet's cells in a collection, shared live with everybody, use `sheetStore`:
```tsx
import { Spreadsheet } from "sluurp/kit/spreadsheet.js";
import { sheetStore } from "sluurp/kit/sheet-store.js";
```
## Tailwind
See [Styling and Tailwind](/docs/styling) for how classes are styled with nothing to set up, and for `cn` and `variants`.
Tailwind v4, no build step: the stylesheet is generated from the classes your app uses. Configure it the v4 way, in CSS:
```css title="app.css"
@theme {
--color-brand: oklch(0.62 0.19 35);
--font-display: "Fraunces", serif;
--breakpoint-3xl: 120rem;
}
@utility content-auto { content-visibility: auto; }
@utility tab-* { tab-size: --value(integer); }
@custom-variant midnight (&:where([data-theme="midnight"] *));
.card { @apply rounded-xl p-4 shadow-sm; @variant hover { @apply shadow-md; } }
```
`--color-brand` generates `bg-brand`, `text-brand/50`, `ring-brand` and so on; `--font-*`, `--text-*`, `--radius-*`, `--shadow-*` and `--breakpoint-*` work the same way. `@apply` and `@variant` are compiled server-side, so the browser gets plain CSS. Newer utilities are supported too: `text-shadow-*`, `inset-shadow-*`, `inset-ring-*`, masks (`mask-b-from-50%`), 3D transforms (`rotate-x-45`, `perspective-near`), `bg-radial`, `bg-conic`, `scheme-*`, `wrap-*`, and variants like `not-*`, `nth-*`, `supports-*`, `min-[…]`, `pointer-coarse`, `forced-colors`, `inert` and `starting`. See them live in the admin's Components screen.
## Theming
Colours use [shadcn's tokens](https://ui.shadcn.com/docs/theming): `background`, `foreground`, `card`, `popover`, `primary`, `secondary`, `muted`, `accent`, `destructive`, `border`, `input`, `ring`, `chart-1`…`chart-5` and `sidebar-*`, each with a `-foreground` pair. Themes from [ui.shadcn.com/themes](https://ui.shadcn.com/themes) or tweakcn paste in unchanged:
```css title="app.css"
:root {
--radius: 0.625rem;
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
}
.dark, [data-theme="dark"] {
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
}
```
`bg-primary` is `var(--primary)`; `bg-primary/50` mixes it with transparent, as in Tailwind 4. Any colour format works: `oklch()`, `hsl()` or `#hex`.
shadcn's seven base colours are included (Neutral, Stone, Zinc, Mauve, Olive, Mist, Taupe), light and dark. Link the stylesheet and pick one on the root:
```html
```
## Palette Editor
A palette for people to choose and shape: its colours as a strip where each one can be re-rolled on its own with a click, another palette with the dice, colours pasted from Coolors, Color Hunt or Lospec, and a pill that puts the default back. Give it `saturation` to add a vibrancy slider.
```tsx
defaultPalette()} onChange={save} />
```
## Smart Table
A table of records you give it as data. Each part can be turned on or off:
- **Toolbar** (`search`, `start`, `actions`): search across all columns, a Filters button for the columns marked `filter`, and slots for your own controls.
- **Headings**: click to sort (ascending, descending, off). Drag a heading to reorder (`reorderable`), drag its edge to resize (`resizable`). The arrow keys also resize; a double click resets the width.
- **Columns menu** (`hideable`): show and hide columns.
- **Rows**: ticks (`selectable`), your own row actions (`rowActions`), pages (`pageSize`) or infinite scroll (`paging: "infinite"`, with `loadMore` to fetch the next page). Past 200 rows, only the rows in view are drawn.
- **Footer** (`footer`): the row count, number totals and yes counts. With ticks, it sums only the ticked rows.
```tsx
},
]}
rows={trips}
/>
```
Values are formatted in the reader's language with `format`: `"currency"`, `"percent"`, `"integer"`, `"date"`, or Intl options. `cell` draws a cell your own way.
Widths fit the space available: the rightmost columns shrink first, each down to its `minWidth`. The table only scrolls sideways once every column is at its minimum. Sorting, hiding, order and widths are the table's `look`: saved in the browser under `storageKey`, or held in your own signal (`look`) so you can save it anywhere.
## Spreadsheet
A spreadsheet component: formulas, selection, in-place editing, copy/paste, column resize/insert/delete/drag/hide, row insert/hide, and a right-click menu. Recalculation is incremental: when a cell changes, only the cells that depend on it re-run.
It doesn't own its data. Pass it a store:
```tsx title="app.tsx"
import { mount } from "sluurp/ui";
import { Spreadsheet, memoryStore } from "sluurp/kit";
const store = memoryStore({ A1: "2", A2: "3", A3: "=SUM(A1:A2)" });
mount("#app", () => );
```
A store implements:
| Method | |
|---|---|
| `cell(ref)` | Returns a reactive getter for a cell (`"A1"`). |
| `write(ref, patch)` | Updates a cell. |
| `shape()`, `setShape(shape)` | Optional. Persist column/row order, count and visibility. Without them, layout is per screen. |
A cell is `{ formula, bold?, format?, renderer? }`.
`memoryStore` is in-memory. For a shared sheet, back the store with a [Sync](/docs/sync) collection, like the [Sheet example](/examples#sheet). For a non-reactive source (a `Map`, a cache), use `externalStore` and notify it with the ref that changed:
```ts
const values = new Map();
const listeners = new Set<(ref?: string) => void>();
const store = externalStore({
get: (ref) => values.get(ref),
write: (ref, patch) => {
values.set(ref, { formula: "", ...values.get(ref), ...patch });
listeners.forEach((l) => l(ref));
},
subscribe: (l) => (listeners.add(l), () => listeners.delete(l)),
});
```
Built-in functions: `SUM`, `AVERAGE`, `MIN`, `MAX`, `COUNT`, `ROUND`, `ABS`, `IF`, plus comparisons and `&` for string concatenation. References work as in Excel: inserting or deleting rows and columns shifts them, and when you paste, `$` pins a part (`$A$1` stays put, `$A1` keeps its column, `A$1` its row). Add your own functions:
```tsx
Number(x) * 2 }} />
```
Conditional styles are set per cell from the context menu: a formula that returns a colour name (`red`, `text:green`, `border:amber`, `bold`) or any Tailwind classes, variants included. It can reference any cells, and `THIS` is the cell's own value:
```text
=IF(SUM(A1:A3)>10, "red", "green")
=IF(THIS>100, "bg-orange-500 text-white hover:underline", "")
```
Classes that aren't in your app's stylesheet (it's generated from your source, and these are typed at runtime) are reported through the `classes` prop. Pass `useClasses` from `sluurp/classes` and it fetches their CSS once and adds it to the page, or to the island's shadow root:
```tsx
import { useClasses } from "sluurp/classes";
```
Custom renderers draw a cell's value however you like. Register them by name; users pick one from the context menu. A renderer can take options, stored after its name in the cell (`progress 0 100 bg-emerald-500`). If it declares them, its menu item asks:
```tsx
const renderers = {
progress: {
options: "min max colour",
render: (value, ref, sheet, [min = "0", max = "1", colour = "bg-primary"]) => {
const share = Math.max(0, Math.min(1, (Number(value) - Number(min)) / (Number(max) - Number(min))));
return (
);
},
},
};
```
There's no networking inside. For multiplayer, pass `others` (remote selections, drawn in each user's colour) and broadcast your own from `onSelect`. Live cursors come from `sluurp/cursors`.
## Try them
Everything below is the live kit in this page's theme: click, type, drag. The island is a single file.
# Charts
A chart is a sentence like mean value by subject sorted, run over a collection's rows as the viewer. For anything a sentence can't express, write a few lines of JavaScript, or return a Plotly figure.
## Chart blocks
A chart block reads a collection's rows (as the viewer) and shapes them in one of two ways:
- **A sentence** like `mean value by subject sorted`. It's parsed, never executed, so it's safe and needs no worker. The language is described below.
- **A few lines of JavaScript** for anything a sentence can't express: `return count(rows, "status")`. It runs in an isolated worker with only the rows and some helpers (no page, no session) and is killed after two seconds.
The block detects which one it is: text starting with a measure and without code punctuation is a sentence.
```js title="Chart block, as JavaScript"
return groupBy(rows, "class").map(([label, list]) => ({ label, value: mean(list, "value") }));
```
Sentence examples:
```text title="Chart block"
count grades by subject
mean value from grades by subject sorted
percent by status
# stacked, one series per status
count by month of date and by status
count by subject top 5 # the rest as "Other"
mean value by week of date rolling 4
sum (first_half + second_half) / 2 by month of date
values value against date where subject = Mathematics # a scatter
# as of a past date
mean value by subject as of 2026-06-30
```
| Part | Forms |
|---|---|
| Measures | `count percent sum mean median min max distinct values` |
| Groupings | `by field`, `by month\|week\|weekday\|year of date`, `by n every 5`; a second grouping after "and" makes series: `by class and by subject` |
| Conditions | `where a = x and b >= 3 and roles has pupil` |
| Endings | `top n`, `sorted` (smallest first) or `sorted desc`, `cumulative`, `rolling n`, `as of 2026-06-30` |
Relations are shown by name. Time axes span first to last month with data, keeping empty months and bins: counts show 0, while an average over nothing is a gap in the line rather than a drop to 0. The language has its own unit tests, which run in Node without a build.
## Live tables, shared charts
Editors can make a table **Live** from its toolbar. Live tables read rows through [sync](/docs/sync), so everyone's changes appear in real time: what you want for a kanban board, trip planning or a sign-up sheet with several people editing. Small live tables (up to 2,000 rows) are also cached per user in IndexedDB, so reloads are instant; signing out clears the cache. Non-live tables load once when the page opens.
Charts on a page share data: each collection (or slice) is fetched once per page. Chart settings:
- **Only rows where** filters the first source with the filter language (`class = "class4a0000000"`), so only that slice is fetched.
- **Live** re-renders when the rows change anywhere, via sync. Off by default, since a chart that stays still is easier to read.
## Try it
Type or pick a sentence to chart a year of a small shop's orders (category, country, shipper, date, total, shipped), Northwind-style:
## Charts with Plotly
For charts beyond the sentence language (heatmaps, sunbursts, box plots, maps, 3D), a JavaScript chart block can return a Plotly figure, `{ plotly: { data, layout } }`, styled with the page's theme and palette. Plotly ships in the binary and is only loaded when such a chart scrolls into view. Any page or island can use it via `plot()` from `sluurp/plotly`; these three islands use the same orders data:
# Pages
sluurp/pages is a Notion-style editor any app can mount: blocks, nested pages, a wiki, books, a link graph, version history, PDF export and charts.
- **Blocks:** text, headings, lists, to-dos, quotes, code, tables, images, embeds, charts, boards, figures, pull quotes, and nested pages.
- **Inline Markdown, Typora-style:** `**bold**`, `*italic*`, `` `code` ``, `~~strike~~`, `==highlight==` and `[text](url)` are formatted as you type; the markers show faintly on the current line and hide when you leave it. `Ctrl+B` / `I` / `E`, `Ctrl+Shift+X` / `H` and `Ctrl+K` (link) toggle formatting on the selection. Lines are stored as Markdown.
- **Reading mode, books view, and a graph** of links between pages.
- **Version history** in the core change log (who, when, why), with restore.
- **Ask AI / chat about a page,** when a model is configured.
### Magazines and articles
A folder can be shown as a magazine front page: the newest article large, the rest as cards. Any page can use the article layout: folder name as kicker, a large serif headline, the description as deck, a byline with reading time, a cover image, a narrow column with a drop cap, and figures (with caption and credit) at column, wide or full width, or floated left/right, rectangular or round.
In reading mode, articles are typeset line by line with [Pretext](https://github.com/chenglou/pretext):
- Text wraps around floated and round figures and the drop cap, following a round image's curve.
- Paragraphs are justified with Knuth–Plass: all line breaks of a paragraph are optimised together to minimise stretched spaces, so a loose line never follows a tight one. Rivers (spaces over 1.5× normal) and over-squeezed spaces (under ⅔) are avoided when possible.
- Hyphenation uses TeX's Liang patterns for English, French, German and Romanian, with a penalty per hyphen and a bigger one for consecutive hyphens. The last line is ragged.
- Bold, italic, code and links are preserved; lines are real text, so selection, copy, Comment and Ask work; screen readers still get paragraphs.
- Tables, boards and charts render normally between typeset runs.
An article's PDF uses the same layout on A4, embedding Source Serif 4 (the font the lines were measured with), so text wraps around round images on paper too. Colour emoji are drawn as images; elsewhere in PDFs they use Noto Emoji.
### Boards
A board is a FigJam-style whiteboard inside a page: sticky notes (with author), text, shapes and sections. Pan and zoom with the wheel, pinch or `Space`+drag; select, move, resize, recolour, duplicate, undo and redo. Connectors (`C`, or drag from a selected note's dot) join items with arrows, optionally labelled, so a board can become a concept map. Everyone on the page sees changes and each other's cursors live.
Multiplayer works like Figma's:
- A change carries only the fields it touched, and the server merges them. Two people editing different fields of the same note (one moves, one recolours) both keep their change; for the same field, last write to the server wins.
- The server sequences every change and broadcasts it, so all clients apply changes in the same order. While your own change is in flight, others' changes to that field are held back, so values don't flicker.
- A reconnecting client reloads the board and replays its unacknowledged changes.
- Undo reverts only your own changes, computed against the board's current state, so redo never clobbers someone else's edit.
- Changes are appended to a journal; every 50 changes the board is compacted into its page and the journal trimmed, so busy boards don't rewrite the page on every drag. Reads apply newer journal rows on top.
- Z-order uses fractional indexes, so two people bringing items to front at once don't collide.
Also on boards:
- **Timer** (1, 3, 5 or 10 min, +1 min, pause, stop), synced across screens, with a soft chime.
- **Voting stamps** (`B`): click again to unvote; stamps move with their note; sections show vote totals; "Clear votes" resets.
- **Pen** (`P`) with smoothing, six inks and three widths; **eraser** (`E`).
- **Laser pointer** (`K`): a fading trail everyone sees, not saved.
- **Images** from the toolbar or by pasting; large ones are downscaled, stored with the page, and count toward quotas.
- **Mind maps** (`M`): `Tab` adds a child, `Enter` a sibling, auto-laid out to the right with a colour per first-level branch and curved links. **Ideas** asks the AI model for branches to keep, edit or delete. Mind maps are ordinary notes and links, so they can grow into a concept map.
- **Style panel** (like Excalidraw) for shapes, strokes and connectors: stroke and fill colour, fill style (hatched, cross-hatched, solid), stroke width and style, sloppiness (neat, hand-drawn, scribbled, via rough.js), corners, arrowheads, opacity and layer order. Sketches are seeded by id, so they look identical everywhere.
- Board text is indexed for search.
## Try a page
This one is editable: type `/` for the block menu, drag blocks by their handle, select text to format it. In an app, pages are saved with history; this demo only lives in this tab.
## Folders: sections made of pages
A folder is a page that contains pages and lists them as rows. That's how a section of an app (say, a catalogue of courses or bookable rooms) is built from pages instead of custom screens.
- **Properties.** The folder defines fields each child page has (`props`): a day, a teacher, a capacity. Each page fills them in (`values`) under its title.
- **Formulas.** A `formula` property is computed from the page's values and its table: `rows` is the row count, `consent.yes` counts rows where `consent` is "yes" (any field/value, lower case), `cost.sum` sums a numeric column. `places - rows` gives remaining places, updating as people sign up.
- **New.** The folder names a template; "New" creates the next page from it inside the folder.
- **Navigation.** A folder marked `nav` becomes an app section for everyone who can read it, with its own icon. Only its creator or a superuser can set this.
- **Icons.** An emoji, a built-in icon (`icon:calendar`), or custom SVG (`svg:
# Live views
A live view keeps its state on the server and renders it there. The browser sends user events and gets back only the values that changed, often a few bytes.
Use it when the server should own the state: a register, a dashboard, a checkout. It isn't meant for collaborative editing: two people typing in the same field overwrite each other, last write wins.
## A view
A view is `views/.js` in the app:
```js title="views/counter.js"
export const rule = "@request.auth.id != null";
export function mount(ctx) {
return { count: 0 };
}
export function render(state, html) {
return html``;
}
export function handle(event, payload, state, ctx) {
if (event === "bump") return { ...state, count: state.count + 1 };
return state;
}
```
- `rule`: who may open it, in the [rules language](/docs/rules).
- `mount`: returns the initial state; `ctx` has the user who opened the view.
- `render`: renders the state with the `html` tag.
- `handle`: takes an event and returns the next state.
## On the page
```ts title="app.ts"
import { mount } from "sluurp/live";
await mount("#counter", "counter");
```
Attributes bind DOM events to view events:
```html title="HTML"
```
The payload includes the element's value, a form's fields, and any `sluurp-value-*` attributes. Add `sluurp-ignore` to an element to leave its children untouched across updates, e.g. for a client-side widget.
## Why so little goes over the wire
A tagged `html` template is already split by JavaScript into static strings and dynamic values. Only the values are diffed between renders, and only changed ones are sent. A counter going from 41 to 42 sends `42`.
Auth is the regular [browser client's](/docs/client), so a view sees the same user the REST API does.
# Translations
Translations live in i18n.json, one entry per phrase with a value per language. Translators edit them in the admin UI or directly on the page, and every open page updates without a reload.
## The file
```json title="app/i18n.json"
{
"locales": ["en", "fr", "de"],
"classes": { "en": "classes", "fr": "classes", "de": "klassen" },
"welcome": { "en": "welcome to {{app}}, {{name}}", "fr": "bienvenue sur {{app}}, {{name}}" }
}
```
- Keys are lower case.
- `{{name}}` is interpolated from the params you pass. `{{app}}` is always the app's name.
- Missing languages fall back to English. A key missing in English too renders as `?[key.locale]`, so gaps are visible instead of blank.
## In the app
```tsx title="app/i18n.ts"
import { createI18n } from "sluurp/i18n";
import table from "./i18n.json" with { type: "json" };
export const i18n = createI18n({ table, appName: "My app" });
export const t = i18n.t;
i18n.connect(sluurp); // live updates
```
```tsx title="app/header.tsx"
{() => t("classes|title")}
// "Classes"
{() => t("welcome", { name: me.first_name })}
```
- `|title`, `|upper` and `|lower` change case, so "classes" and "Classes" don't need separate entries.
- Wrapping in an arrow function makes it reactive: `i18n.setLocale("fr")` or a translator's edit re-renders it. A bare `t("x")` is evaluated once.
- The server merges published edits into `i18n.json` when serving it, so the import is always current.
## Translating
- **Admin UI:** **Translations** lists every key with a field per language.
- **In place:** turn on translate mode, click any text and edit it where it appears. Clicks go to the editor, so links don't navigate.
- **Collaboratively:** a translation session holds drafts visible only to its translators. Everyone's cursor shows in their colour, typing appears live, and the session is published in one step.
- **Guests:** a session link lets someone without an account edit (but not publish).
Only users with the `translator` role can translate. The [UI kit](/docs/ui-kit)'s built-in strings (Close, Search…, month names) come pre-translated. `sluurp i18n` lists unused keys.
# Packages, bundles, budgets
There is no node_modules and no bundler to set up. sluurp add downloads a package into the app's vendor/ folder and records it in sluurp-deps.json (import map, versions, and a hash of every file). The server bundles everything at startup.
```sh title="Terminal"
# add a package and its dependencies to vendor/
sluurp add npm:d3-force@3
# a JSR package, through JSR's npm registry
sluurp add jsr:@std/path@^1
# show wanted and latest versions, like pnpm
sluurp outdated
# upgrade, and drop files only the old version used
sluurp update --latest
# check every file against npm's tarballs
sluurp vendor verify
sluurp remove d3-force
# show the shared cache location and size; `clear` empties it
sluurp cache
```
- Packages are vendored as source, not as a CDN's build. CommonJS is converted to ES modules with rolldown at vendoring time.
- Types are vendored too (the package's own or DefinitelyTyped's), and `tsconfig.json` is set up so the editor finds them.
- A shared, content-addressed cache makes adding the same package to a second app instant. npm's and pnpm's caches are used when they have the file, and `--offline` works from caches alone.
## What you can add
| Spec | What it is |
|---|---|
| `npm:date-fns`, `npm:date-fns@4` | An npm package from its own sources, file by file, as the registry has it. Its dependencies come too. |
| `jsr:@std/path@^1` | A [JSR](https://jsr.io) package, through JSR's npm registry. |
| `canvas-confetti`, `three@0.170` | An npm package as one browser-ready module per package, from jsDelivr's `+esm` build. |
| `https://esm.sh/d3@7` | Any URL, from any CDN. |
| `font:inter:400,600` | A font, from Fontsource, with its CSS. |
Then import it by name, as you would with npm:
```tsx
import { format } from "date-fns";
import confetti from "canvas-confetti";
```
The name is resolved by the import map in `sluurp-deps.json`. A package's own imports of other packages are resolved there too, each in its own scope, so two versions of one package can live side by side.
## Compared with npm and package.json
| | npm with `package.json` | Sluurp |
|---|---|---|
| The list of what you use | `package.json` | `sluurp-deps.json`: the import map, versions, and a hash of every file |
| The lock file | `package-lock.json`, `pnpm-lock.yaml` | the same `sluurp-deps.json` |
| Where the code lives | `node_modules/`, installed on each machine, not committed | `vendor/`, committed with your app |
| What's kept | the whole tarball of every dependency | only the files your pages can reach |
| Installing after a clone | `npm install`, which needs the network | nothing: the files are already there |
| Running it | a bundler (Vite, webpack) turns it into browser code | the browser imports the files; the server bundles them when it starts |
| CommonJS | the bundler converts it on every build | converted to ES modules once, when the package is added |
| Install scripts | `postinstall` runs code on your machine | never run |
| Checking the files | `npm audit` checks versions | `sluurp vendor verify` checks every file against the registry's tarball |
The idea is Deno's: import by name, with no `node_modules` and no install step. The difference is that the files are kept with your app. A clone runs as it is, offline, and what runs in production is byte for byte what you tested.
## Compared with Node, Deno and Bun
Node, Deno and Bun are JavaScript runtimes: you write the server in JavaScript and they run it. Sluurp is the server, written in Rust. Your JavaScript is the app: its pages and components, and the small pieces of server logic it needs.
| | Node, Deno, Bun | Sluurp |
|---|---|---|
| What you write | the whole server: routing, database access, sign-in, uploads | your app; the database, API, sign-in, files, realtime, jobs and admin are built in |
| Server code | runs in V8 or JavaScriptCore, with the runtime's APIs | [server functions](/docs/server-functions), hooks and jobs run in a fresh QuickJS sandbox per call |
| What server code can reach | the file system, the network, `process`, native modules | `ctx`: your data, with its rules; the web's standard globals, `fetch` to public addresses among them; no file system, no `process` |
| TypeScript | Deno and Bun run it; Node strips types | compiled as it's served, with no configuration |
| Packages | npm, and JSR in Deno | npm and JSR, vendored for the browser |
| Deploying | the runtime, your code, `node_modules` and a database | one binary and your app folder |
## Limitations
- **Packages are for the browser.** `sluurp add` vendors code that runs in pages and islands. A package written for Node doesn't work, whether it needs `fs`, `net`, `child_process` or `Buffer`, or is a native addon.
- **Server code isn't Node.** Server functions, hooks and jobs run in QuickJS, with 64 MB and 5 seconds per call. There's no Node API, no file system and no WebAssembly; `fetch` reaches public addresses only. QuickJS interprets JavaScript rather than compiling it, so heavy computation is several times slower than in V8. See [QuickJS: small, safe, fast enough](/docs/server-functions#quickjs-small-safe-fast-enough).
- **No install scripts.** A package that builds something when it's installed (`postinstall`, `node-gyp`) won't have what it built.
- **No `package.json` workflow.** There are no `scripts`, no workspaces and no `npm run`. Sluurp's commands do the work: `serve`, `test`, `static`, `push`.
- **Some packages resist ES modules.** A CommonJS package that loads its code at run time, with `require(variable)`, can't be converted fully. Use its ES module build, or a CDN's `+esm` build.
- **Packages come from the public registries.** npm and JSR by name, and anything else by URL; there's no private registry support yet.
For code the browser shouldn't carry and QuickJS can't run, keep it as a program of its own, in whatever runtime suits it. It can read and write your data through the [REST API](/docs/api) and tell Sluurp what happened with [events](/docs/hooks-and-events#events-from-outside).
## In production
At startup the server bundles each page's modules with rolldown. Bundles are code-split and minified, and include workers and `new URL(…)` assets. The result is cached on disk by content and precompressed with brotli and gzip. In development, your own modules are served unbundled; libraries are pre-bundled (see [Hot reload](/docs/hot-reload)).
## A budget
```json title="budget.json"
{ "first-load-kb": 320, "pages": { "/signup.html": 140 } }
```
`/_sluurp/bundle/report.json` shows how much each page downloads (compressed) before it can run, and flags pages over budget. Load code that only one page needs with `import()` from that page.
# Hot reload
sluurp serve watches the app folder. Save a file and every open page updates in place, keeping its state: counters keep their count, dialogs stay open, inputs keep their text. No setup, no bundler.
```sh title="Terminal"
sluurp serve --public ./app
```
## Swapped in place
The saved module replaces the old one and the page keeps its state. Each case below is covered by the hot lab, a test app that makes every kind of edit and checks state survives:
- **Components:** local state, module-level signals, open dialogs, keyed lists, child components in other files.
- **Plain modules:** `.ts` files imported by components, nested imports, circular imports.
- **Data and assets:** `.json`, `import.meta.glob` (including added/deleted files), `?raw` and `?url` imports, `import.meta.env` from `.env`.
- **Styles:** stylesheets and CSS modules, swapped without re-rendering.
- **Workers:** TypeScript workers, restarted with the new code.
```tsx title="islands/counter.tsx"
import { signal } from "sluurp/reactive";
export default function Counter() {
const count = signal(0);
// Change this label and save: the count stays where it was.
return ;
}
```
## Re-rendered or reloaded
- **Markdown pages, layouts and server routes** are re-rendered and patched into the page, without a reload.
- **Modules with top-level side effects** (not just definitions) trigger a full reload, since running them twice would repeat the effect.
## When something breaks
Compile errors and uncaught exceptions show in the dev overlay with the source line and a link to open it in your editor. Non-fatal problems (a failed request, a `console.error`) are counted in a corner badge instead. Fix and save; the overlay clears itself.
## Pre-bundling and caching
Your own files are served individually so each can be hot-swapped. Libraries aren't: at startup Sluurp pre-bundles every library your app imports (built-ins like `sluurp/reactive` and the UI kit, plus vendored packages), like Vite does.
- **No duplicates.** It's a single build with code splitting, so shared code is loaded once.
- **Only what you use.** Only the names your code imports are exported. Imports used only by lazily loaded screens load with those screens.
- **Immutable caching.** Bundles and your modules are served at content-hashed URLs (`/session.ts?v=3f9a1c07`) and cached forever, so a reload only fetches what changed.
- **Automatic lazy loading.** A component rendered only conditionally (`open() ? : ""`, `ready && `) and never used as a value is fetched the first time it renders, with its exclusive dependencies. You write a normal import; the server rewrites it. Components every page renders stay eager, so nothing pops in late.
Importing something new from a library rebuilds the bundle and reloads the page (about a second). To hot-swap edits to a library itself, serve with `SLUURP_PREBUNDLE=off`.
## Checks on every edit
When a file changes, Sluurp checks it for two mistakes and prints them in the server log and the browser console:
- a component used in JSX that nothing declares (`` for `
## Operations
# Command line
Everything is in one binary, sluurp. sluurp --help lists commands and sluurp <command> --help lists a command's options. This page is an overview.
Every command accepts `--dir `, the data folder (one SQLite file per project). Defaults to `sluurp_data` in the current directory.
## Serving
| Command | What it does |
|---|---|
| `serve` | Start the server: API, admin UI at `/_/`, and the `--public` app |
| `static` | Export the app as static files, islands included; `--base repo` for a subfolder ([Static sites](/docs/static-sites)) |
| `compile` | Build a single binary with one app embedded |
| `test` | Run an app's tests in a headless Chrome, Edge or Chromium, on a fresh database; `--grep`, `--coverage` ([Testing](/docs/testing)) |
`serve` options:
| Option | |
|---|---|
| `--public DIR` | The app at `/`: a folder or [repository URL](/docs/getting-started). `NAME=DIR` mounts another at `/NAME/`; repeatable |
| `--addr HOST:PORT` | Listen address; default `127.0.0.1:8090` (local only) |
| `--no-hot-reload` | Disable file watching, for production |
| `--admin-auto-login` | Sign the admin in as the first superuser without a password, from this machine only. For development; a warning is printed at start. Never use it on a server others can reach |
| `--attach NAME=FILE` | Use an existing SQLite database in place ([Existing SQLite databases](/docs/attach)) |
| `--events [ADDR]` | Accept local events over UDP ([Hooks and events](/docs/hooks-and-events)) |
| `--channel NAME`, `--apps` | Serve a published bundle, or all registered apps by hostname, instead of a folder |
```sh title="Terminal"
sluurp serve --public ./app --public reports=./reports --addr 0.0.0.0:8090
```
## Data
| Command | What it does |
|---|---|
| `migrate APP` | Run an app's [migrations](/docs/migrations); `--plan` previews |
| `schema plan`, `schema apply` | Diff a `schema.json` against the database, or apply it |
| `import FILE` | Import collections and records from an import document |
| `superuser EMAIL PASSWORD` | Create a superuser or reset their password |
| `backup` | Snapshot every project to a dated folder |
| `migrations` | Show Sluurp's internal migrations applied to a project |
| `bench` | Benchmark collections on this machine ([Why SQLite](/docs/why-sqlite)) |
## Packages
| Command | What it does |
|---|---|
| `add SPEC` | Vendor a package into an app: `add three`, `add npm:date-fns@4`, `add jsr:@std/path`, a CDN URL, or `font:inter:400,600` ([Packages](/docs/vendoring)) |
| `remove NAME` | Remove a vendored package |
| `outdated`, `update` | Show and apply newer versions, like `pnpm` |
| `vendor` | List vendored packages; `check` files against hashes, or `verify` against the source |
| `cache` | Show the package cache and bundle locations; `clear` empties them |
These take `--app DIR` for the target app (default: current folder).
## Publishing
| Command | What it does |
|---|---|
| `push DIR` | Store an app as a bundle; `--deploy CHANNEL` also deploys it |
| `bundles` | List bundles and what each channel serves |
| `deploy CHANNEL BUNDLE` | Point a channel at a bundle |
| `promote FROM TO` | Copy one channel's bundle to another, e.g. staging → production |
| `rollback CHANNEL` | Revert a channel to its previous bundle |
| `app` | Connect a git repository to publish on every push |
## Other
| Command | What it does |
|---|---|
| `emit NAME [JSON]` | Send an event to a server running with `--events` |
| `i18n` | List unused keys in an app's `i18n.json` |
# Admin UI
Every Sluurp server has a dashboard at /_/, like Django admin: generated from your schema, zero code. Each collection gets a table to browse, filter and edit, plus screens to operate the server.
## For your data
- **A table per collection** with a filter box (`paid = true && total > 100`), sorting, column picker and paging.
- **A form per record** with the right input per field: date pickers, searchable relation pickers, image preview and upload.
- **History and undo:** every change with who and why, restore any version, deleted records in a recycle bin.
- **Explore and pivot tables,** charts and formulas over a collection, no export needed.
- **Rules** for read and write access, edited next to the collection.
## For the server
Logs, SQL console, backups, scheduled jobs, rate limits, mail, sign-in providers, payments, forms, feeds, translations and storage, each on its own screen. Reorder the Platform items by dragging or with `Alt`+arrow keys. `Ctrl+K` (`⌘K`) jumps to any screen, collection or record.
## Logs
Every request is logged: time, method, path, status, duration, user and IP. Search them with [Kibana's query language (KQL)](https://www.elastic.co/guide/en/kibana/current/kuery-query.html):
```text
status:5xx and path:/api/*
method:POST or took>500
not who:_superusers* and (status:4xx or status:5xx)
time>=2026-09-20 and time`, `>=`, `<`, `<=` compare. `time` accepts a date, a datetime, or `now-15m`, `now-2h`, `now-7d`.
- Combine with `and`, `or`, `not` and parentheses. Adjacent terms are ANDed. A bare word matches anywhere.
The database keeps one week of logs. Older entries are archived to a gzipped file per day (`logs/2026-09-18.jsonl.gz` in the data directory), kept for 90 days. Searching a longer window transparently includes the archives.
## API
The **API** screen lists every endpoint from the project's [OpenAPI document](/docs/api#openapi), grouped by collection. Choose one, fill in its parameters and body (pre-filled from the collection's fields), and send it as yourself. You see the status, the time and the response, and can copy the request as `curl` or download `openapi.json` for a client generator.
## UI kit
The **UI kit** screen is a component workshop, like [Storybook](https://storybook.js.org). It has three modes:
- **Docs**: a page per component, with every prop taken from its source and each example with the code that draws it.
- **Controls**: one component with a field for each prop it takes.
- **Playground**: edit the code and see it drawn as you type, at one width or at phone, tablet and desktop side by side.
Stories use Storybook's [Component Story Format](https://storybook.js.org/docs/api/csf) (CSF 3), so story files work in both. Any `*.stories.tsx` next to a component is picked up:
```tsx title="client/kit/button.stories.tsx"
import { Button } from "./button.js";
export default {
title: "Actions/Button",
component: Button,
args: { children: "Save changes", disabled: false },
argTypes: { variant: { control: "select", options: ["default", "outline", "destructive"] } },
};
export const Primary = {};
export const Destructive = { args: { variant: "destructive", children: "Delete" } };
```
Without `render`, a story draws `component(args)`. Controls are inferred from the args' values; `argTypes` is only needed for a select. `play({ canvasElement, args })` runs after drawing. `parameters.order` places a story within its group when A to Z is not the order to read them in.
One Sluurp extension (Storybook ignores it): `argTypes: { collection: { control: "collection" } }` lets you pick a collection from the current project, and its rows are passed to `render(args, { loaded: { rows } })`. Every story is also drawn in a test.
### Your own components
An app's own story files show up too, under **Custom**, after the kit's. So a separate app can be a library of your own components, including ones built on the kit's:
```tsx title="components/gradient-button.tsx"
import { Button, type ButtonProps } from "sluurp/kit/button.js";
export interface GradientButtonProps extends ButtonProps {
/** Where the gradient starts and ends. */
tone?: "sunset" | "ocean";
}
export function GradientButton({ tone = "sunset", className, ...rest }: GradientButtonProps = {}) {
return Button({ ...rest, className: `bg-linear-to-r text-white ${tone === "sunset" ? "from-amber-400 to-rose-500" : "from-sky-400 to-indigo-600"} ${className ?? ""}` });
}
```
```tsx title="components/gradient-button.stories.tsx"
import { GradientButton } from "./gradient-button.tsx";
export default {
title: "Buttons/Gradient Button",
component: GradientButton,
args: { children: "Book the trip" },
};
export const Sunset = {};
export const Ocean = { args: { tone: "ocean" } };
```
To give it its own icon in the UI kit's list, set `parameters.icon` to an SVG, as markup or an element. Without one, it gets the default grid icon:
```tsx
export default {
title: "Buttons/Gradient Button",
component: GradientButton,
parameters: {
icon: '',
},
};
```
Use `stroke="currentColor"` (or `fill`) so the icon follows the list's colours, light and dark, selected or not.
Its props table lists `tone` and everything it takes from `ButtonProps`. Its examples import it from its own file and open in the Playground like the kit's. The app can be the one at `/` or one mounted beside it (`--public ui=path/to/app`).
## Compared with Django admin
**Sluurp has, Django admin doesn't:**
- Live updates: records changed by others update on your screen.
- Built-in history, restore and recycle bin for every collection.
- SQL console, backups, logs, jobs and rate limits in one place.
- Nothing to register: a collection appears as soon as it exists.
- Collaborative live translation editing.
**Django admin has, Sluurp doesn't (yet):**
- Per-model admin customisation in code: visible columns, form grouping, read-only fields.
- Custom bulk actions on selected rows.
- Inline editing of related records in the parent form.
- Date drill-down (year → month → day) above lists.
The admin UI is built with the same [UI kit](/docs/ui-kit) your apps use.
# Admin extensions
Add your own screens, record cards and actions to the admin UI. Put a file in your app's admin/ folder; what it exports decides where it shows up. There's no manifest to write and nothing to register.
```tsx title="admin/orders.tsx"
import { signal } from "sluurp/reactive";
import { Button } from "sluurp/kit/button.js";
export const title = "Orders";
export const icon = "card"; // one of the admin's icons
export const collections = ["customers"]; // where Block, Action and Bulk show; all when left out
// A screen of its own, under Extensions in the sidebar.
export default ({ api }) => {
const open = signal(0);
api.send("/collections/orders/records?perPage=1&filter=status='open'")
.then((page) => open.set(page.totalItems));
return
{() => `${open()} open orders`}
;
};
// A card at the end of a record.
export const Block = ({ api, record }) =>
Customer since {record.created.slice(0, 4)}
;
// An item in the record's More actions menu, opening a dialog.
export const Action = ({ record, toast, close }) => (
);
// A button on the selection bar when rows are ticked.
export const Bulk = ({ ids, toast, close }) => {
toast("Tagged", `${ids.length} customers`);
close();
return "";
};
```
## What goes where
| Export | Shows up as |
|---|---|
| `default` | a screen, listed under **Extensions** in the sidebar and kept on reload |
| `Block` | a card at the end of a record, titled with `title` |
| `Action` | an item in a record's **More actions** menu, opening a dialog |
| `Bulk` | a button on the selection bar, opening a dialog |
| `title` | the name everywhere (defaults to the file name: `report-cards` → "Report cards") |
| `icon` | the icon in the sidebar and menus |
| `collections` | which collections get `Block`, `Action` and `Bulk` |
A file can export any combination of these.
## What each one receives
Every export gets:
- `api`, the admin's client, signed in as the superuser: `api.send(path, { method, body })`;
- `toast(title, body)`;
- `open(collection, id?)` to go to a collection or one of its records;
- `refresh()` to reload the open collection's records after changing them.
`Block` and `Action` also get `collection` and `record`. `Bulk` gets `collection` and the ticked `ids`. `Action` and `Bulk` get `close()`, which shuts their dialog; after a bulk action the table is read again.
## Good to know
- Files are `.tsx`, `.ts`, `.jsx` or `.js`, compiled like the rest of your app, and they import the [UI kit](/docs/ui-kit) the way your pages do.
- They run in the admin with no sandbox: extensions are your own code, trusted like a hook. They do run in the browser, though, so keep secrets out of them. Call a [server function](/docs/server-functions) for anything secret.
- If an extension fails to load or throws, it shows the error in its own place and the rest of the admin keeps working.
# Deploying
In production, a Sluurp app is one binary, one app folder and one data folder on a machine you control. No database server, no build step.
## In one click
Start from [SluurpHQ/starter](https://github.com/SluurpHQ/starter), a template with a small app and everything a host needs. Each option below keeps your data on a disk of its own, so it survives a redeploy. For commercial use, set your licence as `SLUURP_LICENSE`.
- **Render**: the template's Deploy to Render button, with a 1 GB disk.
- **Railway**: a project from the template, plus a volume at `/data`.
- **Fly.io**: `fly launch`, a volume named `data`, then `fly deploy`.
- **DigitalOcean, Hetzner or any Ubuntu or Debian server**: paste [cloud-init.yaml](https://github.com/SluurpHQ/releases/blob/main/deploy/cloud-init.yaml) as the server's user data when you create it. Fill in your domain, the first admin and your licence first. On DigitalOcean that's Create Droplet, Advanced options; on Hetzner Cloud it's the Cloud config field. Sluurp runs as a service behind Caddy, with HTTPS.
## Docker
```sh title="Terminal"
docker run -d -p 8090:8090 -v sluurp-data:/data -v "$PWD/app:/app" ghcr.io/sluurphq/sluurp
```
The image serves the app in `/app` and keeps its data in `/data`. To ship your app inside it:
```dockerfile title="Dockerfile"
FROM ghcr.io/sluurphq/sluurp
COPY --chown=sluurp app /app
```
## On the server
Install the binary, copy the app, and create an admin:
```sh title="Terminal"
curl -fsSL https://raw.githubusercontent.com/SluurpHQ/releases/main/install.sh | sh
sluurp --dir /srv/sluurp/data superuser you@example.com a-long-password
sluurp --dir /srv/sluurp/data serve --public /srv/sluurp/app --no-hot-reload
```
- `--dir` is the data folder: one SQLite file per project. It's the only folder you need to back up.
- `--no-hot-reload` turns off file watching, which production doesn't need.
- `--public` can also be the app's [repository URL](/docs/getting-started): it's cloned on first start and pulled on each restart.
Sluurp listens on `127.0.0.1:8090` (local only). Use `--addr 0.0.0.0:8090` to accept outside connections, or better, put a reverse proxy in front.
## HTTPS
Sluurp serves plain HTTP; a reverse proxy adds HTTPS. [Caddy](https://caddyserver.com) obtains and renews certificates automatically:
```text title="Caddyfile"
school.example.com {
reverse_proxy 127.0.0.1:8090
}
```
Realtime, sync and cursors use WebSockets, which Caddy proxies out of the box. With nginx, forward the `Upgrade` and `Connection` headers.
## Running as a service
A systemd unit that starts Sluurp at boot and restarts it on failure:
```ini title="/etc/systemd/system/sluurp.service"
[Unit]
Description=Sluurp
After=network.target
[Service]
ExecStart=/home/sluurp/.sluurp/bin/sluurp --dir /srv/sluurp/data serve --public /srv/sluurp/app --no-hot-reload
User=sluurp
Restart=always
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target
```
```sh title="Terminal"
sudo systemctl enable --now sluurp
```
## Storage
Everything Sluurp keeps is in one folder, the data folder: `--dir`, or `/data` in the Docker image.
| | |
|---|---|
| `_system.db`, `default.db`, one `.db` per project | The databases: your records, users, settings, pages, and the files server code keeps. |
| `storage/` | Files people upload, a folder per project. Usually the biggest part. |
| `snapshots/` | Backups, when you make them here. |
| `logs/` | Request logs older than a week, one gzipped file a day. |
| `bundles/` | Production bundles of your app, made at start. They can be made again. |
Backups in the admin copy the databases only. Back up `storage/` (or the `SLUURP_FILES` folder) as well, with your usual tools, or keep files in a bucket, below.
### Files on a disk of their own
Uploads often outgrow the databases many times over. To keep them on another disk or volume, set `SLUURP_FILES` to a folder there:
```sh title="Terminal"
SLUURP_FILES=/mnt/files sluurp --dir /srv/sluurp/data serve --public /srv/sluurp/app
```
With Docker, mount a volume for them. Either mount it where the files already go:
```sh title="Terminal"
docker run -d -p 8090:8090 \
-v sluurp-data:/data \
-v /mnt/big-disk/sluurp:/data/storage \
-v "$PWD/app:/app" ghcr.io/sluurphq/sluurp
```
or mount it anywhere and say where:
```sh title="Terminal"
docker run -d -p 8090:8090 -e SLUURP_FILES=/files \
-v sluurp-data:/data -v sluurp-files:/files \
-v "$PWD/app:/app" ghcr.io/sluurphq/sluurp
```
When you move existing files, stop Sluurp, copy the `storage/` folder to the new place, then start it with the new setting.
The admin shows where the files are, and how much room is left on that disk, under Settings, Storage. The same screen sets how much the project, and each person, may upload; see [Files](/docs/files#quotas).
### Files in a bucket
With more than one server, or to keep files off the server, add an S3-compatible bucket: AWS S3, Cloudflare R2, Backblaze B2 or MinIO. Each upload is copied to the bucket, and a server that lacks a file fetches it from there. See [Files](/docs/files#storage) for the settings.
## Many connections
One Sluurp handles tens of thousands of open connections: pages, API calls, live queries over WebSockets. Each one costs a few kilobytes and no thread. The operating system has limits of its own, though, and on Linux the first one is 1,024 open files per process. Every connection is an open file.
Sluurp raises its own limit as far as the system lets it when it starts. The `LimitNOFILE` line in the unit above lets it go that far. The server install script also sets these, and you can set them yourself on any Linux server:
```ini title="/etc/sysctl.d/90-sluurp.conf"
net.core.somaxconn = 8192
net.ipv4.tcp_max_syn_backlog = 8192
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
fs.file-max = 2097152
```
```sh title="Terminal"
sudo sysctl --system
```
They make room for a burst of new connections, give requests your functions make to other services all the local ports, and reuse closed sockets sooner. With Docker, pass `--ulimit nofile=1048576:1048576` to `docker run`.
If Caddy or nginx sits in front, it holds a connection to each client and one to Sluurp, so give it the same limit.
## Backups
`sluurp backup` writes a dated snapshot of every project to `snapshots/` in the data folder. **Backups** in the admin UI schedules them and sets how many to keep. Copy snapshots off the machine too: a backup on the same disk won't survive that disk.
## Upgrades
Replace the binary and restart. On startup Sluurp upgrades its own tables and runs any pending app [migrations](/docs/migrations). Back up first; `sluurp migrate APP --plan` shows what will run.
## No server at all
A site that doesn't need the API, sync or server functions (a landing page, docs) can be exported as static files with [`sluurp static`](/docs/static-sites) and hosted on GitHub Pages or any file host.
# Static sites
If a site doesn't need a server (landing pages and docs usually don't), Sluurp can export it as static files. Pages are pre-rendered and islands still hydrate in the browser. This site is published that way.
```sh title="Terminal"
sluurp static --public website --public todos=examples/todos/app --out dist
```
It takes `--public` just like `serve` (a folder or a [repository URL](/docs/getting-started)), runs the app locally and crawls it. Starting from `/` and each mounted app, it follows every link, script, stylesheet, island, import-map entry, module import and font, and writes out:
- pages as `path/index.html`, so `/docs/sync` becomes `docs/sync/index.html`;
- everything else at its own path, with TypeScript modules compiled and saved as `.js` so any host serves them with the right type.
## What works
Everything rendered on the server comes through unchanged: pages, Markdown docs, highlighted code, the UI kit. Client-only islands keep working: charts, forms, a Page editor, the chart playground.
Anything that needs a server doesn't: the API, sync, `"use server"` functions and live cursors. Islands that call the server still load, but get no response. The Todos Collab and Sheet examples on this site are like that.
## GitHub Pages
The output includes a `.nojekyll` file so GitHub publishes `/_/` (theme and styles) instead of Jekyll dropping it for starting with an underscore. A workflow to publish on every push:
```yaml title=".github/workflows/pages.yml"
name: Pages
on:
push:
branches: [master]
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
environment: github-pages
steps:
- uses: actions/checkout@v4
- run: curl -fsSL https://raw.githubusercontent.com/SluurpHQ/releases/main/install.sh | sh
- run: ~/.sluurp/bin/sluurp static --public website --out dist
- uses: actions/upload-pages-artifact@v3
with:
path: dist
- uses: actions/deploy-pages@v4
```
## Served from a folder
A project's GitHub Pages site lives under the repository name: `username.github.io/repo/`. Pass it as `--base`:
```sh title="Terminal"
sluurp static --public website --out dist --base repo
```
Root-relative URLs in pages and stylesheets (`/docs`, `/_/theme.css`) are rewritten under `/repo/`, and each page's import map is adjusted too, so module imports and named islands resolve under the base without rewriting any module. On a custom domain or a `username.github.io` repository the site is at the root and needs no `--base`.
Root paths built from strings in your own code, like `link.href = "/theme.css"`, aren't rewritten. Resolve them relative to the module instead: `new URL("../theme.css", import.meta.url)`.
# Security
Common web attacks, as listed by MDN, and how Sluurp handles each. Most need nothing from you.
## Cross-site scripting (XSS)
Text is never parsed as markup. JSX and `html` templates insert strings as text, so a name like `` renders literally. `javascript:` URLs in `href`, `src`, `action` etc. are neutralised however they're encoded. Strings can't be event handlers: `onclick="…"` is rejected. The same applies to server rendering.
## Cross-site request forgery (CSRF)
Requests are authenticated by the `Authorization` header, never by cookies. Another site can make the browser send a request but can't add the header, so it arrives unauthenticated.
## Clickjacking
HTML pages can't be framed by other sites: all are sent with `X-Frame-Options: SAMEORIGIN` and `frame-ancestors 'self'`. A page meant for embedding can set its own header, which takes precedence.
## Insecure direct object references (IDOR)
Ids are random, but that's not the protection. Every read and write goes through the collection's [rules](/docs/rules), enforced in SQL down to individual fields, so knowing an id grants nothing.
## Manipulator in the middle (MITM)
Serve behind TLS; see [Deploying](/docs/deploying). Once a browser connects over HTTPS, `Strict-Transport-Security` pins it there for a year.
## Server-side request forgery (SSRF)
Outbound requests made on a user's behalf (a function's `fetch`, link previews) can only reach public addresses. Loopback, private ranges and cloud metadata endpoints are blocked, every redirect is re-checked, and the check applies to the resolved IP actually connected to, so DNS rebinding doesn't bypass it. Superusers can allow-list extra hosts for functions.
## Prototype pollution
The server parses JSON into Rust types, which have no prototype. In the browser, island props and RPC results are parsed with devalue, which rejects `__proto__` keys.
## Cross-site leaks
Cross-site referrers are trimmed to the origin (`strict-origin-when-cross-origin`), and MIME sniffing is disabled (`nosniff`).
## Supply chain
Nothing is installed at runtime; the server is a single binary. Packages are [vendored](/docs/vendoring) byte for byte into `vendor/` and served from there, not a CDN, so what runs is exactly what you reviewed and committed.
## Phishing and subdomain takeover
These are about people and DNS, not code. Offer two-factor sign-in (see [Sign-in](/docs/auth)), and delete DNS records when their target server goes away.
## Development
# Testing
Put tests in your app's tests/ folder and Sluurp runs them. You can run them in the admin, where you watch each one drive your app, or headless with sluurp test for CI. There's no Node and no Playwright to install; Sluurp uses the Chrome or Edge already on your machine.
## A test
```tsx title="tests/notes.test.tsx"
import { expect, test } from "sluurp/test";
test("a note is added", async ({ page }) => {
await page.goto("/notes");
await page.getByLabel("Title").fill("Milk");
await page.getByRole("button", { name: "Add" }).click();
await expect(page.getByText("Milk")).toBeVisible();
});
test("only a signed-in person may list notes", async ({ request, admin }) => {
expect((await request.get("/api/collections/notes/records")).status).toBe(403);
expect((await admin.get("/api/collections/notes/records")).ok).toBe(true);
});
```
Each test gets three things:
- `page`: your app in a fresh frame, driven as a person would. It has `goto`, `reload` and `locator`, plus `getByRole`, `getByText`, `getByLabel`, `getByPlaceholder` and `getByTestId`. What those return has `click`, `fill`, `press`, `check`, `hover`, `textContent` and more.
- `request`: your API, as someone who isn't signed in.
- `admin`: your API as a superuser, for setting data up.
Every action waits until its element is on the page and visible. Inside an island, it also waits until the island has woken up.
`expect` on a locator or the page retries until the check holds, for up to 5 seconds, as in Playwright. The checks are `toBeVisible`, `toBeHidden`, `toHaveText`, `toContainText`, `toHaveCount`, `toHaveValue`, `toHaveAttribute`, `toBeChecked`, `toBeEnabled`, `toBeDisabled`, `toHaveURL` and `toHaveTitle`. On any other value, `expect` checks once. `expect.poll(() => value)` asks again until the check holds.
Group tests with `test.describe`, set things up with `test.beforeEach`, `test.afterEach`, `test.beforeAll` and `test.afterAll`, and narrow a run with `test.only` and `test.skip`.
## In the admin
**Tests** in the admin's sidebar lists your test files and runs them. Each test's frame is shown while it runs, and you can choose a result to see its steps and what failed. Turn on **Auto-run** and a test file you save runs again at once, without the page reloading. A change to the app itself runs every test again.
## Headless
```sh title="Terminal"
sluurp test --public app
```
Your app is served on a fresh database, and its tests run in a headless Chrome, Edge or Chromium. Results are printed as they come, and a failed test makes the command exit with code 1, for CI. Use `--grep` to run only the tests whose name contains a word, and `--browser-path` or `SLUURP_BROWSER` to name the browser.
## Coverage
```sh title="Terminal"
sluurp test --public app --coverage
```
This reports which lines of your app's code the tests ran in the browser, file by file. It also writes `coverage/lcov.info`, which coverage services and editors can read. Pages drawn on the server run on the server, so they aren't measured in the browser.
## Components on their own
A component's stories are tests too: each story's `play` runs in the UI kit's Test tab. See [UI kit](/docs/ui-kit).
# Dev mode and production
It's the same binary and the same app folder. While you develop, Sluurp watches your files and helps you change them; in production it serves them as fast as it can and watches nothing.
## Choosing
```sh title="Terminal"
sluurp serve --public app # developing: hot reload on
sluurp serve --public app --no-hot-reload # production
```
Hot reload is what makes it dev mode: with it on, the server watches your app and the pages listen for changes. [Deploying](/docs/deploying) always uses `--no-hot-reload`, as does the Docker image.
## While you develop
- **Hot reload**: a saved component is swapped in place, keeping what's on screen. A page drawn on the server is drawn again in place, and CSS swaps without a reload. See [Hot reload](/docs/hot-reload).
- **Errors where you are**: a mistake in a file shows over the page, at its line, and goes away when you fix it.
- **Source as written**: modules come with their source maps, so the browser's tools show your `.tsx`, not what it compiled to.
- **The UI kit's tools**: the Source tab and the agent can write your components, and the kit warns you about components that leave things running.
- **Tests with Auto-run**: saving a test runs it again in the admin's [Tests](/docs/testing) screen.
- **Signing in by itself**, if you ask for it: `--admin-auto-login` signs the admin in as the first superuser, from your own machine only. It's for development, and it prints a warning when it starts. Never use it on a server others can reach.
None of this reaches production: without hot reload there are no markers in your pages, nothing is watched, and the tools that write files refuse.
## In production
- **Nothing is watched**, and no reload script is sent.
- **Libraries are bundled** when the server starts, and your modules are served at addresses named after their content, so browsers keep them for good and fetch only what changed.
- **Pages load only what they show**: a component drawn only sometimes is fetched the first time it's drawn. See [Packages, bundles, budgets](/docs/vendoring).
- **The release binary is minified**: modules are sent without comments or spaces.
- **The licence**: commercial use needs `SLUURP_LICENSE` set, or a key added in the admin under Settings, License. Everything works the same with or without one. See [Business licence](/business).
## Tests, either way
`sluurp test` starts your app on a fresh database, runs its tests in a headless browser, and stops. Run it on your machine or in CI. See [Testing](/docs/testing).
## Pages
# Docs
Sluurp is a backend and a way of building on it. Start with [Getting started](/docs/getting-started), or pick from the list.
Found a bug, or missing something? [Report a bug](https://github.com/SluurpHQ/releases/issues/new?template=bug.yml) or [suggest a feature](https://github.com/SluurpHQ/releases/issues/new?template=idea.yml) on GitHub. You can also [see what others have reported](https://github.com/SluurpHQ/releases/issues).
These pages are Markdown files in `website/routes/docs/`. The sidebar is built from their frontmatter, so a new page is a new file.
# Examples
Each example is a folder, run with just sluurp serve --public. The schema is applied on startup.
`--public DIR` serves a folder as the app at `/` (pages, routes, islands, schema, hooks, agents). `NAME=DIR` mounts another app at `/NAME/`, so one server can run several.
## This site
`website/` is this site. The landing page is a server-rendered `.tsx` route; the docs are Markdown, with the sidebar generated from frontmatter by a folder layout. Code is highlighted server-side, and JavaScript only loads for interactive islands: the [chart playground](/docs/charts), the [UI kit](/docs/ui-kit), the [editable page](/docs/pages). It can be published with [`sluurp static`](/docs/static-sites).
## Todos Collab
`examples/todos/app` is a multiplayer to-do list with live cursors, in two files. The route declares the data model with `export const schema` and renders one island, `islands/todos.tsx`, which keeps the list with [Sync](/docs/sync) and shows the cursors with `sluurp/cursors`.
This is the actual island, not a screenshot. Open the page in a second window to see edits and cursors sync.
## Sheet
`examples/sheet/app` is a multiplayer spreadsheet in one file, built on the kit's [`Spreadsheet`](/docs/ui-kit#spreadsheet). Each cell is a row in a collection, synced to every open browser with [Sync](/docs/sync). Changing a cell or a formula recalculates only its dependents. You see other people's pointers and selections, and the cell they're editing, in their colour.
Open this page in two windows and edit a cell:
Running it locally, add `?name=Ada` to each window's URL to tell the pointers apart.
## Server components
`examples/server-components/app` is a server-rendered `.tsx` page using the kit: an `async` component, a `"use server"` function called directly, and two islands. One calls the function from the browser; the other hydrates when scrolled into view.
## A wiki
`examples/wiki/app` is a full wiki built on [Pages](/docs/pages) in one file: nested pages, history, link graph, books and PDF export. It only adds sign-in, translations and a header.
## Components
`examples/components` is the UI kit's test bench: galleries of every component, the JSX runtime, TypeScript served as written, and renderer benchmarks. The kit's browser tests run against it.
# License
Sluurp is free for non-commercial use. Commercial use needs a business licence, one per server. Sluurp ships as binaries only; its source is not published.
Sites and apps built with Sluurp. We pick the ones that look and work best, so a place here is earned.
## Built with Sluurp
Nothing here yet. Yours could be the first.
## Add yours
Open a [showcase issue](https://github.com/SluurpHQ/releases/issues/new?template=showcase.yml) on Sluurp's public repository, with your site's name, its address, a sentence on what it is, and a screenshot. We add the ones that look and work best here and to the [wiki's Showcase](https://github.com/SluurpHQ/releases/wiki/Showcase).
# Terms of sale
These terms apply when you buy a Sluurp business licence. What a licence lets you do is in the Sluurp License; these terms cover the purchase itself.
Last updated: 29 September 2026.
## 1. Who you buy from
You buy from the Sluurp authors ("we", "us"). You are the person or business named on the order ("you"). By placing an order you accept these terms and the Sluurp License. If you buy for a business, you confirm that you may act for it.
Business licences are sold to businesses and professionals. Sluurp is free for non-commercial use, so a private person using it for themselves needs no licence.
## 2. What you buy
A business licence is a key that allows commercial use of Sluurp on one server. The first licence costs $499. Each licence for another server costs $99, and extends a first licence held by the same business. See [Business licence](/business) for what's included.
A licence is paid once and doesn't expire. It covers every version of Sluurp released while you hold it, including new versions, for as long as Sluurp is offered under these terms. We may change what later versions include, or their prices, for new purchases; a licence you already hold keeps working.
## 3. Prices and taxes
Prices are in US dollars and don't include taxes. Where the law requires it, value added tax, sales tax or a similar tax is added at checkout, based on where you are. A business with a valid VAT or tax number may be charged no tax where the reverse-charge rules apply; you are then responsible for declaring it.
Prices may change at any time. The price you pay is the one shown when you place the order.
## 4. Payment
Payment is taken in full when you order, by the methods offered at checkout. Card and bank details go straight to our payment provider; we never see or store them. Your licence key is sent once the payment has cleared, usually within minutes.
## 5. Invoices
Each purchase comes with an invoice, sent by email to the address on the order. It shows the business name, address and tax number you give at checkout, and any tax charged. If a detail is wrong, tell us within 30 days and we'll issue a corrected invoice.
## 6. Refunds
If Sluurp doesn't work for you, ask for a refund within 30 days of the purchase and we'll give you your money back in full, with no questions asked. The refund goes to the method you paid with, usually within 10 working days. Once a licence is refunded, its key stops being valid, and any server using it commercially needs another licence.
After 30 days, purchases aren't refundable, except where the law says otherwise or where we can't deliver what you bought.
## 7. Support and updates
Updates are published at [SluurpHQ/releases](https://github.com/SluurpHQ/releases). Report bugs and ask for features in its [issues](https://github.com/SluurpHQ/releases/issues). Licence holders get answers about their licence and purchases by email. Sluurp is in alpha: we fix what we can, but we don't promise a response time or that a particular problem will be fixed.
## 8. Liability
Sluurp is provided as described in the [Sluurp License](/license), without warranty. As far as the law allows, our total liability for anything to do with a purchase is limited to what you paid for it. We aren't liable for lost profits, lost data or indirect damages. Nothing in these terms limits liability that the law says can't be limited.
## 9. Ending a licence
A licence ends if you break the Sluurp License or these terms, for example by passing a key on to another business. It also ends if a payment is reversed or charged back. When it ends, stop using Sluurp commercially on the servers it covered. No refund is due for a licence that ends because the terms were broken.
## 10. Changes to these terms
We may update these terms. The version in force when you buy applies to that purchase. The date at the top says when they last changed.
## 11. Law and disputes
These terms are governed by the law of the country where the Sluurp authors are established. Disputes go to its courts, unless the law of your country gives you the right to go to yours.
## 12. Contact
For questions about a purchase, an invoice or a refund, reply to the email your licence came with.