Vibes DIY
Vibes DIY / Docs
creator docs · guide · backend.js

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, or fetch("/_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:

  1. Guard on event.dbName first — the handler fires for every database in the app, so an unguarded write-back loops on itself.
  2. Write derived docs to a different database than the one that triggered the event. Backend writes trigger onChange again (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-Origin check, 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.com is seeded, api.linkedin.com came via meta-hub). Adding a host is a reviewed pull request to the platform repo — open an issue or PR against vibes.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 → onChangectx.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-app is what /_api, onChange, and scheduled execute. 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 fetch lane is still rolling out — design as if fetch callers are anonymous, and require allowAnonymous-permitted writes or route through the other lanes.
  • Observability is the database. There's no log tail yet; the working pattern is an oplog database your handlers write status docs into, which doubles as your dashboard's data source (see the operator console pattern).

Where to go next