How to Embed a Drag-and-Drop Builder in Remix
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 Remix app. It is the editor you own outright, not a SaaS — no monthly fee, no per-seat pricing, no hosted backend to depend on. The single fact that shapes everything in Remix is this: BuilderJS is a client-only DOM-mount bundle that attaches a global window.Builder, and Remix renders every route on the server first. So the whole job is keeping the editor out of the server render while letting the rest of the route — its loader and action — do the data work Remix is good at. This page shows the Remix-correct pattern: wrap the editor in a ClientOnly boundary (or split it into a .client module) so the engine never executes during SSR, load a saved design through the route loader, and persist getData() JSON back through a Remix action. It also explains, honestly, why React.lazy and a generic "dynamic import" do not actually solve the SSR problem here — ClientOnly does. Everything matches the real BuilderJS API, so you can copy, adapt, and ship. BuilderJS builds and exports the markup; your Remix app owns storage, sending, and hosting.
Mount it
// app/routes/builder.tsx — Remix (or React Router v7 Framework Mode)
import { useEffect, useRef } from "react";
import { ClientOnly } from "remix-utils/client-only";
import { useLoaderData, useFetcher } from "@remix-run/react";
// LOADER (server) — read the saved design from YOUR datastore.
export async function loader() {
const saved = await db.getDesign("my-page"); // your own storage
return saved ?? null; // a prior getData() snapshot, or null for a fresh start
}
// ACTION (server) — persist getData() JSON + getHtml() markup.
export async function action({ request }) {
const form = await request.formData();
await db.saveDesign("my-page", {
data: JSON.parse(String(form.get("data"))), // round-trip JSON
html: String(form.get("html")), // rendered markup
});
return { ok: true };
}
// Client-only editor — window.Builder is loaded globally (root.tsx),
// NOT imported. ClientOnly guarantees this never runs during SSR.
function BuilderEditor() {
const saved = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const ref = useRef<any>(null);
useEffect(() => {
if (ref.current) return; // guard Strict-Mode double-invoke; no destroy() exists
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, in order.
builder.load(saved ?? THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
ref.current = builder;
}, [saved]);
const save = () => {
const b = ref.current;
fetcher.submit(
{ data: JSON.stringify(b.getData()), html: b.getHtml() },
{ method: "post" }
);
};
return (
<>
<button onClick={save}>Save</button>
<div id="bjs-widgets" />
<div id="bjs-canvas" />
<div id="bjs-settings" />
</>
);
}
export default function BuilderRoute() {
// children-as-function => editor subtree is never built on the server.
return <ClientOnly fallback={<div>Loading editor…</div>}>{() => <BuilderEditor />}</ClientOnly>;
}
It's a global window.Builder script, not an npm import
BuilderJS ships as a compiled browser bundle, not an ES module, and there is no npm package to install. You load it once with two tags — <link rel="stylesheet" href="/builderjs/builder.css"> and <script src="/builderjs/builder.js"></script> — which attaches a single global, window.Builder. In Remix, the cleanest place for these is your root.tsx <Links> / <Scripts> region or a plain <script> in the document shell, served from wherever you self-host the dist files (the live demo serves them under /builderjs/). Do NOT write import { Builder } from 'builderjs'; there is no module and no named export, and that line will fail to resolve. At runtime you reference the global directly: new window.Builder({ ... }). Because the bundle is self-hosted and bundler-agnostic, the same vanilla engine runs identically whether your route is served by Remix on Node, on an edge runtime, or after the React Router v7 migration.
Wrap it in ClientOnly so SSR never touches window.Builder
Remix renders routes on the server, where there is no window and no DOM — so any module that calls new window.Builder({...}) at mount time will crash SSR, or hydration will mismatch. The Remix-correct fix is the ClientOnly component from remix-utils: it renders a fallback on the server and during the first client paint, then renders its children only after hydration, in the browser, where the DOM and window.Builder exist. Use it as <ClientOnly fallback={<div>Loading editor…</div>}>{() => <BuilderEditor />}</ClientOnly>. The children are a function so the editor subtree is never even constructed during SSR. If you prefer no extra dependency, the same effect comes from a .client.tsx module: Remix treats any *.client file as browser-only and stubs it to empty on the server, so importing your editor from editor.client.tsx guarantees it is excluded from the server bundle entirely. Either tool produces the same guarantee — the engine runs only in the browser.
Why React.lazy / a plain dynamic import isn't enough
It is tempting to reach for React.lazy(() => import('./BuilderEditor')) or a generic dynamic import and assume that defers the editor past SSR. It does not. React.lazy is about code-splitting, not about skipping the server render: under Remix's streaming SSR the lazy boundary can still be rendered on the server (and even when it suspends, you have not actually prevented the editor module from being evaluated where window is undefined). A bare dynamic import inside a component runs in whatever environment the component runs in — including the server during the initial render. Neither one gives you the one thing you need: a hard guarantee that 'this subtree exists only in the browser, after hydration.' That guarantee is exactly what ClientOnly (render-after-hydration boundary) and the .client module convention (server-excluded module) provide. The honest rule of thumb: use lazy/dynamic to split bundles; use ClientOnly or .client to enforce client-only execution. For a DOM-mount engine like BuilderJS, you want the latter.
Persist with the action, rehydrate with the loader
This is where Remix earns its keep. In your route loader, read the saved design from your own datastore (database, object storage, a file) and return it: the loader runs on the server, so it can talk to your DB directly and hand the JSON to the page. On the client, after ClientOnly mounts the editor, instantiate inside useEffect and seed the canvas: const builder = new window.Builder({ mainContainer: '#bjs-canvas', widgetsContainer: '#bjs-widgets', settingsContainer: '#bjs-settings' }), then builder.load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl) — exactly four positional arguments, in that order. Pass the snapshot your loader returned (from a prior getData()) as the first argument to rehydrate the exact design. To save, call const data = builder.getData() (the round-trip JSON) and const html = builder.getHtml() (the rendered markup), then submit them to a Remix action with fetcher.submit or a fetch POST; the action persists them server-side and you control the storage layer entirely. Note the constructor options are CSS selector strings — not refs, not DOM nodes — and mainContainer is required while the other two are optional. BuilderJS exposes no destroy(): on unmount Remix discards the container DOM and the editor goes with it, so guard against a dev double-invoke with a ref flag and never call builder.destroy(), which does not exist.
Remix is now React Router v7 Framework Mode — same pattern applies
As of 2024–2025, Remix's framework features have merged into React Router v7's 'Framework Mode,' and new projects increasingly start there rather than on the standalone Remix package. The good news: nothing in this guide changes. Framework Mode keeps the same server-first rendering model, the same loader/action data conventions, and the same *.client module convention, and ClientOnly from remix-utils continues to work the same way. So whether you are on Remix today or on React Router v7 Framework Mode, the BuilderJS mount is identical — keep the editor in a client-only boundary, seed it from the loader, persist getData() through the action. And the deeper reason it all stays portable: BuilderJS itself is plain vanilla JS with no framework coupling. You self-host builder.js and builder.css under a one-time CodeCanyon license (Regular $52 / Extended $99) with no SaaS, monthly fee, or per-seat charge, so the editor outlives whatever your routing layer is called this year.
Frequently asked questions
-
Why use ClientOnly instead of React.lazy or a dynamic import to embed BuilderJS in Remix?
Because lazy and dynamic import only split bundles — they do not guarantee the editor runs solely in the browser. Under Remix's server-first (and streaming) rendering, a lazy boundary or a dynamic-imported module can still be evaluated on the server, where window and the DOM do not exist, so new window.Builder(...) crashes or causes a hydration mismatch. ClientOnly (from remix-utils) renders its children only after hydration in the browser, and the .client.tsx convention excludes a module from the server bundle entirely. Either one gives the hard client-only guarantee BuilderJS needs; lazy/dynamic do not.
-
Is there a Remix or npm package for BuilderJS?
No. BuilderJS is a self-hosted, framework-agnostic browser bundle that attaches window.Builder when builder.js loads. There is no npm package and no module export, so never write import { Builder } from anything. You add the script and stylesheet (for example via root.tsx), then call new window.Builder(...) on the client inside a ClientOnly boundary or a .client module.
-
How do I save a design in Remix and load it back?
Use Remix's own data flow. In the route loader, read the saved design from your datastore and return it; on the client, pass it as the first argument to builder.load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl) — four positional arguments in that exact order. To save, call builder.getData() (round-trip JSON) and builder.getHtml() (rendered markup) and submit them to a Remix action (fetcher.submit or fetch POST), which persists them server-side. BuilderJS does not store designs for you — storage and transport are yours.
-
Does the pattern still work now that Remix is React Router v7 Framework Mode?
Yes, unchanged. React Router v7 Framework Mode keeps the same server-first rendering, the same loader/action conventions, and the same *.client module convention, and ClientOnly continues to work. The BuilderJS mount is identical on either: client-only boundary, seed from the loader, persist getData() through the action. BuilderJS is vanilla JS with no framework coupling, so it is unaffected by the rename.
-
Do I need to call destroy() when the Remix route unmounts?
No — BuilderJS has no destroy(), teardown(), or dispose() method, so never call builder.destroy(). When Remix unmounts the route it discards the container DOM nodes and the editor goes with them; there is nothing to clean up. The only thing to guard is a development double-invoke (React Strict Mode), which you handle with a ref flag so the constructor runs exactly once.
-
Does BuilderJS send the email or host the page it builds?
No. BuilderJS is the embeddable editor only — it does not send email, host or publish pages, run funnels, or do CRM. It builds the design and exports markup with getHtml() and the JSON snapshot with getData(); your Remix app's action persists it and your own backend handles sending and hosting. That clean split is by design so you keep full control of delivery and storage.