BuilderJS

Embed a Drag-and-Drop Builder in Nuxt 3 (SSR-Safe)

BuilderJS is a framework-agnostic, vanilla-JavaScript drag-and-drop editor for HTML email and pages that you self-host and embed inside your own Nuxt 3 app. The mount itself is simple — render three container divs, then instantiate window.Builder in onMounted. The catch that trips up Nuxt developers is server-side rendering: by default Nuxt renders your components on the server first, and BuilderJS is a client-only DOM-mount bundle that touches window and document the moment it constructs. Drop a plain Vue wrapper into a Nuxt page unguarded and the server build throws the classic "window is not defined" — because onMounted may be safe, but the global script and any window reference are not part of the SSR-safe path you get for free. The correct Nuxt answer is to render the editor inside Nuxt's built-in <ClientOnly> component (or load the engine from a .client plugin), so the server never tries to render or execute it. This page shows that SSR-safe pattern, then persists the design as JSON to a Nitro /server/api route and re-hydrates it with the four-argument load(). Everything here matches the real BuilderJS API — no npm package, no module export, no fabricated methods. BuilderJS is the editor you embed; it does not send email, host pages, or publish anything, so you keep your own backend for delivery and storage.

Mount it

<!-- components/BuilderEditor.client.vue (or wrap usage in <ClientOnly>) -->
  <script setup>
  import { onMounted } from 'vue'
  
  let builder = null // window.Builder comes from a .client plugin / global script
  
  onMounted(async () => {
    if (builder) return // construct exactly once (guards HMR / re-invoke)
    builder = new window.Builder({
      mainContainer: '#bjs-canvas',       // required — CSS selector string
      widgetsContainer: '#bjs-widgets',   // optional
      settingsContainer: '#bjs-settings', // optional
    })
  
    // Re-hydrate: load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl)
    const saved = await $fetch('/api/design') // Nitro server route
    builder.load(saved.themeJson, saved.themeTemplates, saved.themeConfigData, saved.mediaUrl)
  
    // Persist: POST getData() JSON to your own Nitro route.
    window.saveDesign = () =>
      $fetch('/api/design', { method: 'POST', body: builder.getData() })
    // No destroy(): Nuxt discards these container divs on unmount.
  })
  </script>
  
  <template>
    <!-- In a page: <ClientOnly><BuilderEditor /></ClientOnly> -->
    <div id="bjs-widgets"></div>
    <div id="bjs-canvas"></div>
    <div id="bjs-settings"></div>
  </template>

Why a plain Vue wrapper is wrong here — and ClientOnly is right

A bare Vue 3 wrapper is fine in a pure SPA, but Nuxt 3 renders components on the server by default before hydrating them in the browser. BuilderJS is a client-only DOM-mount bundle: it reads window, queries the DOM, and builds the editor chrome the instant new window.Builder(...) runs. During server render there is no window and no document, so any path that reaches the engine on the server throws 'window is not defined' and breaks the build or the request. Putting the mount inside onMounted alone does not fully solve it — onMounted does only run client-side, but Nuxt still server-renders the component's template, the global script reference, and any top-level window access around it. The idiomatic Nuxt fix is to wrap the editor in Nuxt's built-in <ClientOnly> component, whose default slot is skipped entirely during SSR and only rendered after hydration. That is the difference from the plain /embed/vue/ pattern: in Nuxt you are not just deferring a function call, you are telling the framework not to server-render this subtree at all. <ClientOnly> is the right tool precisely because BuilderJS has no SSR path to preserve.

Two SSR-safe mounts: <ClientOnly> or a .client plugin

You have two clean, idiomatic options. First, the component approach: place your builder component inside <ClientOnly> in the page template, and instantiate window.Builder in onMounted within that component. The server emits nothing for that slot (you can pass a #fallback slot for a loading placeholder), and the engine only ever runs in the browser. Second, the plugin approach: load the global engine from a client-only plugin file named with the .client.ts suffix (for example plugins/builderjs.client.ts), which Nuxt bundles and executes exclusively on the client — never during SSR. Inject the builder bundle there so window.Builder is guaranteed present before any client mount. Either way, you still render the three container nodes — <div id="bjs-widgets">, <div id="bjs-canvas">, and <div id="bjs-settings"> — and pass them to the constructor as CSS selector strings: { mainContainer: '#bjs-canvas', widgetsContainer: '#bjs-widgets', settingsContainer: '#bjs-settings' }. Only mainContainer is required; the other two are optional sidebars. These are selector strings handed to document.querySelector — not Vue refs and not DOM nodes.

It's a global window.Builder script, not an npm import

BuilderJS ships as a compiled browser bundle, not an ES module, so there is no @builderjs package to npm install and no named export to import. You self-host builder.js and builder.css (for example under Nuxt's public/ directory, served from /builderjs/) and load them so the browser attaches a single global, window.Builder. Do NOT write import { Builder } from 'builderjs' — that module does not exist and will fail to resolve. In Nuxt the cleanest way to load the script is from your .client plugin or via useHead with a script entry scoped so it only runs client-side; pairing that with <ClientOnly> keeps the whole thing off the server render path. At runtime you reference new window.Builder(...) directly. Because the engine is plain vanilla JS attached to a global, the exact same bundle that runs in React, Svelte, or a static PHP page runs unchanged in Nuxt — Nuxt's only added responsibility is making sure it never executes on the server.

Persist getData() JSON to a Nitro /server/api route, re-hydrate with load()

Nuxt ships its own server engine, Nitro, so you do not need a separate backend just to store designs. Add a route handler under server/api (for example server/api/design.post.ts with defineEventHandler) that reads the posted body and writes it to your database or object storage, plus a GET handler that returns it. On the client, call const json = builder.getData() to get the full serializable snapshot of the editor state, then send it with $fetch('/api/design', { method: 'POST', body: json }). To re-hydrate, fetch the stored snapshot and call builder.load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl) — exactly four positional arguments, in that order: the saved theme/page tree JSON, the theme templates map, the theme config data (fonts, colors, sizes), and the base media URL for relative assets. Pass your saved getData() output back as the first argument and the design reconstructs exactly. There is no built-in autosave or vendor sync: BuilderJS hands you JSON and consumes JSON, and your Nitro route owns where it lives. BuilderJS does not send, host, or publish — you bring your own sending and hosting.

No destroy() — instantiate once, let Nuxt discard the DOM

BuilderJS exposes no destroy(), teardown(), or dispose() method, so never call builder.destroy() in onBeforeUnmount or onUnmounted — there is nothing to dispose. When Nuxt unmounts the component (route change, or the <ClientOnly> subtree leaving), Vue removes the container DOM nodes and the editor goes with them; the garbage collector reclaims the instance once nothing references it. The single thing to guard against is constructing two instances on the same containers, which can happen with hot module replacement during dev or if a mount hook re-runs. Keep a simple flag (let builder = null; if (builder) return) so new window.Builder(...) runs exactly once per mounted component. Treat the instance as single-use per mount: build it in onMounted, hold a reference if you need getData() or load() again, and let unmount discard the DOM. You own the source under a one-time CodeCanyon license (Regular $52 / Extended $99) — self-hosted, with no SaaS, monthly fee, or per-seat pricing.

Frequently asked questions

Explore