How to Wire BuilderJS Image Upload to S3 (and R2, B2, MinIO)
A common first question when embedding BuilderJS is "how do I send uploaded images to S3?" The honest, important answer shapes everything else: BuilderJS never uploads anything itself. The engine has no file input, no fetch to an upload endpoint, and no opinion about where bytes go. When a user clicks Browse on an image control, the editor calls one callback you provide — onBrowse(handleUrl) — and then waits. Your app opens a picker, uploads the file to your own storage, and calls handleUrl(url) with the resulting public URL. That URL becomes the image src. This split is deliberate: BuilderJS is the editor you own and self-host, not a platform, so persistence and storage stay entirely on your side. The upside is that S3, Cloudflare R2, Backblaze B2, or a local MinIO box are all just "an endpoint your handler POSTs to." This guide explains the exact contract, walks the shipped S3 reference handler (pure SigV4, no SDK), and shows how the same handler covers every S3-compatible store. All API names here match the real BuilderJS source.
Mount it
// Mount BuilderJS and own the upload yourself.
// The engine NEVER uploads — it calls onBrowse(handleUrl); your code uploads
// to S3/R2/B2/MinIO via your handler and returns the public URL.
const builder = new Builder({
mainContainer: '#MyCanvas', // REQUIRED — CSS selector string
widgetsContainer: '#MyWidgets', // optional
settingsContainer: '#MySettings', // optional
// Called when the user clicks "Browse" on any image control.
onBrowse: (handleUrl) => {
// 1) Open your own picker / file input — the host owns the UX.
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = () => {
const file = input.files[0];
if (!file) return;
// 2) POST to YOUR handler — e.g. the shipped reference:
// demo/examples/backend/2-s3/s3-upload.php
// (pure SigV4, no AWS SDK; config points at S3 / R2 / B2 / MinIO)
const body = new FormData();
body.append('file', file);
fetch('/examples/backend/2-s3/s3-upload.php', { method: 'POST', body })
.then((r) => r.json())
.then((res) => {
// 3) Reference handler returns:
// { "status":"success", "url":"https://.../<key>.png", ... }
if (res.status === 'success') {
handleUrl(res.url); // ← the engine sets this as the image src
}
});
};
input.click();
},
});
// Restore a saved design (4 positional args, in this exact order):
builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
// Persist: getData() returns JSON (incl. the image URLs) you POST to your backend.
// const json = builder.getData();
The engine never uploads — it calls onBrowse(handleUrl)
This is the contract to internalize first. BuilderJS does not create a file input or POST an upload anywhere. When a user clicks Browse on any image-bearing control, the engine invokes the onBrowse callback you passed to the constructor, handing it a single argument: handleUrl. Your code opens whatever picker you like — a modal, a native input, a third-party asset library — uploads the chosen file to your backend, gets back a public URL, and calls handleUrl(url). The editor then sets that URL as the image src. If a user clicks Browse and onBrowse is not configured, those controls throw a runtime error rather than silently failing — fail loud by design. So 'upload to S3' is never an engine feature; it lives entirely in your onBrowse implementation and the endpoint it talks to.
The host owns the upload endpoint and the response shape
Inside onBrowse you do the actual work: POST the file (typically multipart/form-data with file=<binary>) to an endpoint you control, then resolve the returned URL through handleUrl. BuilderJS does not dictate your endpoint, auth, or storage — only that you eventually call handleUrl with a string URL. The shipped reference handlers return a small JSON envelope: { "status": "success", "url": "https://..." } (the S3 handler also echoes key, mime, and size). Because the URL is just a string, you can scope uploads per tenant by closure-capturing a tenant ID in onBrowse, attach session cookies for auth, or front the call with a signed-URL flow. None of that touches the editor — it stays a focused editor while your backend keeps full control of where files land.
The reference S3 handler: pure SigV4, no AWS SDK
BuilderJS ships a working reference upload handler at demo/examples/backend/2-s3/s3-upload.php. It validates the file (max bytes, allowed MIME types and extensions, max dimensions), generates a date-bucketed key, and streams the bytes to S3. The notable part: it signs the PUT request with pure cURL plus inlined AWS Signature Version 4 — hash_hmac chains building the AWS4-HMAC-SHA256 signing key — with no aws/aws-sdk-php dependency. That keeps the handler tiny, dependency-free, and easy to read. A BUYER CONFIG block exposes endpoint, bucket, region, key, secret, and public_url as plain variables (or env vars), so you point it at your store by editing a few lines. This is reference code you own and adapt — not a service BuilderJS runs for you.
Same handler, any S3-compatible store: R2, B2, MinIO
Because the handler speaks the raw S3 API over SigV4 rather than relying on AWS-only client behavior, it works against any S3-compatible endpoint by changing config alone. For AWS S3, use your bucket's regional endpoint. For Cloudflare R2 or Backblaze B2, set endpoint to the provider's S3-compatible URL, keep region as the value that provider expects (commonly us-east-1 / auto), and supply that provider's access key and secret. For local development, the example ships a one-command MinIO docker-compose (S3 API on :9000) plus a USE_S3=0 toggle that falls back to local-disk storage so you can build without any cloud account. The response shape stays identical across all of them, so onBrowse never changes — only where the bytes physically rest.
Putting it together end to end
The full path is short. At mount time, pass onBrowse to the Builder constructor. When a user clicks Browse, your onBrowse opens a picker, POSTs the file to s3-upload.php (or your adaptation of it), reads result.url from the JSON, and calls handleUrl(result.url). The handler has already streamed the file to S3/R2/B2/MinIO and returned its public URL, which the editor stamps as the image src. When the user saves, builder.getData() serializes the design — including those image URLs — as JSON you POST to your own backend; builder.load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl) restores it later, images and all. BuilderJS builds the markup and references the URLs; your storage and backend own the bytes and the persistence. That clean boundary is exactly what makes upload destinations interchangeable.
Frequently asked questions
-
Does BuilderJS upload images to S3 for me?
No. The engine never opens a file input or POSTs an upload itself. It calls your onBrowse(handleUrl) callback when a user clicks Browse, and your app does the upload to S3 (or anywhere) and calls handleUrl(url) with the resulting public URL. Storage is entirely host-owned — BuilderJS is the editor, not a file service. The shipped s3-upload.php is reference code you run on your own backend.
-
What exactly is onBrowse and what does it receive?
onBrowse is a constructor option: new Builder({ mainContainer: '#MyCanvas', onBrowse: (handleUrl) => { ... } }). When a user clicks Browse on an image control, the engine calls it with one argument, handleUrl. You open your picker, upload the file, then call handleUrl(url) with the public URL. The editor sets that URL as the image src. If onBrowse is not configured, image controls throw a runtime error rather than silently no-op.
-
Can I use Cloudflare R2 or Backblaze B2 instead of AWS S3?
Yes. The reference handler signs requests with raw AWS SigV4 over cURL and has no AWS SDK dependency, so it works against any S3-compatible endpoint. Point the endpoint, key, secret, region, and public_url config at R2 or B2 and the same handler streams to it. The JSON response shape is identical, so your onBrowse code doesn't change — only where the bytes are stored.
-
Can I test uploads locally without an AWS account?
Yes. The 2-s3 example ships a one-command MinIO docker-compose exposing the S3 API on :9000, with a bucket auto-provisioned on first run. Alternatively, set USE_S3=0 and the handler falls back to local-disk storage under uploads/, returning a same-shaped JSON response with a local URL. Either way you can build and test the full onBrowse flow before wiring a real cloud bucket.
-
How do I scope uploads per tenant or require auth?
Do it inside onBrowse and on your endpoint — the editor stays out of it. Closure-capture a tenant ID when you construct each Builder instance and attach it to the upload request, so tenant A's uploads can never reach tenant B's space. For auth, send your session cookie or token with the POST and validate it server-side in the handler, the same way you'd protect the save endpoint. BuilderJS only needs the final URL via handleUrl.