Embed a Drag-and-Drop Builder in SvelteKit (SSR-Safe)
BuilderJS is a framework-agnostic, vanilla-JavaScript drag-and-drop editor for HTML email and web pages that you license once, self-host, and embed directly inside your own app. There is no Svelte component to npm install and no module to import — the engine ships as a compiled browser bundle that attaches a global window.Builder when its script loads. That global is also the one thing that makes SvelteKit interesting: SvelteKit server-renders your components by default, and window does not exist on the server. Touch window.Builder during SSR and the render throws ReferenceError: window is not defined before a single byte reaches the browser. This page is the SSR-safe recipe specifically for SvelteKit, and it is distinct from the plain-Svelte guide: here the whole story is keeping the editor off the server. The short version is that onMount is the only safe place to instantiate the builder, because onMount never runs during server-side rendering — it fires only after the component hydrates in the browser. We will cover why a top-level instantiation in +page.svelte crashes SSR, the two correct ways to gate it (onMount plus the browser flag, or a dynamic import inside onMount), how to bind the three container nodes, and how to save getData() JSON through a +server.js POST route and restore it with load() on hydrate. Everything matches the real BuilderJS API. BuilderJS is the editor only — it does not send email, host pages, or run a backend, so your SvelteKit app keeps full ownership of persistence, upload, auth, and delivery.
Mount it
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment'; // true only in the browser
let savedDesign; // optional: pass in from a SvelteKit load() as a prop
onMount(() => {
// onMount never runs during SSR — the only safe place to touch window.Builder.
if (!browser) return; // defensive; onMount already guarantees client-only
// Pass CSS SELECTOR STRINGS — the constructor runs querySelector on them.
// Do NOT pass bind:this nodes; the engine wants selectors, not DOM refs.
const builder = new window.Builder({
mainContainer: '#bjs-canvas', // required — CSS selector string
widgetsContainer: '#bjs-widgets', // optional
settingsContainer: '#bjs-settings' // optional
});
// Restore on hydrate: load() takes 4 positional args, in this exact order.
if (savedDesign) {
builder.load(savedDesign, themeTemplatesJson, themeConfigData, mediaUrl);
}
// Save: POST the canvas JSON to a +server.js route you own.
window.save = () =>
fetch('/builder/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(builder.getData())
});
// No destroy(): on navigation SvelteKit discards these container divs.
});
</script>
<div id="bjs-widgets"></div>
<div id="bjs-canvas"></div>
<div id="bjs-settings"></div>
<!-- src/routes/builder/save/+server.js
import { json } from '@sveltejs/kit';
export async function POST({ request }) {
const data = await request.json(); // builder.getData() payload
await saveToYourDb(data); // your storage — BuilderJS stores nothing
return json({ ok: true });
}
-->
<!-- Load the self-hosted bundle once in src/app.html:
<link rel="stylesheet" href="/builderjs/builder.css">
<script src="/builderjs/builder.js"></script>
-->
Why a +page.svelte instantiation crashes SSR
SvelteKit renders every route on the server first, then hydrates it in the browser. The instructions inside a component's <script> top level run in both environments. So if you write new window.Builder(...) at the top level of +page.svelte — or in a module-level statement, or in a $: reactive block that evaluates on the server — that line executes during SSR, where window is undefined. The render aborts with ReferenceError: window is not defined and the page 500s before hydration. The same trap catches you if you import the bundle as an ES module at the top of the file or reference any browser-only global (document, localStorage) outside a client-only guard. The mental model: treat window.Builder as strictly browser-only state. It must never be read while SvelteKit is rendering HTML on the server. This is the single most common SvelteKit integration failure, and it is not a BuilderJS quirk — it is how SSR works. The fix is not to disable SSR for the whole app; it is to defer the one client-only line until after hydration.
onMount is the only safe place to touch window.Builder
Svelte's onMount lifecycle hook fires exactly once, in the browser, after the component's DOM is in the document — and crucially it never runs during server-side rendering. That makes it the correct and only safe place to instantiate the editor in SvelteKit. Inside onMount you call new window.Builder({ mainContainer, widgetsContainer, settingsContainer }), passing CSS selector strings (not DOM nodes, not Svelte refs) that point at the three container divs you rendered in the component markup. The constructor resolves each container by running document.querySelector on the string you pass, which is why a Svelte ref obtained with bind:this is the wrong shape here — hand it a selector like '#bjs-canvas', not a node. mainContainer is required and resolves the canvas; widgetsContainer and settingsContainer are optional sidebars, so a selector that resolves to nothing simply leaves that panel unmounted. For an extra layer of defensiveness, import the browser flag from $app/environment and early-return when it is false; onMount alone already guarantees client-only execution, but the explicit if (!browser) return; documents intent and protects any helper code you factor out. The companion code block shows the browser guard plus the selector-string containers wired to the divs in the markup. There is no destroy() method on the instance — on a route change SvelteKit discards the component's DOM and the editor goes with it, so leave the onMount return (cleanup) empty and never call builder.destroy(), which does not exist.
Two ways to keep the bundle off the server: tag vs dynamic import
You have two clean options for loading the engine, and both keep window.Builder out of the SSR path. The simplest is a plain script tag in src/app.html — <link rel="stylesheet" href="/builderjs/builder.css"> and <script src="/builderjs/builder.js"></script> — served from your own origin (self-hosting is the whole point of the one-time license). The browser attaches window.Builder before your component hydrates, and onMount finds it ready. The second option, useful if you do not want the bundle on routes that never show an editor, is a dynamic import inside onMount: await import('/builderjs/builder.js') runs only in the browser because onMount only runs in the browser, so the import is never evaluated during SSR. The bundle is an IIFE that sets the global as a side effect, so importing it for effect is enough — onMount then reads window.Builder. Either way the rule is identical: the bundle is browser-only and must not be a top-level ES import in a component that server-renders. Do not write import { Builder } from 'builderjs' — there is no npm package and no named export; that line both fails to resolve and would pull a browser bundle into the server render.
Save getData() via +server.js, load() on hydrate
BuilderJS does not persist, send, or host anything — it is the editor, and your SvelteKit backend owns storage. Saving is one call: builder.getData() returns a plain serializable object describing the entire design. POST that JSON to a route you own. In SvelteKit the idiomatic endpoint is a +server.js file exporting a POST handler that reads await request.json() and writes to your database, returning json({ ok: true }). On the client, you fetch that endpoint from a save button bound in your component. (The engine also ships a built-in builder.save() that POSTs to a saveUrl you pass in the constructor, but hand-rolling the fetch is cleaner when you need a CSRF token or a per-record slug in the path.) Restoring is the mirror: in onMount, after the instance exists, call builder.load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl) with exactly four positional arguments in that order — your saved getData() output (or a starter theme), the theme templates JSON, the theme config data, and the media base URL for resolving relative asset paths. Because load() runs inside onMount it is automatically client-only, so hydrating a saved design never touches the server render. You can fetch the saved JSON in a SvelteKit load function and pass it as a prop, or fetch it client-side in onMount — either keeps the four-argument load() call on the browser where it belongs.
Own the source with a one-time license
BuilderJS is sold under a one-time CodeCanyon license (Regular $52 / Extended $99). You get the source, self-host builder.js and builder.css from your own origin, and embed the editor in as many of your own SvelteKit screens as the license tier allows. There is no SaaS subscription, no monthly bill, no per-seat charge, and no usage metering — nothing phones home at runtime, which keeps your app private and removes a vendor dependency from your render path. Because you serve the bundle yourself, there is no external service for SvelteKit to wait on, and the editor stays fast and fully under your control. When a design is ready to ship, BuilderJS exports standalone HTML for pages and inline-CSS, client-safe HTML for email; your SvelteKit app then owns whatever happens next — saving, hosting, or handing the HTML to your own sending pipeline.
Frequently asked questions
-
Why does my SvelteKit build crash with "window is not defined"?
Because something touched window.Builder (or another browser global) during server-side rendering. SvelteKit renders components on the server first, where window does not exist. Move every reference to the builder inside onMount, which only runs in the browser after hydration, and never instantiate it at the top level of +page.svelte or as a top-level ES import. Optionally add an if (!browser) return; guard using the browser flag from $app/environment.
-
Can I just instantiate the builder at the top of +page.svelte?
No. Top-level <script> code in a Svelte component runs during SSR as well as in the browser, so new window.Builder(...) there throws on the server before the page ever loads. The instantiation must live inside onMount, the one lifecycle hook SvelteKit guarantees runs only client-side.
-
Should I disable SSR for the whole route?
You do not need to. onMount already defers the editor to the browser, so SSR can stay on for the rest of the page and you keep its SEO and first-paint benefits. Disabling SSR is a heavier hammer than the problem requires; gating the one client-only call with onMount (and optionally the browser flag) is the precise fix.
-
How do I save and load designs in SvelteKit?
Save with builder.getData(), which returns a serializable object, and POST it to a +server.js endpoint that exports a POST handler reading await request.json(). Restore with builder.load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl) — four positional arguments in that order — called inside onMount so it runs only after hydration. BuilderJS stores nothing for you; your backend owns the round-trip.
-
Do I pass Svelte refs (bind:this) as the containers?
No. The constructor resolves each container by calling document.querySelector on the value you pass, so it expects a CSS selector string such as '#bjs-canvas', not a DOM node. A node captured with bind:this is the wrong shape. Give the three divs ids and pass their selectors as mainContainer (required), widgetsContainer, and settingsContainer (both optional).
-
Is there a SvelteKit or Svelte npm package for BuilderJS?
No. BuilderJS ships as a self-hosted browser bundle that attaches a global window.Builder when builder.js loads. There is no npm package and no named export, so you never write import { Builder } from '...'. Load the script (a tag in app.html, or a dynamic import inside onMount) and call new window.Builder(...). This is what keeps it framework-agnostic.