BuilderJS

Embed a Drag-and-Drop Builder Inside Your Own WordPress Plugin

BuilderJS is a framework-agnostic, vanilla-JavaScript drag-and-drop editor (~140 KB minified + gzipped) for HTML email and web/landing pages that you self-host and embed wherever you can render a DOM node — including your own WordPress plugin's admin screen. To be precise about scope: BuilderJS is the editor layer you put behind your plugin, not a published WordPress plugin itself and not a sending or hosting service. It builds the markup; your plugin (and WordPress) owns the admin menu, the database, the nonces, the REST routes, and whatever delivery or rendering happens next. That makes it a clean complement to email plugins like MailerPress or YayMail and to page tools — it gives users a real visual canvas inside your settings page without you writing an editor from scratch, and without replacing the plumbing those tools already provide. This guide is the plugin-author mount recipe: enqueue the bundle on admin_enqueue_scripts gated by $hook_suffix so it loads only on your page, render three container divs in your settings view, instantiate window.Builder client-side, persist getData() JSON through wp_ajax_ or the REST API with a nonce, re-hydrate with load(), and wire the WordPress Media Library to the editor's onBrowse callback. Every API call below matches the real BuilderJS surface — confirm against the bundle you license, then copy and ship.

Mount it

<?php
  // your-plugin/includes/builder-admin.php
  // Editor layer behind YOUR plugin — BuilderJS is self-hosted, not a WP plugin.
  
  add_action('admin_menu', function () {
      // Capture the hook suffix so we only enqueue on OUR screen.
      $GLOBALS['myplugin_builder_hook'] = add_menu_page(
          'Email Builder', 'Email Builder', 'edit_pages',
          'myplugin-builder', 'myplugin_render_builder_page'
      );
  });
  
  add_action('admin_enqueue_scripts', function ($hook_suffix) {
      if ($hook_suffix !== ($GLOBALS['myplugin_builder_hook'] ?? null)) {
          return; // gate: load the engine ONLY on our page
      }
      $base = plugin_dir_url(__FILE__) . '../assets/builder/';
      wp_enqueue_style('bjs', $base . 'builder.css', [], '1.0.0');
      wp_enqueue_script('bjs', $base . 'builder.js', [], '1.0.0', true);
  
      // Bridge server data -> browser: nonce + saved design + media base.
      $design_id = absint($_GET['design'] ?? 0);
      wp_localize_script('bjs', 'MYPLUGIN_BJS', [
          'restUrl'  => esc_url_raw(rest_url('myplugin/v1/design/' . $design_id)),
          'nonce'    => wp_create_nonce('wp_rest'),
          'data'     => get_post_meta($design_id, '_bjs_data', true) ?: null,
          'mediaUrl' => esc_url_raw(wp_upload_dir()['baseurl'] . '/'),
      ]);
  });
  
  function myplugin_render_builder_page() {
      // Three container divs — BuilderJS resolves them as CSS selectors.
      echo '<div class="wrap">
              <div id="bjs-widgets"></div>
              <div id="bjs-canvas"></div>
              <div id="bjs-settings"></div>
              <button id="bjs-save" class="button button-primary">Save</button>
            </div>';
  }
  
  // REST route to persist getData() JSON (permission + nonce enforced).
  add_action('rest_api_init', function () {
      register_rest_route('myplugin/v1', '/design/(?P<id>\d+)', [
          'methods'  => 'POST',
          'permission_callback' => fn() => current_user_can('edit_pages'),
          'callback' => function (WP_REST_Request $r) {
              $id = absint($r['id']);
              update_post_meta($id, '_bjs_data', wp_unslash($r->get_param('data')));
              update_post_meta($id, '_bjs_html', wp_kses_post($r->get_param('html')));
              return ['ok' => true];
          },
      ]);
  });
  ?>
  
  <!-- assets/builder/mount.js (enqueued alongside, or inline) -->
  <script>
  document.addEventListener('DOMContentLoaded', function () {
    if (!window.Builder) return; // self-hosted global, no npm import
  
    var builder = new window.Builder({
      mainContainer:     '#bjs-canvas',   // required (CSS selector string)
      widgetsContainer:  '#bjs-widgets',  // optional
      settingsContainer: '#bjs-settings', // optional
      outputMode: 'email',                // 'email' export pipeline, or 'page'
      // loadAssets: false,               // air-gapped/CSP: self-host the
                                          // Material Symbols font + Bootstrap 5 JS
  
      // Wire the WordPress Media Library: engine calls onBrowse(handleUrl).
      onBrowse: function (handleUrl) {
        var frame = wp.media({ title: 'Select image', multiple: false });
        frame.on('select', function () {
          var att = frame.state().get('selection').first().toJSON();
          handleUrl(att.url); // hand the chosen URL back to the editor
        });
        frame.open();
      },
    });
  
    // Re-hydrate: load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl)
    builder.load(MYPLUGIN_BJS.data, {}, {}, MYPLUGIN_BJS.mediaUrl);
  
    // Save: hand-roll the fetch so we can attach the WP REST nonce.
    // (builder.save() exists but posts url-encoded to one saveUrl with NO nonce.)
    document.getElementById('bjs-save').addEventListener('click', function () {
      fetch(MYPLUGIN_BJS.restUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-WP-Nonce': MYPLUGIN_BJS.nonce, // WordPress-owned auth
        },
        body: JSON.stringify({
          data: builder.getData(),  // lossless JSON for round-trip load()
          html: builder.getHtml(),  // rendered markup, if you cache it
        }),
      });
    });
  });
  </script>

