BuilderJS

Embed a Drag-and-Drop Builder in Astro with a client:only Island

BuilderJS is a framework-agnostic, vanilla-JavaScript drag-and-drop editor for HTML email and web pages that you self-host and embed inside your own app. Astro is built for the opposite of an editor — it ships zero JavaScript by default and hydrates only the islands you opt in. That mismatch is exactly why embedding BuilderJS in Astro has one correct answer and several wrong ones. BuilderJS is a stateful, DOM-mutating application: the moment it constructs, it queries the DOM, builds the widget palette and settings panels, and takes over the canvas. If Astro pre-renders or partially hydrates that markup, the server emits HTML the engine never produced and the client tries to reconcile a canvas it does not own — drag handles go dead, overlays misalign, and selection state vanishes. The fix is to mount BuilderJS as a `client:only` island so Astro skips server rendering entirely and ships it as a real interactive island. This page explains when to use `client:only` versus `client:load`, why partial hydration breaks the canvas, and how to persist a design with `getData()` to an Astro endpoint. One honest caveat up front: a heavy editor belongs on an admin or app route, not a static marketing page — Astro's strengths and an embedded editor pull in different directions, and you should put the island where that trade-off makes sense.

Mount it

---
  // src/pages/admin/editor.astro
  import Layout from '../../layouts/Layout.astro';
  import BuilderIsland from '../../components/BuilderIsland.jsx';
  ---
  <Layout>
    <!-- builder.css + builder.js are loaded in the layout <head>,
         which attaches window.Builder. There is NO npm import. -->
    <!-- client:only renders the editor in the browser ONLY — Astro
         does not pre-render it, so there is no canvas to reconcile. -->
    <BuilderIsland client:only="react" />
  </Layout>
  
  /* src/components/BuilderIsland.jsx */
  import { useEffect, useRef } from 'react';
  
  export default function BuilderIsland() {
    const mounted = useRef(false);
  
    useEffect(() => {
      if (mounted.current) return; // guard dev double-invoke
      mounted.current = true;
  
      const builder = new window.Builder({
        mainContainer: '#bjs-canvas',       // REQUIRED — CSS selector string
        widgetsContainer: '#bjs-widgets',   // optional
        settingsContainer: '#bjs-settings', // optional
      });
  
      // load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl) — 4 args
      builder.load(window.THEME_JSON, window.THEME_TEMPLATES, window.THEME_CONFIG_DATA, window.MEDIA_URL);
  
      // Save: POST builder.getData() JSON to an Astro endpoint.
      window.saveDesign = () =>
        fetch('/api/design', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(builder.getData()),
        });
      // No destroy() exists — React discards the container DOM on unmount.
    }, []);
  
    return (
      <>
        <div id="bjs-widgets"></div>
        <div id="bjs-canvas"></div>
        <div id="bjs-settings"></div>
      </>
    );
  }
  
  // src/pages/api/design.ts  (requires server/hybrid output)
  export const prerender = false;
  export async function POST({ request }: { request: Request }) {
    const design = await request.json(); // the getData() snapshot
    // ...persist `design` to your DB / object storage / file
    return new Response(JSON.stringify({ ok: true }), {
      headers: { 'Content-Type': 'application/json' },
    });
  }

client:only vs client:load — and why the canvas breaks under partial hydration

Astro's client directives decide how an island reaches the browser. `client:load` server-renders the component to HTML first, then hydrates that same markup on the client — the framework expects the server output and the client render to match. `client:only` skips the server entirely: Astro emits a placeholder, ships the component's JavaScript, and renders it for the first time in the browser. For BuilderJS you want `client:only`. The engine is a stateful DOM-mutating app — when `new Builder({...})` runs it queries your containers, injects the widget palette, builds the settings panel, and rewrites the canvas DOM in place. There is no server-equivalent of that output to pre-render. Under `client:load`, Astro renders the wrapper on the server (where `window.Builder` and the live DOM do not exist), sends that stale HTML, then tries to reconcile it against what the engine mutates on the client. The reconciliation fights the editor: drag-and-drop handles detach, canvas overlays misalign against nodes that moved, and the hydration mismatch can throw outright. `client:only` sidesteps the whole problem because there is nothing to reconcile — the editor renders once, in the browser, and owns its DOM from the first paint.

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

