Skip to content
Wire to production D1 8 min read Integration Copy-along · 60 min

Wire a real backend

Save · load · upload · auth · multi-tenant — 60 minutes round-trip.

The shipped demo/backend/save.php writes builder JSON to disk next to the sample it loaded (demo/themes/<dir>/<sample>.json). That's fine for the demo. For production you'll swap it for your own stack — MySQL / Postgres / S3 / Firebase — plus auth and tenant-scoping. This doc walks the shipped contract and four adapter patterns. Time budget: 60 minutes.

Switch the demo backend on

The package ships with every write endpoint switched off. Until you configure it, Save, image upload, theme import and the examples/backend/* handlers answer 403 and write nothing — the builder still loads, edits and exports. The switch is one file, demo/backend/config.php:

mode Who can write Use it for
'disabled' (default) Nobody. A copy you only browse, or any server you have not secured yet.
'key' A browser session signed in at /backend/unlock.php with accessKey (16+ characters). Anything other people can reach — a staging server, a shared demo.
'open' Anyone who can reach the server. No authentication. Your own machine, or a sandbox you are prepared to wipe.

Edit the file, or leave it untouched and set environment variables instead:

On Apache use `SetEnv BJS_DEMO_BACKEND key` in the vhost; on nginx, `fastcgi_param BJS_DEMO_BACKEND key;`. An unknown mode or a short key is a configuration error — the endpoints answer `500` and name the problem rather than guess.

What the gate (demo/backend/_lib/BackendAccess.php) enforces once a mode is on:

  • Same-origin requests only — a page on another site cannot POST on a visitor's behalf.
  • Theme import is authenticated-only. A bundle puts HTML templates under the web root, so theme-upload.php runs in mode 'key' only, never in 'open'. It unpacks an allow-list of theme file types and rejects the whole bundle on a .php file, a hidden file, a symlink or a .. path.
  • Uploads are raster images only (JPEG / PNG / WebP / GIF), identified from the file's bytes and stored under a server-generated name. SVG is refused: it can carry script, and serving it from the builder's origin is stored XSS.
  • save.php only overwrites a sample that already exists, or writes into a scratch directory whose name starts with _.

Two files add a second lock on Apache — demo/uploads/.htaccess and demo/themes/.htaccess switch script execution off in the directories PHP writes to. On nginx, make sure no location ~ \.php$ block applies to /uploads/ or /themes/.

The gate protects the demo. It is not your application's auth: a real integration replaces these handlers and checks its own users (see Auth — wrap the handler).

The save / load contract

When the buyer clicks Save, the engine POSTs to whatever URL you configured as saveUrl:

The body is `application/x-www-form-urlencoded` with **two engine-emitted fields**:
Two more fields are layered on by the demo's chrome (`demo/assets/js/builder-chrome.js`) for path-based persistence:
If you ship your own host page that doesn't wrap the engine's save call, your handler will receive only `html` + `data` and you map persistence by session / page id / whatever your domain owns. If you copy the demo's `save.php` verbatim, ALSO copy `wireSaveButton` from `builder-chrome.js` so the 4-field shape stays in sync.

Your handler returns JSON:

That's the whole contract. The engine doesn't care where you persist (file, DB, S3, blob storage); it only cares about the response shape.

The shipped demo/backend/save.php handler

Read it (about 120 lines). It's the buyer's reference for a minimum-viable backend — condensed here:

Four reusable patterns:
  1. Gate first — BackendAccess::requireWrite() runs before anything reads the request. In your app this line is your own auth check.
  2. Validator pipeline — chain required(), regex(), maxLength(), then validateOrFail(). The library is demo/backend/_lib/Validator.php — copy it into your project.
  3. Paths built from plain segments — sample is slash-separated [A-Za-z0-9_-]+ segments with no dots, so a request cannot reach ../, a dotfile, or x.php.html; the handler adds the extension itself. Apply the same rule to every user-controlled path.
  4. Validate, then write atomically — the JSON is decoded before any file is touched, and each file is written to a temp name and rename()d into place. For real DBs use a transaction; for S3 use a single PutObject (it's atomic).

Adapter — MySQL

Replace the file write with a query. The shipped examples/backend/1-mysql/ walks this end-to-end. The skeleton:

Schema:
The composite UNIQUE KEY makes "save same path again" upsert; without it you'd accumulate duplicate rows.

Adapter — S3 / object storage

For static-site or serverless deployments, write the HTML + JSON to object storage:

The shipped [`examples/backend/2-s3/`](/examples/backend/2-s3/) shows the AWS SDK setup + a MinIO fallback for local dev (no AWS credentials needed).

Auth — wrap the handler

Every save is authenticated. The demo's own handlers do it with the shared access gate (BackendAccess::requireWrite(), see Switch the demo backend on); in your app, replace that line with whatever your app uses:

For JWT / Bearer-token auth, swap the session check for a `verifyJwt($_SERVER['HTTP_AUTHORIZATION'])` call. The shipped [`examples/backend/3-auth/`](/examples/backend/3-auth/) walks both session + JWT patterns.

Multi-tenant — closure capture

The W6.D lesson: the engine stays tenant-naive. Tenant identity lives in the host's Builder constructor closure, not on the engine instance.

Each Builder instance has its own closure; tenant A's instance can never accidentally save to tenant B's space because the URL is baked into the closure at boot. The shipped [`examples/extensions/5-multi-tenant/`](/examples/extensions/5-multi-tenant/) walks this end-to-end with `_lib/TenantRegistry.php` showing the host-side resolution.

Asset upload

The engine never opens a file input or POSTs an upload itself. Instead it calls your onBrowse(handleUrl) callback when the buyer clicks Browse on any image-bearing control. The host opens its own picker (modal / native / 3rd party), uploads to its own endpoint, then calls handleUrl(url) with the resulting public URL:

Shipped backend handler at `demo/backend/asset-upload.php` accepts a `multipart/form-data` POST with `file=<binary>` — a JPEG, PNG, WebP or GIF up to 10 MB — and returns:
The file type is read from the bytes, not from the uploaded name, and the stored name is generated server-side. Anything else — including SVG — is answered with `422`.

DemoFileBrowser.pickFile() reads result.url and hands it back through the handleUrl callback. The same JSON contract works with any picker — your host's file picker in your host app, an S3 pre-signed picker, a custom asset library, etc.

Async hand-off for heavy exports

The W6.C.15 lesson: HTML / ZIP / Bundle exports can be slow on big pages. Don't block the buyer's UI on a 30-second export. Pattern:

  1. POST /api/v1/exports/queue with the page id.
  2. Server enqueues a background job, returns { "jobId": "abc-123" }.
  3. Buyer's UI shows a "exporting…" toast.
  4. Server-Sent Events (SSE) or polling against /api/v1/exports/status?jobId=abc-123 returns progress.
  5. On completion, the response includes a download URL.

This pattern matters for large galleries, multi-language exports, and any case where post-processing exceeds 2-3 seconds.

Did you make it?

You should now be able to:

  • ✅ State the save contract: 4 form fields + JSON status response.
  • ✅ Adapt save.php to MySQL with a pages table and user/tenant scoping.
  • ✅ Adapt save.php to S3 / object storage with a PutObject call.
  • ✅ Add an auth wrapper (session OR JWT) to any backend handler.
  • ✅ Use closure-capture for multi-tenant Builder instances.

If a step didn't work:

  • 🩹 Save returns 422 → your validator caught a malformed field. Check the response's errors array — each entry names which field failed.
  • 🩹 Save returns 500 → tail your PHP error log. Most often: DB connection failed (wrong host/creds), filesystem write failed (permissions), or a fatal in your handler.
  • 🩹 Builder doesn't fire the Save POST → confirm saveUrl is set in the constructor options. If not set, clicking Save throws saveUrl is not configured. Pass it in the constructor options.
  • 🩹 Multi-tenant accidentally crosses tenants → the closure isn't capturing the right tenantId. Re-read the closure — every new Builder(...) call MUST have its own server-rendered constants.

What's next?