Vibes DIY
Vibes DIY / Blog
From the build log

Every vibe just got a backend

Until now, a vibe was a front end with a synced database: your App.jsx runs in the browser, Fireproof keeps everyone's data live, and access.js decides who may write what. That covers a huge range of apps — but there was always a category just out of reach: the app that needs something to happen when nobody has the page open. Accepting a webhook. Reacting to a write after it commits. Ticking once a minute.

That category just opened up. A vibe can now ship one more file — backend.js — and it runs on our servers, at the edge, with a real database handle.

A live vibe showing a heartbeat card: 5 seconds since last tick, written by the server as the owner.
A live vibe whose server writes a heartbeat every minute — as the app owner, into a database only the owner may write. The page just watches.

One file, three superpowers

backend.js sits next to App.jsx and access.js. Export only what your app needs:

js// Answer HTTP at your app's own /_api URL — webhooks, form posts, JSON APIs.
export async function fetch(request, ctx) { ... }

// React after any document write commits — derive, mirror, aggregate.
export async function onChange(event, ctx) { ... }

// Run on a timer, from every 5 seconds to every hour.
export const config = { scheduled: { interval: "1m" } };
export async function scheduled(event, ctx) { ... }

Every handler gets a ctx with ctx.appInfo (which app this is), ctx.userInfo (who the handler is acting as), and the important one — ctx.db:

jsconst id = await ctx.db.put({ kind: "rsvp", name }, { db: "rsvps" });
await ctx.db.delete(id, { db: "rsvps" });

Those writes are real Fireproof writes. Everyone with the app open sees them sync in, the same as any user's write.

The security model is the whole point

Here's the design decision everything else hangs on: a backend write goes through your app's own access.js — the exact same gate as a user write — acting as the identity of whatever triggered it.

HandlerIts writes act as…
onChangethe user whose write triggered the event
fetchthe signed-in caller when verifiable — treat as possibly anonymous today
scheduledthe app owner

There is no service key, no bypass, no second permission system. If your access function wouldn't let the triggering user write a document, the backend can't write it either. That cuts both ways, and it's worth internalizing:

  • An onChange write is exactly as privileged as the user who caused it — it's for derivation, never escalation. Anything the backend can write on a user's behalf, that user's own client could have written too.
  • For documents only the server should control — a tally, a digest, a heartbeat — use scheduled. It acts as the owner, and owner is an identity your access.js can genuinely restrict a database to. That's what the heartbeat vibe above does: if (!user || !user.isOwner) throw { forbidden: … }, and no client can forge that document.
  • A fetch handler runs for whoever hits your URL — including nobody in particular. Anonymous writes are fail-closed: the access rule must explicitly opt in with allowAnonymous: true, or the write is denied. (We rediscovered this ourselves an hour after launch, when our own demo forgot the flag and the gate correctly refused it.)

Try it live

This embedded vibe is running its backend right now. Press the button: the page calls fetch("/_api/hit?note=…"), the server handler writes a hit document through the access gate, and the list updates from the live query — server write to synced UI, end to end.

The handler behind it is the whole file:

Get posts like this in your inbox

One email field. Real updates. No algorithm required.

jsexport async function fetch(request, ctx) {
  const url = new URL(request.url);
  if (url.pathname !== "/hit") {
    return new Response(JSON.stringify({ error: "not found" }), { status: 404 });
  }
  const note = url.searchParams.get("note") || "ping";
  const id = await ctx.db.put({ kind: "hit", note, at: new Date().toISOString() }, { db: "hits" });
  return new Response(JSON.stringify({ ok: true, id }), {
    headers: { "content-type": "application/json" },
  });
}

Two details worth knowing. Your endpoint's public address is your published app's URL plus /_api/… — that's what you hand to a webhook provider. And the path arrives pre-stripped: a request to …/_api/webhooks/pay reaches your handler with pathname /webhooks/pay.

Reacting to writes

onChange runs after any document write commits — user writes and backend writes alike. The event carries { dbName, docId, doc, oldDoc, seq, deleted }.

A vibe with two columns: user-written notes on the left, and an audit column on the right written by the backend, attributed to the same user.
The onChange demo: write a note, and the server mirrors it into a second database — attributed to you, because the backend acts as the writer who triggered it.
jsexport async function onChange(event, ctx) {
  if (event.dbName !== "notes" || event.deleted) return;
  await ctx.db.put(
    { _id: "act-" + event.docId, kind: "activity", srcId: event.docId, by: ctx.userInfo },
    { db: "activity" }
  );
}