BuilderJS ships as a compiled browser bundle, not an ES module — there is no `import { Builder }` and no package to install. Self-host the two files and load them with a stylesheet link and a script tag: `<link rel="stylesheet" href="/builderjs/builder.css">` and `<script src="/builderjs/builder.js"></script>`. Loading the script attaches a single global, `window.Builder`. In Astro the cleanest place for those tags is your layout's `<head>` (or a `<Fragment slot>` in the page), so the global is present before the island's framework component runs. The constructor reads CSS selector strings, not DOM nodes or framework refs: `new window.Builder({ mainContainer: '#bjs-canvas', widgetsContainer: '#bjs-widgets', settingsContainer: '#bjs-settings' })`. Only `mainContainer` is required; the other two are optional sidebars that simply stay unmounted if their selector resolves to nothing. Because the engine is plain vanilla JS attached to the window, the same bundle drops into the island whether you author it as a React, Vue, Svelte, or Solid component — Astro just needs to treat it as `client:only` so that the framework wrapper, and the engine inside it, only ever execute in the browser.

Building the client:only island

Author the editor as a small framework component (React, Vue, Svelte — whatever your Astro integration uses) and render it in your `.astro` page with the `client:only` directive, naming the framework: for example `<BuilderIsland client:only="react" />`. Astro needs the explicit framework hint because, with no server render, it cannot infer which renderer to hydrate with. Inside the component, render the three container nodes — `#bjs-widgets`, `#bjs-canvas`, `#bjs-settings` — and construct the editor in the framework's mount hook (`useEffect` in React, `onMount` in Svelte, `onMounted` in Vue) so the DOM exists first. BuilderJS exposes no `destroy()`, `teardown()`, or `dispose()` method, so there is nothing to clean up — on unmount the framework discards the container DOM and the editor goes with it. The one thing to guard is constructing twice: in React 18 Strict Mode (or any dev double-invoke), set a `useRef` flag and return early if it is already set, so `new window.Builder(...)` runs exactly once. Never call `builder.destroy()` — that method does not exist.

Persisting getData() JSON to an Astro endpoint

BuilderJS is the editor only — it does not send email, host pages, or publish anything; you bring your own storage, sending, and hosting. Saving a design is plain JSON. Call `const json = builder.getData()` to get a serializable snapshot of the current design, then POST it to an Astro server endpoint — a file like `src/pages/api/design.ts` exporting a `POST` handler — which writes it to your database, object storage, or a file. To restore, fetch that JSON and feed it back through `builder.load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl)`: exactly four positional arguments, in that order — the saved theme/design JSON, the theme templates map, the theme config data (fonts, colors, sizes), and the base media URL for relative asset paths. Passing your saved `getData()` output back as the first argument reconstructs the exact design. The same `load()` call also seeds a fresh editor with a starter template on first paint. There is no built-in autosave or server sync — the transport and storage layer are entirely yours, which is the point: BuilderJS hands you the JSON and consumes the JSON, and your app decides where it lives and how the finished email or page gets delivered. Note that an Astro endpoint requires a server or hybrid output mode (an adapter); a fully static build has no place to POST to.

Honest fit: an admin route, not a marketing page

Astro's whole pitch is shipping little or no JavaScript so content pages stay fast. An embedded drag-and-drop editor is the opposite kind of workload — a heavyweight, stateful, fully interactive application. Putting a `client:only` editor island on a public marketing page works technically but fights the framework's grain: it adds a large client bundle to a route that should be near-static, and there is no SEO or first-paint benefit to a tool only logged-in users touch. The honest, realistic placement is an admin or app route — a dashboard screen inside your product where users design their emails and pages — served from Astro's server or hybrid output so you also get the endpoint to persist `getData()` to. Used that way, Astro's island model is genuinely a good host: the rest of the admin shell stays light, and the one route that needs the editor pays for it explicitly. You own the source under a one-time CodeCanyon license (Regular $52 / Extended $99), self-host `builder.js` and `builder.css` from your own origin, and pay no SaaS subscription, monthly fee, or per-seat charge — so the only ongoing cost is the server you already run.

Frequently asked questions

Explore