Your app has a backend
Every vibe can ship a third file. App.jsx is what people see, access.js is
who can do what — and backend.js is the part that runs when nobody's
browser is open. It runs on the platform's servers, next to your data, with
three lanes and no infrastructure to provision:
fetch(request, ctx)— your app gets its own HTTP endpoint at/_api/.... Webhooks from the outside world, orfetch("/_api/…")calls from your own UI.onChange(event, ctx)— runs after any document write commits. Derive, aggregate, moderate, fan out.scheduled(event, ctx)— runs on a timer, from every 5 seconds to every hour. Cleanup, digests, token rotation.
Export only what you need. Most apps need no backend.js at all — normal
reads and writes flow through Fireproof directly. Reach for it when something
must happen server-side: accepting a webhook, maintaining a tally users
shouldn't be able to forge, or doing periodic work.
This guide is the narrative tour. The full contract — every field, every cap — is the backend.js reference, the same document the code generator reads. You can also just describe what you want ("a backend that summarizes new entries every night") and the generator wires it for you.
Ship one in five minutes
Pull your app, add a file, push:
shnpx vibes-diy pull you/your-app --dir ./your-app
cd your-app
# create backend.js (below), then:
npx vibes-diy push
js// backend.js — an RSVP endpoint anyone can POST to
export async function fetch(request, ctx) {
const url = new URL(request.url);
if (url.pathname === "/rsvp" && request.method === "POST") {
const body = await request.json();
const id = await ctx.db.put(
{ kind: "rsvp", name: body.name, at: Date.now() },
{ db: "rsvps" }
);
return new Response(JSON.stringify({ ok: true, id }), {
headers: { "content-type": "application/json" },
});
}
return new Response("not found", { status: 404 });
}
The path is rooted after /_api: a request to
https://your-app--you.host/_api/rsvp arrives with pathname /rsvp. From
your own App.jsx, call it relative — fetch("/_api/rsvp", { method: "POST", … }).
You can also prompt for it in the editor: backend.js is a normal part of the vibe's source, editable in the Code tab like everything else.
The identity model — one permission system, not two
There is no service key. Every ctx.db.put and ctx.db.delete a handler
makes goes through your app's own access.js, acting as whoever triggered
the handler:
| Handler | Writes act as… |
|---|---|
onChange |
the user whose write triggered the event |
fetch |
the signed-in caller when verifiable — treat as possibly anonymous |
scheduled |
the app owner, in admin mode |
Two consequences worth internalizing:
onChange is for derivation, never privilege escalation. Its writes act
as the triggering user, so anything it may write, that user's own client
could write too. If a document should be something only the server controls,
don't write it from onChange.
scheduled is the owner, with the owner's override. It runs in admin
mode: requireAccess/requireRole no-op, and its reads are unfiltered — a
scheduled sweep can read and rewrite every user's documents in the app.
Access functions protect users from each other, not from the app's owner.
That makes scheduled the right lane for genuinely server-controlled
documents (gate them to the owner in access.js and no user can forge them),
and also the lane to keep small and well-guarded: because admin mode skips
the access checks, a buggy scheduled write fails silently-wrong instead of
forbidden.
If your fetch handler writes, the access function must allow that write for
an anonymous caller — or move the write to onChange/scheduled, which
carry stronger identities.
onChange without loops
jsexport async function onChange(event, ctx) {
// event: { dbName, docId, doc, oldDoc, seq, deleted }
if (event.dbName !== "votes" || event.deleted) return;
await ctx.db.put(
{ _id: "tally-" + event.doc.pollId, kind: "tally", bump: event.seq },
{ db: "tallies" }
);
}
Two rules keep reactions sane, both visible above:
- Guard on
event.dbNamefirst — the handler fires for every database in the app, so an unguarded write-back loops on itself. - Write derived docs to a different database than the one that
triggered the event. Backend writes trigger
onChangeagain (the platform caps runaway chains, but a same-database ping-pong is still wasted work). Different db + dbName guard makes loops structurally impossible.
scheduled — periodic work
The interval is a static string literal between "5s" and "1h", validated
at push time:
jsexport const config = { scheduled: { interval: "15m" } };
export async function scheduled(event, ctx) {
// event: { scheduledTime } — ISO timestamp of this tick
const docs = await ctx.db.query({ db: "notes" }); // unfiltered, admin mode
// read, decide, then put/delete
}
ctx.db.query({ db }) returns the database's latest docs (capped at 2000) —
filter and sort in the handler. In fetch and onChange the same read is
ACL-gated as the acting identity, anonymous callers are denied, and databases
bound to an access function can't be queried at all — unfiltered reads over
access-controlled data are deliberately a scheduled-only power.
Secrets — set by the owner, seen only by handlers
shnpx vibes-diy secrets set STRIPE_KEY --vibe you/your-app # or the vibe's Settings page
npx vibes-diy secrets ls --vibe you/your-app # names + last4 only
npx vibes-diy secrets rm STRIPE_KEY --vibe you/your-app
Handlers see them as ctx.secrets.STRIPE_KEY — frozen, {} when none. The
value never appears in a document, a release, or any browser (not even
yours); it exists only inside a handler invocation. Use secrets to verify
inbound webhooks (compare a shared secret) or authenticate outbound calls
(Authorization: Bearer ${ctx.secrets.KEY}). Keeping your own code from
leaking one is on you: never write a secret anywhere users can read —
a doc, a response body, an error message. Write back a status code, not the
response.
Outbound fetch — what your backend may call
Handlers can fetch() external HTTPS APIs through a policy gate with two
lanes:
- CORS-parity (default): if a browser on your app's own page could call
it, your backend can too. Same preflight rules, same
Access-Control-Allow-Origincheck, browser-forbidden headers stripped. Most public JSON APIs just work — Bluesky's entire XRPC API, for example, is CORS-open, which is why a vibe can run a Bluesky bot with no special arrangement. - Curated platform list: server-to-server APIs that send no CORS headers
(
api.github.comis seeded,api.linkedin.comcame via meta-hub). Adding a host is a reviewed pull request to the platform repo — open an issue or PR againstvibes.diy/api/svc/intern/egress-platform-list.ts.
A denied call returns
403 {"vibesEgressDenied": true, "gate": "floor" | "owner-blocked" | "cors" | "rate-limit", "host": …} —
check for that shape and surface a useful error instead of a mystery failure.
Limits: HTTPS on port 443 only, 15s per request, 10MB responses, per-vibe
rate caps (30 per 10s, 300 per minute). Make outbound calls before
returning your Response — a fetch fired after the handler returns is
denied. The design story behind the gate is
"Trust the target, bless the person".
ctx.callAI — server-side AI with no key anywhere
jsconst text = await ctx.callAI(
"Summarize these entries as one paragraph: " + JSON.stringify(entries),
{ model: "openrouter/auto", max_tokens: 500 }
);
Resolves to the completion text. No API key exists in your code, your
secrets, or anywhere in your tenant world — the platform makes the call and
meters the cost to the account of whoever triggered the handler (the writer
in onChange, the signed-in caller in fetch, the owner for scheduled
ticks and anonymous webhooks — same ai_credits ledger as the chat).
Options: model (default "openrouter/auto"), max_tokens, temperature.
No streaming, no schema mode — ask for JSON in the prompt and JSON.parse
defensively. Budget: a handful of calls per invocation (currently 5) and
~64k prompt chars. A failed or denied call throws: catch it and write an
error field back onto the doc rather than losing the event. Keep
interactive, streaming AI in App.jsx via callAI; use ctx.callAI when
the result must be server-authoritative — moderation, digests, summaries
users shouldn't be able to forge. The canonical shape is
button → request doc → onChange → ctx.callAI → write-back: the
database is the request/response channel, so results survive reloads and
sync to every viewer.
Honest edges
- Backends always run the live release. There is no version-pinned
backend serving: whatever you pushed last under
you/your-appis what/_api,onChange, andscheduledexecute. To test backend or access changes without touching production, push under a secondary slug (npx vibes-diy push --vibe you/your-app-staging) and promote by pushing to the real slug when it works. - Verified signed-in identity on the
fetchlane is still rolling out — design as iffetchcallers are anonymous, and requireallowAnonymous-permitted writes or route through the other lanes. - Observability is the database. There's no log tail yet; the working
pattern is an
oplogdatabase your handlers write status docs into, which doubles as your dashboard's data source (see the operator console pattern).
Where to go next
- backend.js reference — the full contract, every field and cap, generated from platform source.
- Patterns cookbook — the write-only credential vault, doc-status state machines, CI lanes with device certs, and the other production patterns our own infrastructure runs on.
- Launch stories: Every vibe just got a backend, Your backend can call the model now.