Keelv0.86.0
Docs / Starter kits
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.
  • .env is 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.