BuilderJS

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

Explore