BuilderJS

How to save BuilderJS content to a database

BuilderJS is the embeddable editor — it builds the markup and hands you a lossless JSON document, but it never persists anything for you. Saving to a database is deliberately your application's job, and it takes two real moving parts: a `saveUrl` you pass to the constructor, and `builder.save()`, which POSTs the rendered HTML plus the JSON state to that URL. There is no SaaS, no phone-home, and no engine `save:click` event — you wire your own button to `save()`, and your backend writes the row. This guide walks the exact, source-accurate contract: what the constructor expects, what `save()` actually sends on the wire, how the shipped reference MySQL handler stores it, and how to reload a saved design later with `load()`. Everything below uses the real BuilderJS API — no invented methods.

Mount it

// 1) Mount the builder with a saveUrl pointing at YOUR endpoint.
  //    saveUrl is a plain string — there is no save:{url,csrf} object.
  const builder = new Builder({
    mainContainer:     '#MainContainer',
    widgetsContainer:  '#WidgetsContainer',
    settingsContainer: '#SettingsContainer',
    saveUrl:           '/examples/backend/1-mysql/mysql-save.php',
    upload:            { url: '/backend/asset-upload.php', maxBytes: 5_000_000 },
    loadAssets:        'auto',
  });
  
  // 2a) Simplest path: wire your own button to builder.save().
  //     save() POSTs { html, data } (url-encoded) and resolves with the
  //     server's JSON. There is NO save:click event — this is host-driven.
  document.querySelector('#saveBtn').addEventListener('click', async () => {
    try {
      const res = await builder.save();   // POST html + data -> saveUrl
      console.log('Saved:', res);         // server's parsed JSON response
    } catch (err) {
      console.error('Save failed:', err); // surface to your own UI
    }
  });
  
  // 2b) Keyed/CSRF path: the reference mysql-save.php also wants a `slug`,
  //     which builder.save() does NOT add. Hand-roll the fetch instead,
  //     reading the real getHtml()/getData() outputs yourself.
  async function saveKeyed(slug, csrf) {
    const body = new URLSearchParams();
    body.append('slug', slug);
    body.append('html', builder.getHtml());
    body.append('data', JSON.stringify(builder.getData()));
    const res = await fetch('/examples/backend/1-mysql/mysql-save.php', {
      method:  'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrf },
      body:    body.toString(),
    });
    return res.json();
  }
  
  // 3) Round-trip: load the stored JSON back later (lossless restore).
  //    builder.load(theme, templates, configData, mediaUrl) — 4 args.
  const saved = await (await fetch('/api/pages/my-page')).json();
  builder.load(saved.theme, saved.templates, saved.configData, saved.mediaUrl);

Configure saveUrl, then call save()

Persistence starts with one constructor option: `saveUrl`, a string pointing at your own endpoint. (It is a plain string — there is no `save:{url,csrf}` object; pass `saveUrl` directly.) Once set, calling `builder.save()` returns a Promise. Internally it runs `getHtml()` and `getData()`, then issues a `fetch` POST with `Content-Type: application/x-www-form-urlencoded` containing exactly two fields: `html` (the rendered output) and `data` (the JSON state, `JSON.stringify`-ed). The Promise resolves with `response.json()`. There is no `save:click` event on the engine — save is host-driven, so you wire any button, keyboard chord, or debounced autosave to `save()` yourself. If `saveUrl` is unset, `save()` throws.

What lands on the wire

Be precise about the payload so your handler validates the right shape. `builder.save()` sends a URL-encoded body with `html` and `data` only — no slug, no CSRF token, no auth header. If your endpoint needs a record key (to upsert a specific page) or a CSRF token, the built-in `save()` won't add them. In that case, skip `save()` and hand-roll the fetch: read `builder.getHtml()` and `builder.getData()` yourself, append your own `slug`/`csrf`/headers, and POST to your route. Both approaches are first-class; `save()` is the convenience path for the simplest contract, and the manual fetch is the path when your schema is keyed or your framework requires a token.

The reference MySQL handler

The download ships a reference PHP backend at `examples/backend/1-mysql/mysql-save.php` you copy and own. It accepts a URL-encoded POST of `slug`, `html`, and `data`, validates each (slug is regex-bounded against path traversal; html and data have byte caps that fire before PDO), confirms `data` is real JSON, then runs a single prepared UPSERT into a `pages` table — `INSERT … ON DUPLICATE KEY UPDATE` on MySQL 8, `INSERT … ON CONFLICT(slug) DO UPDATE` on SQLite/PostgreSQL. It returns JSON `{ status, slug, message }`. Because it is just a reference talking to PDO, the same contract ports to Node, Go, or Python in a few lines. Note it expects a `slug`, so wire it with the hand-rolled fetch pattern, not bare `save()`.

Round-trip: reload with load()

Saving is only half the loop — you also need to restore the exact canvas. On the next visit, read the stored JSON back from your database and pass it into `builder.load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl)`. The `getData()` ↔ `load()` round-trip is lossless: a saved document reopens byte-for-byte, including all element types, controls, and styles. Because the format is plain JSON you own outright, you can version it, diff it across edits, migrate the schema, or render it server-side — there is no proprietary save format and nothing locks you in. Your app keeps full control of routing, auth, multi-tenant isolation, and where the bytes live.

Frequently asked questions

Explore