Getting Started
Zerotal is a full-stack framework for Bun. This guide walks you from a blank machine to a running app with a route, a model, and a service provider.
Requirements
- Bun ≥ 1.3.14 — install
- A database, unless you scaffold the Minimal template. Every other template
is configured for SQLite, which needs nothing installed — PostgreSQL and
MySQL are supported and are a
DATABASE_URLaway.
One project, one Bun
engines.bun is a floor, not a lock, and nothing in npm enforces it. A project can end up
with two runtimes in it without anyone choosing that: the shell's bun, and a different one
in node_modules/bun, put there by a transitive peer dependency nobody declared. The work
then splits between them — the server served by one, the suite run by the other — and nothing
says so.
That is expensive because the difference is real but narrow. The SQLite bindings, node:
compatibility and the test runner itself all change between releases, so a couple of
assertions happen to be runtime-sensitive and the rest are not. When those two fail you go
looking for a bug in the code they touch. And a suite that passes is not evidence either: it
only means no test happened to stand on a difference.
So zt refuses to run when the two disagree:
Two Bun runtimes are in play. This process is Bun 1.3.14, but the project
installs Bun 1.4.0 (/srv/app/node_modules/bun/package.json).
This is not a pin. The version to agree on is whichever one the project installed, so
bun update bun moves it and nothing needs editing — what is enforced is that there is only
one. Fix it either way:
bun update bun # move the installed one to match your shell
node_modules/.bin/bun # or run everything through the installed one
Most projects never see this, because most have no bun in node_modules to disagree with.
To boot anyway — mid-upgrade, say — set ZT_ALLOW_RUNTIME_MISMATCH=1, which downgrades the
refusal to a warning. The refusal is a RuntimeMismatchError, which carries both versions in
its context for anything scripting around it.
zt test spawns the binary running it, not whatever bun resolves to on PATH, so the
suite runs on the runtime the guard just checked.
Create a new project
# in your project root's parent directory
bun create zerotal my-app # or: bunx create-zerotal my-app
cd my-app
The scaffolder prompts for a project name and a template, then generates a ready-to-run project and installs dependencies. The database prompt only appears for the API template:
| Prompt | Options |
|---|---|
| Project name | Defaults to my-zerotal-app |
| Template | API, Admin, Flow, React, Vue, or Minimal (see below) |
| Database | SQLite (zero setup), PostgreSQL, or MySQL — asked for API |
It writes a fresh APP_KEY into the generated .env.example for you (no manual
generation needed). For Postgres/MySQL it reminds you to set DATABASE_URL
before migrating.
Without a terminal — CI, scripts, agents
Every prompt has a flag, and the scaffolder never asks a question when there is no TTY to answer it:
bunx create-zerotal my-app --template=api --db=postgres --no-install
bunx create-zerotal my-app --yes # take the defaults for anything unset
bunx create-zerotal --help
| Flag | Values / description |
|---|---|
-t, --template <name> | api, admin, flow, react, vue, minimal |
--db <name> | sqlite, postgres, mysql — API template only |
-y, --yes | Take defaults for anything not given; never prompt |
--no-install | Skip bun install |
-h, --help | Usage |
-v, --version | The scaffolder's own version |
An answer that is missing and cannot be asked for is an error naming the flag
that would supply it, and the exit code is non-zero — so a pipeline fails where
it used to wait. A failed bun install also exits non-zero when there is no
terminal: a half-built project that reports success is worse than one that stops.
Which template should I use?
- API — JSON REST API with core, ORM, auth, validation, and testing. The default for a backend service.
- Admin — an admin panel: resources, sign-in, dashboard widgets, and
seeded demo data.
bun zt db:seedgives you an account to log in with. - Flow — server-driven reactive UI (Flow pages, top nav, Tailwind).
- React — Inertia + React SPA with file-based routes and Tailwind.
- Vue — Inertia + Vue SPA with file-based routes and Tailwind.
- Minimal — a single page with JSX views and Tailwind on the bare framework.
Note — every template except Minimal ships a database config and migrations, configured for SQLite. Only the API template asks which database you want; the rest start on SQLite and switch when you set
DATABASE_URL.
Project structure
The API template generates the following. Other templates vary (SPA
templates add a resources/ frontend, for example):
# my-app/ (API template)
my-app/
├── app/
│ ├── controllers/ # HTTP controllers
│ ├── middleware/ # Custom middleware
│ └── models/ # ORM models (auto-discovered)
├── bootstrap/
│ └── app.ts # Builds the Application (providers + routing)
├── config/ # Config files (database, session, queue…) — auto-loaded
├── database/
│ └── migrations/ # Database migrations
├── routes/
│ └── index.ts # Route definitions
├── tests/ # Test suites
├── zt.ts # Managed CLI entry point — do not edit
└── .env # Environment variables (git-ignored)
zt.ts is the entry point for everything — bun zt serve, migrate,
test, and every make:* generator run through it. It boots bootstrap/app.ts,
which defines your Application. Anything under app/models/,
app/providers/, and config/ is auto-discovered at boot —
no manual registration.
Tip —
app/providers/is auto-discovered when present. The API template doesn't generate one; runbun zt make:provider …and the framework picks it up at boot.
Environment setup
The scaffolder generates .env.example with a ready-made APP_KEY. Copy it to
.env:
# in your project root
cp .env.example .env
# .env
APP_ENV=development
APP_KEY=base64:… # pre-generated; rotate with `bun zt key:generate`
DATABASE_URL=./database/db.sqlite
SESSION_SECRET=change-me-in-production
The database is configured with a single DATABASE_URL — SQLite uses a file
path, Postgres/MySQL use a connection string:
# .env — alternative DATABASE_URL forms
# DATABASE_URL=postgres://user:pass@localhost:5432/my_app
# DATABASE_URL=mysql://root@localhost:3306/my_app
Danger — Change
SESSION_SECRETbefore deploying. The placeholder value is not safe for production.
See Configuration for the full config system.
Run migrations
# in your project root
bun zt migrate
Start the dev server
# in your project root
bun zt dev
Every command in these docs is written bun zt <command>. The scaffold also adds a
dev script, so bun run dev does the same thing — reach for whichever you prefer.
Your app is now running at http://localhost:3000 with hot reload enabled —
dev watches your files and hot-swaps routes without dropping connections. The
generated package.json also gives you bun start (production serve) and
bun test.
Your first route
Open routes/index.ts and add:
// routes/index.ts
import { Router } from "zerotal";
import { PostController } from "../app/controllers/PostController.ts";
Router.get("/posts", PostController, "index");
Router.post("/posts", PostController, "store");
Create the controller:
// app/controllers/PostController.ts
import type { HttpContext } from "zerotal";
export class PostController {
async index(ctx: HttpContext) {
return ctx.json({ posts: [] });
}
async store(ctx: HttpContext) {
const body = await ctx.request.json();
return ctx.json({ created: body }, 201);
}
}
Note — Every controller action receives the request HttpContext directly. Read route params and resolved model bindings from
ctx.params; type them with the generic, e.g.ctx: HttpContext<{ post: Post }>.
Your first model
# in your project root
bun zt make:model Post --migration
This generates app/models/Post.ts and a matching migration. Models in
app/models/ are auto-discovered at boot — no manual registration, and the table
name is derived by convention (Post → posts), so @table is optional. The
same applies to observers, policies, and event listeners; see
Conventions.
Open the migration and define your columns:
// database/migrations/xxxx_create_posts_table.ts
import { Migration, Schema } from "@zerotal/orm";
export default class CreatePostsTable extends Migration {
async up(): Promise<void> {
await Schema.create("posts", (table) => {
table.increments("id");
table.string("title");
table.text("body");
table.string("slug").unique();
table.timestamps();
});
}
async down(): Promise<void> {
await Schema.drop("posts");
}
}
Run it:
# in your project root
bun zt migrate
Your model is ready to use:
// in a controller or service
import { Post } from "../models/Post.ts";
const posts = await Post.query().latest().limit(10).get();
const post = await Post.find(1);
const fresh = await Post.create({ title: "Hello", body: "...", slug: "hello" });
Adding a service provider
Providers are where you wire up your own bindings. Scaffold one:
# in your project root
bun zt make:provider AppServiceProvider
// app/providers/AppServiceProvider.ts
import { ServiceProvider } from "zerotal";
import { PaymentGateway } from "../services/PaymentGateway.ts";
import { StripeGateway } from "../services/StripeGateway.ts";
export class AppServiceProvider extends ServiceProvider {
onRegister(): void {
this.app.container.singleton(
PaymentGateway,
() => new StripeGateway({ key: Bun.env.STRIPE_KEY! }),
);
}
}
Anything under app/providers/ is auto-discovered and registered at boot — no
manual wiring needed. You only touch bootstrap/app.ts when you need to control
ordering (e.g. one provider must register before another). See
Service Providers and Conventions.
Available commands
Everything runs through zt.ts. The most common:
# in your project root
bun zt make:model Name --migration # model + migration
bun zt make:controller Name # controller class
bun zt make:middleware Name # middleware class
bun zt make:provider Name # service provider
bun zt make:job Name # queue job
bun zt migrate # run pending migrations
bun zt migrate:rollback # roll back last batch
bun zt migrate:status # show migration status
bun zt migrate:fresh # roll back all + re-migrate
bun zt key:generate # generate a fresh APP_KEY
bun zt queue:work # start the queue worker
There are many more (make:policy, make:factory, make:seeder, make:page,
db:seed, schedule:list, …). Run bun zt list to see them all, or see
Commands for the full list and
Scaffolding for what each generator produces.
Next steps
- Routing — route definitions, groups, parameters, and file-based routes.
- Controllers — move route logic into classes.
- ORM — models, queries, and relationships.
- Configuration — how config and auto-discovery work.
- Flow — server-driven reactive UI (or Inertia for a React/Vue SPA).