It's an editor layer behind your plugin — not a WordPress plugin to install

BuilderJS ships as two self-hosted static files (dist/builder.js and dist/builder.css) that attach a global window.Builder constructor — there is no Composer package, no npm import, and nothing in the WordPress.org plugin directory. You vendor the two files inside your own plugin (for example wp-content/plugins/your-plugin/assets/builder/) and load them like any other admin asset. The honest boundary matters: BuilderJS does not send email, does not host or publish pages, and does not add an admin menu, settings, or post type for you. It is the visual canvas your plugin embeds; WordPress and your plugin code own the menu page, the options, the capabilities, and the delivery. That is exactly why it sits alongside MailerPress, YayMail, or a page tool rather than competing with them — you give users a drag-and-drop design surface, while sending, templating hooks, and storage stay in the tools and code that already own them.

Enqueue on admin_enqueue_scripts, gated by $hook_suffix

Load the bundle only on your plugin's screen so you never pollute the rest of wp-admin. Hook admin_enqueue_scripts and bail unless $hook_suffix matches the page returned by add_menu_page() / add_submenu_page() — that hook suffix is the single source of truth for 'is this my screen.' Inside the guard, wp_enqueue_style() the builder.css and wp_enqueue_script() the builder.js (no jQuery dependency; it is standalone vanilla JS). This is also where you bridge server data to the browser: call wp_localize_script() (or wp_add_inline_script) to expose the REST/ajax URL, a freshly minted nonce via wp_create_nonce(), and the saved design JSON for the current record. Gating on $hook_suffix keeps the ~140 KB engine off every other admin page and avoids conflicts with other plugins' editors. One real-world caveat: when missing, BuilderJS auto-loads two peer assets (the Material Symbols Rounded font and Bootstrap 5's JS bundle) from a CDN; for an air-gapped or strict-CSP WordPress install, construct with loadAssets: false and enqueue your own self-hosted copies of those two instead.

Three container divs and a window.Builder mount

In your settings page callback, render three empty container elements — one for the canvas, one for the widget palette, one for the settings panel — for example <div id="bjs-canvas">, <div id="bjs-widgets">, and <div id="bjs-settings">. After those nodes exist in the DOM, construct new window.Builder({ mainContainer: '#bjs-canvas', widgetsContainer: '#bjs-widgets', settingsContainer: '#bjs-settings' }). Those three options are CSS selector strings that BuilderJS resolves with querySelector — not DOM nodes and not refs. mainContainer is required; widgetsContainer and settingsContainer are optional, so a selector that resolves to nothing simply leaves that sub-panel unmounted. Pass outputMode: 'email' to run the email-export pipeline (flexbox to nested tables, MSO conditionals, @media mobile-stack, SVG rasterized to PNG, CSS inlined — Litmus-tested across 22 clients) or outputMode: 'page' for responsive web/landing HTML. The editor is client-side only; it never participates in your PHP render. There is no destroy() method, so mount once per page load and let WordPress discard the DOM on navigation.

Save getData() through wp_ajax_ or REST — hand-roll the fetch for the nonce

Persistence is plain JSON and stays entirely inside WordPress. Call builder.getData() to get a lossless snapshot of the design (and builder.getHtml() for the rendered markup if you want to cache it), then POST both to your own endpoint. BuilderJS does ship a built-in builder.save() helper, but it POSTs html plus data as application/x-www-form-urlencoded to a single saveUrl and carries no WordPress nonce — so for admin-ajax or REST you hand-roll the fetch yourself to attach the nonce header and any post/record ID. Register a wp_ajax_{action} handler (or a register_rest_route with a permission_callback), verify with check_ajax_referer() / wp_verify_nonce(), gate on current_user_can(), then store the JSON in post meta, an options row, or a custom table. To reopen a saved design, echo that JSON back through wp_localize_script and pass it to load(). The design is your data in your database; BuilderJS imposes no storage format beyond the JSON it hands you.

Re-hydrate with load() and wire the Media Library via onBrowse

Restoring a design is one call: builder.load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl) — exactly four positional arguments, in that order: the saved theme JSON (your previously stored getData() output), the theme templates JSON, the theme config data, and the media base URL for resolving assets. Feed the saved snapshot back through load() and the canvas reconstructs the exact design. For images, you do not need a separate uploader — pass an onBrowse callback to the constructor and delegate to the native WordPress Media Library. BuilderJS calls onBrowse(handleUrl) when a user clicks Browse; inside it, open wp.media({...}), and in its 'select' event read the chosen attachment's URL and call handleUrl(url) (or handleUrl(null) to cancel). That hands the editor the asset URL while WordPress keeps owning uploads, the media grid, and attachment records — no parallel asset store to maintain.

Frequently asked questions

Explore