Your schema is the API.
Zonai is a batteries-included backend framework for Dart. Define your tables, write your rules, and get a complete REST API — auth, live query streams, file uploads, email, and cron — compiled into a single binary you host yourself.
macOS · Linux · Windows · one self-extracting binary · all downloads
final class TaskTable extends Table<Task> {
TaskTable(super.$)
: id = $.id('id', (s) => s.id,
fromString: TasksId.new, generate: TasksId.generate),
title = $.text('title', (s) => s.title),
isComplete = $.boolean('is_complete', (s) => s.isComplete),
createdAt = $.createdAt('created_at', (s) => s.createdAt),
updatedAt = $.updatedAt('updated_at', (s) => s.updatedAt);
final IdColumn<TasksId> id;
final TextColumn title;
final BoolColumn isComplete;
final CreatedAtColumn createdAt;
final UpdatedAtColumn updatedAt;
}
final tasks = table('tasks', TaskTable.new);
-
POST
/dbcreate a row -
GET
/dbread one -
GET
/db/listquery many -
GET
/db/countcount matches -
PATCH
/dbupdate -
DELETE
/dbdelete -
GET
/db/streamlive row -
GET
/db/stream/listlive list -
GET
/db/stream/countlive count
Stop polling.
Every table gets three streaming endpoints alongside the ordinary reads. The server holds the connection open and pushes a new payload whenever the underlying query result changes. No WebSocket setup, no pub/sub broker, no separate "realtime" product to bolt on.
final sub = client.db.listen
.list(
body: StreamListBody(
table: 'tasks',
where: Eq('isComplete', false),
limit: 50,
),
fromJson: (row) => row,
)
.listen((tasks) {
// Fires on connect, then again on every insert,
// update, or delete that changes this result set.
setState(() => _tasks = tasks);
});
await sub.cancel();
-
Ship the migration
tk_9f2a -
Review auth rules
tk_4c81 -
Wire up SMTP
tk_7b30
Same rules, same limits
Streams reuse canView / canList / canCount and the matching rate-limit buckets. There is no separate canStream to forget about.
Three shapes
stream-one for a single row, stream-list for a result set, stream-count for a running total. Pick the one your UI actually binds to.
Plain HTTP underneath
GET /db/stream*, JSON in a ?body= query param, events until you cancel. Reachable from anything that speaks HTTP, not just Dart.
A control room you did not build.
Every Zonai server serves an admin dashboard at /_ — a data browser, live metrics, cron status and the full auth flow. It is compiled into the same binary as your API, so there is nothing extra to deploy, host, or keep in sync.
-
tk_9f2aShip the migrationfalse -
tk_4c81Review auth rulesfalse -
tk_7b30Wire up SMTPtrue
Browse and edit every table
Your schema rendered as a data grid — inline edit, row detail, search and filter (including datetime ranges), foreign-key pickers, and photo columns that show the actual image.
Watch the traffic
Requests and errors over the last 24 hours, active sessions, p95 response time, and per-table row counts. Admin traffic can be excluded so your own clicking does not skew it.
Keep an eye on jobs
Which cron jobs exist, which are running right now, and the errors coming up most often — without opening a terminal or tailing a log.
Exercise the auth flows
Sign-in, one-time passcodes, magic links, password reset and email verification are all real screens, so you can try the flow you just configured.
Built with Jaspr, same as this page. Metrics come from GET /dashboard/metrics, so anything the dashboard shows you can also pull yourself.
The parts you would have written anyway.
Zonai is opinionated so the boring half of a backend is already decided. Everything below is part of the framework, not a plugin you go shopping for.
A REST API per table
Create, read, list, count, update, delete and three live-stream routes, handled straight from your schema. No handler code and no generation step.
Authentication included
Password sign-up/sign-in, one-time passcodes, and magic links — each a single trait mixed into an auth table. Sessions, refresh, and logout come with it.
DocsRules before SQL
Table and row rules are plain Dart returning true or false. They run ahead of the query, so a denial costs a 403 and nothing else.
DocsLive query streams
GET /db/stream, /db/stream/list and /db/stream/count push new payloads as SQLite changes. In Dart, that is client.db.listen.
DocsBuilt-in admin dashboard
Served at /_ from the same binary: browse and edit every table, watch requests and errors, check cron status, and run the auth flows. Nothing extra to deploy.
Generated Dart client
zonai_client wraps auth, admin auth, db, photos, and email so your Flutter app never hand-rolls an HTTP call or a JSON map.
DocsTransactional email
SMTP-backed HTML templates with Mustache variables, sent from lifecycle hooks. Built-in templates for the auth flows, custom ones for everything else.
Push notifications
Send from a lifecycle hook to a recipient set named by a query, not a list of tokens. The fan-out pages, checkpoints, resumes after a restart, and clears dead tokens — FCM for Android, APNs direct for iOS.
DocsScheduled jobs
Cron-syntax jobs compiled into their own worker, with the full database API and catch-up logic for runs missed while the server was down.
Per-IP rate limiting
A policy class per table and operation, with dedicated buckets for the auth routes and trusted-proxy handling for real client IPs.
One binary to deploy
./zonai build links your project into build/zonai. Cross-compile it, copy it to a host, run it. SQLite is bundled; nothing else is required.
DocsCompiled Dart, all the way down.
There is no interpreter on the request path. Your operations and rules are linked into the server binary; config, extensions, rate limits, and crons compile into separate workers. Every request walks the same ordered pipeline.
Denied requests never touch the database
Rules run before any SQL is executed. If a rule returns false the request is rejected immediately, with zero database access and nothing to roll back.
One binary to ship
./zonai build produces build/zonai with your project linked in for the CRUD hot path. Copy it to a server and run it — no runtime dependencies, no Dart SDK on the box.
Four files and a CLI.
A Zonai project is mostly schemas and rules. Everything else — routing, serialization, session handling, migrations — is the framework’s problem.
TaskTableRules main() => TaskTableRules();
final class TaskTableRules extends TableRules<TaskTable, Task> {
TaskTableRules() : super(tasks);
@override
Future<bool> canList(Jwt? jwt) async => true;
@override
Future<bool> canView(Jwt? jwt) async => true;
@override
Future<bool> canCreate(Jwt? jwt) async => jwt != null;
@override
Future<bool> canUpdate(Jwt? jwt) async => jwt != null;
@override
Future<bool> canDelete(Jwt? jwt) async => jwt?.admin.isAdmin ?? false;
}
// Anything you do not override defaults to false. Deny by default.
Rules are evaluated before any SQL runs. Every method you do not override denies by default, so a forgotten rule fails closed rather than leaking a table.
What Zonai is not.
Every framework has a shape, and pretending otherwise wastes your afternoon. Here is where Zonai stops, so you can decide before you install anything.
Not a full application framework
Zonai is an API server. It renders no HTML and has no view layer. You talk to it from Flutter or Dart with zonai_client, or over plain HTTP from anything else.
Not a managed cloud service
There is no dashboard to sign up for and no per-seat bill. You host the binary yourself, anywhere that runs Linux, macOS, or Windows.
Not a general-purpose ORM
It is opinionated about how APIs are shaped and it uses SQLite. If you need arbitrary joins across Postgres, this is the wrong tool and that is fine.
Not poll-only for live UI
The live path is /db/stream* and client.db.listen. If you find yourself writing a Timer.periodic against a Zonai backend, you have taken a wrong turn.
Every platform, one binary.
The CLI runs from your project root. Grab the universal build and it works out which OS and architecture you are on by itself; or take the exact one you need.
macOS + Linux
Universal — detects your OS and architecture
One self-extracting file containing macOS arm64, macOS x64, Linux x64 and Linux arm64. It caches the right build on first run. Not a Windows executable — Windows takes the zip below.
All builds
- macOSApple Silicon (arm64)11.8 MiB
- macOSIntel (x64)12.5 MiB
- Linuxx6411.9 MiB
- Linuxarm6411.5 MiB
- Windowsx6412.0 MiB
Already installed? ./zonai version check tells you if there is a newer build, and
./zonai version update replaces the binary in place.
Running in a couple of minutes.
Download the binary, define a table, serve. No global install, no Docker, no account. The long version, with the full schema and rules, is in the docs.
-
1
Drop in the binary
A Zonai app is an ordinary Dart package. Add the schema library, then put the zonai executable in the project root — it is not a pub dependency, and it resolves your project relative to where it sits.
$ dart create my_app && cd my_app $ dart pub add zonai_schema $ curl -fsSL https://github.com/mrgnhnt96/zonai/releases/download/v0.9.1/zonai -o zonai && chmod +x zonai -
2
Define tables and start dev
Write your tables under lib/src/schemas/, then start the dev server. It watches worker sources, recompiles them, and gives you a TUI dashboard.
$ ./zonai dev # no zonai.yaml yet? dev walks you through creating one -
3
Migrate and serve
Generate a migration from your schema, apply it, and the REST API — including the stream routes — is live.
$ ./zonai db migrate generate -n init $ ./zonai db migrate apply $ ./zonai serve
Your assistant can read the manual too
A curated index for LLMs lives at llms.txt. Inside a project,
./zonai ai writes framework reference sheets into the repo for Claude Code, Cursor, Copilot, Windsurf, and Cline — so every developer and every agent shares the same context about how your backend is built.
$ ./zonai ai all # every supported tool
$ ./zonai ai claude # CLAUDE.md
$ ./zonai ai cursor # .cursor/rules/zonai-*.mdc
Write the backend in the language you already ship.
Zonai is open source under the MIT license, versioned, and used in production by the people who build it. Clone it, read it, break it.