npm create keeljs@latest my-app -- --preset saas
Four curated applications. Each is a complete, working app — not a scaffold you have to finish. For the full path from this command through Cloudflare or Keel Cloud, see From install to deploy.
| Preset | What you get |
|---|---|
minimal |
Routes, a controller, JSX + Keel UI + Tailwind, /health. No database. |
api |
JSON API via apiResource, OpenAPI at /docs, Watch at /watch, migrations, tests. |
app (default) |
Full-stack auth: register/login, password reset form, email verification, 2FA setup + confirm, Watch. |
saas |
app plus teams, role gates, invitation revoke, Stripe-ready team billing (pricing / checkout / portal, FakeGateway when Stripe keys are absent), social login, a background queue + scheduler, and a team-scoped REST API with OpenAPI at /docs. |
UI chrome (buttons, fields, panels, hero) comes from @shaferllc/keel/ui — see
UI. Kits import the stylesheet in resources/css/app.css and use the
JSX components in resources/views/.
Edge deploy is cross-cutting — every DB kit ships worker.ts + Wrangler. There is
no separate edge preset.
Pick a kit
npm create keeljs@latest my-app # app (default)
npm create keeljs@latest my-api -- --preset api
npm create keeljs@latest my-saas -- --preset saas
npm create keeljs@latest bare -- --preset minimal
cd my-app && npm install && npm run dev
Then open http://localhost:3000. The SaaS kit already has a team switcher,
invites, role-gated admin actions, and team billing wired through teams
and billing — start by editing app/Models and routes/web.ts.
Refreshing an existing kit
create-keeljs writes .keel/kit.json with content hashes of every stock file.
After you bump @shaferllc/keel, pull new kit files without clobbering your edits:
npm install @shaferllc/keel@latest
npx keel kit:sync # uses preset from .keel/kit.json
# npx keel kit:sync --preset saas # if you have no lockfile yet
# npx keel kit:sync --force # overwrite customized kit files too
# npx keel kit:sync --dry-run # preview
- Missing kit files are always added (new views, configs, …).
- Untouched files (hash still matches the lockfile) are updated in place.
- Customized files are skipped unless you pass
--force. .envis never overwritten.
Apps generated before kit lockfiles existed: pass --preset once; sync writes
.keel/kit.json so later runs are smart. Without --force, only missing files
are added until the lockfile knows what "stock" looked like.
Every database, Cloudflare first
Each kit with a database ships with all four drivers wired. Switching is
DB_CONNECTION and nothing else — no model or query changes, because they talk to
a Connection, not a driver.
| D1 | The default for deploys. Inside the Worker Keel uses the binding; migrations and scripts reach the same database over the HTTP API, so keel migrate works from your laptop and from CI. |
| SQLite (libSQL) | A local file. What npm run dev uses — no account, no wrangler. |
| Turso | libSQL over the network. |
| Postgres | For when you want it. |
Local and production are both SQLite dialects, so one schema and one set of migrations serve both.
npm run dev # Node, SQLite file, no setup
npm run dev:edge # wrangler, local D1
npm run deploy # wrangler deploy
To deploy:
wrangler d1 create my-app # paste the id into wrangler.jsonc
npm run deploy
What's in the box
app and saas mount accounts, so password reset, email
verification, and two-factor already work — the flows live in the framework, tested
once, rather than being copy-pasted into each new app. HTML controllers own the UI;
JSON /auth/* routes are disabled via config/accounts.ts.
api mounts apiResource and OpenAPI so the
posts demo is declarative CRUD with a live /docs UI. api, app, and saas also
mount Watch at /watch for local debugging.
saas also mounts teams and billing. The team is
the Stripe customer (billableTable: "teams"). Without Stripe keys, FakeGateway
runs so subscribe still redirects to a checkout URL in development and tests.
In saas, a tenant-owned model is one word:
import { TenantModel } from "@shaferllc/keel/teams";
class Project extends TenantModel {
static table = "projects";
}
await Project.all(); // only the current team's. Always.
await Project.create({ name: "Hi" }); // stamped with the current team
Another team's project isn't merely hidden from a list — Project.find(id) returns
null. You never write .where("team_id", …), which is what makes it impossible to
forget.
The REST API is the same tenancy, for free
saas also generates a REST API over that model — GET/POST /api/projects,
GET/PUT/DELETE /api/projects/:id — documented at /docs:
apiResource(router, Project, {
body: ProjectBody,
access: { read: () => !auth().guest(), /* … */ },
});
There is no scope: option and no where clause, because Project is a
TenantModel: the generated queries are already constrained to the caller's team.
GET /api/projects/1 on another team's project is a 404, not a leak. Access is
deny-by-default, so a guest gets a 403 rather than a 500 — they have no team, and a
tenant query without one throws, by design.
It lives under /api/projects because the HTML form owns POST /projects; two
handlers on one method+path is a silent shadowing bug.
Background work
Registration doesn't wait on SMTP. The verification email is a queued job:
await dispatch(new SendVerificationEmailJob(user.id));
Under Node, BackgroundServiceProvider runs a MemoryDriver and drains it on an
interval. On Cloudflare it stays SyncDriver — a Worker may not hold a timer between
requests — and the cron trigger in wrangler.jsonc drives the
scheduler through the Worker's scheduled handler. Recurring work
(prune-invitations, daily) is declared in ScheduleServiceProvider, so one trigger
serves any number of tasks.
Social login
"Sign in with GitHub / Google", off by default. Leave the client id blank and the
provider simply isn't offered — no button, and its route 403s — the same bargain
billing makes with Stripe. Set GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET in .env
to turn it on.
Accounts are matched on the provider's id first, email second, and email only links an account when the provider says the address is verified. The reverse order is an account-takeover bug: anyone can put your address on their GitHub profile.
Why a generator, and not a template repo
Because a second repo rots. The old starter sat pinned to 0.78.2 while the
framework was on 0.79.0, and nothing noticed.
The templates live inside the framework package, so the version a kit is
generated from is, by construction, the version it was written for. And CI generates
all four on every push, then typechecks, migrates, boots, serves a request, bundles
the Worker, and runs their tests — so a breaking change fails in the pull request
that caused it, not in your npm create three weeks later.
The Node/edge seam
Each kit has two provider lists. bootstrap/providers.ts runs under Node;
bootstrap/providers.edge.ts runs in the Worker and deliberately omits the
database provider — it reaches for pg, which needs net/tls, and wrangler
cannot bundle a TCP driver for the edge. worker.ts binds D1 before the app boots,
so nothing on the edge needs to open a connection.
If you add a provider that touches a Node-only module, add it to the Node list only.