BuilderJS

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

Explore