Embed a Drag and Drop Builder in Rails with a Stimulus Controller
BuilderJS is a framework-agnostic, vanilla-JavaScript drag-and-drop editor (~140KB) for HTML email and web/landing pages that mounts in any DOM node — including a div rendered by an ERB or ViewComponent template in your Rails app. It is the editor you embed and self-host, not a hosted platform: it builds the markup, and your Rails app owns the routes, persistence, auth, and serving. There is no gem and no npm component to install. You serve two static assets (builder.js + builder.css), render three container divs in a view, and let a Stimulus controller construct new Builder({...}) in its connect() lifecycle hook — the textbook place to wire a third-party widget into a Hotwire/Turbo page. Saving is plain JSON: builder.getData() returns a lossless snapshot you POST to one of your own routes with Rails' CSRF token, and builder.load() restores it. BuilderJS does not send email or host pages — you bring your own ESP and host. This page shows the exact, copy-paste Stimulus mount pattern against the real BuilderJS API.
Mount it
// app/javascript/controllers/builder_controller.js
// builder.js + builder.css are served by your app (public/builder/ or the
// asset pipeline) and attach window.Builder — there is NO gem/npm import.
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
if (this.builder) return // one instance per mount (Turbo re-connects)
this.builder = new window.Builder({
mainContainer: "#bjs-canvas", // REQUIRED — CSS selector string
widgetsContainer: "#bjs-widgets", // optional (null-tolerant)
settingsContainer: "#bjs-settings", // optional (null-tolerant)
outputMode: "page", // or "email" for Outlook-safe export
})
// load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl) — 4 args.
this.builder.load(window.THEME_JSON, window.THEME_TEMPLATES,
window.THEME_CONFIG_DATA, window.MEDIA_URL)
}
// Wire this to a save button: data-action="builder#save"
async save() {
const csrf = document.querySelector('meta[name="csrf-token"]').content
await fetch("/builder/save", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify({ data: this.builder.getData(),
html: this.builder.getHtml() }),
})
}
disconnect() {
// No destroy() exists — Turbo discards the DOM; just drop the reference.
this.builder = null
}
}
{{!-- app/views/builders/show.html.erb --}}
{{!-- <link rel="stylesheet" href="/builder/builder.css"> in your layout --}}
{{!-- <div data-controller="builder"> --}}
{{!-- <div id="bjs-widgets"></div> --}}
{{!-- <div id="bjs-canvas"></div> --}}
{{!-- <div id="bjs-settings"></div> --}}
{{!-- <button data-action="builder#save">Save</button> --}}
{{!-- </div> --}}
It's a global window.Builder script, not a gem or an npm import
BuilderJS ships as a compiled browser bundle (builder.js + builder.css), not a published gem or ES module. Drop the two files into app/assets or public/builder/, then load them — a <link rel="stylesheet" href="/builder/builder.css"> and a <script src="/builder/builder.js"></script> in your layout, or pinned through importmap-rails / referenced via Propshaft. Loading the script attaches a single global, window.Builder. There is no import { Builder } from anything to write, because no such package or named export exists — your Stimulus controller references window.Builder directly. Keeping it a plain global is exactly what lets the same vanilla engine run identically in a Rails ERB view, a React app, or a static HTML file with no Rails-specific build wiring.
Mount in the Stimulus connect() lifecycle hook
Stimulus' connect() fires once the controller's element is in the DOM — the natural mount point for a client-side editor. Render three container nodes in your view and target the host element with a controller, for example <div data-controller="builder">, then in connect() call new window.Builder({ mainContainer: '#bjs-canvas', widgetsContainer: '#bjs-widgets', settingsContainer: '#bjs-settings' }). These options are CSS selector strings resolved with querySelector — not DOM nodes and not Stimulus targets. Only mainContainer is required; widgetsContainer and settingsContainer are optional and null-tolerant, so an omitted panel simply does not mount. The editor is client-side only and never runs during the ERB server render — Rails emits the divs, the browser builds the editor. Then builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL) paints a starting template using those four positional arguments in that exact order.
Persist with getData() → your own Rails route, with CSRF
The editor is JSON-first. builder.getData() returns a lossless snapshot of the canvas and builder.getHtml() returns the rendered markup; builder.load() restores the JSON byte-for-byte on the next edit. In Rails that means a post 'builder/save' route hitting a controller action that accepts the JSON (and the HTML if you want it) and writes to your model — a designs table with a jsonb column works well. Send Rails' CSRF token from the auto-injected csrf-token meta tag: read document.querySelector('meta[name="csrf-token"]').content and pass it as the X-CSRF-Token header on your fetch. The payload is your data in your database and the route is your controller; BuilderJS has no opinion about route-model binding, Pundit/CanCanCan policies, or which guard protects the editor.
Turbo navigation, double-connect, and no destroy()
With Turbo Drive, Stimulus controllers connect and disconnect as the page cache swaps, so connect() can fire more than once for the same logical screen. BuilderJS exposes no destroy(), teardown(), or dispose() method — there is genuinely nothing to tear down, because on disconnect Turbo discards the container DOM and the editor goes with it. Handle this honestly: guard with an instance flag so you construct exactly one Builder per connect, and in disconnect() just drop your reference (this.builder = null) and let the DOM be discarded. Never call builder.destroy() to 'reset' between Turbo visits — that method does not exist. If a cached page restores stale editor markup, mark the host frame data-turbo-permanent or render it inside a turbo-frame you refresh.
One editor for email and pages — owned outright
The same mounted builder produces two outputs via the outputMode option: 'page' emits responsive web/landing-page HTML, while 'email' runs an export pipeline that converts flexbox to nested tables, rasterizes SVG to PNG, applies @media mobile-stacking, and injects Outlook MSO conditionals with inlined CSS for client-safe email HTML. So a single Rails integration can power both a transactional-email template editor and a marketing landing-page editor — you flip one constructor option. BuilderJS is sold once on CodeCanyon (item 27146783, Regular $52 / Extended $99): you own the source, self-host the assets, and pay no monthly fee, no per-seat charge, and no usage metering, with no phone-home. Extend it without forking via Builder.registerElement for custom elements, widgets, and controls, plus i18n across 20+ locales.
Frequently asked questions
-
Is there a Rails gem or npm package for BuilderJS?
No. BuilderJS is a DOM-mount bundle (builder.js + builder.css), not a published gem or npm package. You serve the two files yourself (public/builder/ or the asset pipeline / importmap), load builder.js so it attaches window.Builder, and call new window.Builder({...}) from a Stimulus controller. There is no import { Builder } statement — reference the global directly. The same files work in plain HTML, React, Vue, or Rails unchanged.
-
Where in a Stimulus controller should I mount the editor?
In connect(). That hook fires once the controller's element is in the DOM, which is exactly when the editor's container divs exist for querySelector to find. Construct new window.Builder({ mainContainer, widgetsContainer, settingsContainer }) there with CSS selector strings (not Stimulus targets or DOM nodes), then call builder.load(...) with its four positional arguments to paint a template.
-
How do I handle CSRF when saving to a Rails route?
Read the token Rails injects with csrf_meta_tags: document.querySelector('meta[name="csrf-token"]').content, and send it as the X-CSRF-Token header on your fetch. POST builder.getData() (JSON) — and builder.getHtml() if you want the rendered markup — to your own post route, then persist it in a model, for example a jsonb column. BuilderJS imposes no storage format beyond the JSON it hands you.
-
Should I call builder.destroy() in the Stimulus disconnect() callback?
No. BuilderJS has no destroy(), teardown(), or dispose() method. On disconnect() Turbo/Stimulus discards the container DOM nodes and the editor with them, so just null your reference (this.builder = null). Guard connect() with an instance flag so Turbo navigations construct exactly one Builder per mount rather than stacking instances on the same containers.
-
Does BuilderJS send the email or host the page it builds in my Rails app?
No. BuilderJS is the embeddable editor only — it builds the email template or page markup and stops there. It does not send email, host or publish pages, run funnels, or do CRM. Your Rails app brings its own ESP (Action Mailer plus a delivery service) to send and its own host to publish. For email, the export pipeline produces Outlook-safe HTML you hand to your sending platform.