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
-
Is BuilderJS a WordPress plugin I install from the directory?
No. BuilderJS is a self-hosted vanilla-JS editor bundle (builder.js + builder.css) that attaches window.Builder — not a WordPress plugin, Composer package, or npm import. You vendor the two files inside your own plugin and enqueue them on your admin screen. It adds no menu, settings, or post type; your plugin owns all of that. It is the editor layer you put behind your plugin, which is why it complements tools like MailerPress or YayMail rather than replacing them.
-
How do I load the bundle only on my plugin's admin page?
Hook admin_enqueue_scripts and check the $hook_suffix argument against the value returned by add_menu_page()/add_submenu_page(); return early if it doesn't match. Inside the guard, wp_enqueue_style() builder.css and wp_enqueue_script() builder.js, then use wp_localize_script() to pass the ajax/REST URL, a wp_create_nonce() token, and the saved design JSON to the browser. Gating on $hook_suffix keeps the ~140 KB engine off every other wp-admin screen.
-
How do I save the design and protect it with a nonce?
Call builder.getData() for a lossless JSON snapshot (and builder.getHtml() if you want the markup), then hand-roll a fetch to your own wp_ajax_{action} handler or a register_rest_route endpoint, attaching the nonce. BuilderJS's built-in save() posts html+data url-encoded to one saveUrl and carries no WP nonce, so you write the fetch yourself. Server-side, verify with check_ajax_referer()/wp_verify_nonce(), gate with current_user_can(), and store in post meta, an option, or a custom table. The data lives in your database.
-
Can the editor use the WordPress Media Library for images?
Yes. Pass an onBrowse callback to the Builder constructor. BuilderJS calls onBrowse(handleUrl) when a user clicks Browse; inside it, open wp.media({...}), and on its 'select' event read the attachment URL and call handleUrl(url). WordPress keeps owning uploads, the media grid, and attachment records, while the editor just receives the chosen URL — no separate asset store to build.
-
Does BuilderJS send the email or publish the page my plugin builds?
No. BuilderJS is the embeddable editor only — it produces the email template or page markup and stops there. It does not send email, host or publish pages, schedule, or do CRM. Your plugin (or a tool like MailerPress/YayMail) handles sending, and WordPress or your host serves any page. For email, its export pipeline emits Outlook-safe HTML (table layout, MSO conditionals, inlined CSS) that you then pass to whatever sends it.
-
What does it cost, and is it a subscription?
BuilderJS is a one-time CodeCanyon license (item 27146783): Regular $52 or Extended $99. You own the source and self-host the bundle inside your plugin — no SaaS subscription, no monthly fee, no per-seat charge, no usage metering, and no phone-home. Author support is time-boxed (roughly 6 to 12 months), which is standard for a one-time CodeCanyon license, but the code is yours to keep and ship.