Two rules keep change-reactions sane, and they're the same two that make loops structurally impossible: guard on event.dbName first (the handler fires for every database), and write derived documents into a different database than the one that triggered you. Backend writes do trigger onChange again — one generation deeper — and the platform caps runaway chains, but a guard plus a separate target database means you never lean on the cap. Inside onChange, the triggering database is the default for ctx.db, so cross-database writes take an explicit { db }.

The vibe that ships this platform

The demos above are tutorials. Here's the one we actually lean on: ship-button is a vibe with one big red button that deploys VibesDIY/vibes.diy itself — prod, cli, and npm in a single press. Pressing it doesn't call GitHub from the browser. It writes a press document, and the vibe's backend.js reacts to the committed write by dispatching our ship-fanout workflow with a GitHub token that lives in the vibe's server-side secrets. The browser never sees the token; the press log below the button is just the same synced database everyone else reads.

The ship-button vibe: a locked circular button reading LOCKED — ask for ship access, above a pending-version panel and a log of green deploy runs pressed by jchris.
The live ship button, signed in with a handle that isn't on the crew: the button stays locked, while the run log shows the real deploys it dispatched earlier today. (Fully shipped, too — zero commits pending, so even crew would see ALL CLEAR right now.)

The security model from earlier is doing all the work. access.js lets only the owner write crew documents, and each crew doc grants one handle the right to write a press doc — so granting a teammate the power to deploy from main is typing their handle into the app, and revoking it is deleting a doc. Because the dispatch runs from onChange acting as the pressing user, a press that wouldn't pass the write gate never reaches GitHub. There's no second permission system guarding the deploy — the database ACL is the deploy ACL. Pull the source with the CLI if you want to crib it: the whole thing is one App.jsx, a 50-line access.js, and a 40-line backend.js.

How to get one

You don't scaffold anything. Two paths:

  • Ask the builder. The app generator knows this skill — prompt for an app that "accepts a webhook," "keeps an activity feed as notes are added," or "cleans up expired entries every 15 minutes," and it will emit a backend.js (and the matching access.js rules) alongside your app.
  • Write it by hand. Pull any vibe with the CLI, drop a backend.js at the root next to App.jsx, and push:
shnpx vibes-diy pull you/your-app --dir ./your-app
cd your-app && $EDITOR backend.js
npx vibes-diy push --vibe you/your-app

The push registers your handlers and validates your schedule up front — a scheduled export requires config.scheduled.interval as a plain string between "5s" and "1h", and a bad interval is rejected at push time, not silently at 3am.

The honest edges

At launch this section said backend.js had no external network egress and no ctx.secrets. Both doors have since opened — they're what the ship button above runs on — and they opened with the same discipline as the database channel. Egress: a handler's fetch() goes out through a proxy with two lanes. By default it behaves like a browser on your vibe's page — it forwards only what the target API's own CORS policy would let that browser do, presents your vibe's real origin, and rate-limits bursts. For server-to-server APIs that were never meant to speak CORS, there's a curated platform list where hosts can send real Authorization headers — and adding a host is a reviewed pull request to that file, so the approval trail is public. Its seed entry is api.github.com: exactly what the ship button's dispatch rides. Secrets: vibes-diy secrets set MY_KEY --vibe you/your-app stores a key owner-only on the server; handlers read it as ctx.secrets.MY_KEY. The platform never puts the value in a document, a release hash, or the browser on its own — it exists only inside a handler invocation, so keeping your own code from writing it somewhere is the one job left to you (the ship button's handler reports only a status code back to the database, never the response body, for exactly this reason). Still ahead: verified signed-in identity on the fetch lane — until it lands, treat fetch writes as anonymous and gate them accordingly — and AI calls, which stay in App.jsx via callAI for now.

All four apps in this post are live, and their complete source — App.jsx, access.js, backend.js — is pullable with the CLI: backend-fetch, backend-onchange, backend-scheduled, and the ship-button we deploy with.

Give your app a server side

Prompt an app that reacts, schedules, and answers webhooks — the backend comes with it.

Start building →

Enjoyed this? Get the next post by email

One email field. Real updates. No algorithm required.

Prefer a feed? RSS · Atom