BuilderJS

Embed a drag-and-drop JS editor in Blazor without the render tree clobbering it

The hard part of putting a DOM-mutating JavaScript editor inside Blazor — Server or WASM — is not loading the script. It is stopping Blazor's diffing renderer from clobbering the editor's own DOM. BuilderJS is a framework-agnostic, vanilla-JavaScript drag-and-drop editor for HTML email and pages that you self-host and embed in your own app, and it builds its entire chrome by mutating the DOM imperatively. Blazor, meanwhile, owns a virtual render tree and reconciles real DOM against it on every render. The moment those two touch the same nodes, the editor breaks. The pattern that works is simple and well-worn: give BuilderJS a plain container div that Blazor renders once and never re-renders into, then mount window.Builder exactly one time from OnAfterRenderAsync(firstRender) by calling a tiny JS init module through IJSRuntime.InvokeVoidAsync. To save, you read getData() back across the interop boundary into C# and POST it from your own backend. Be clear-eyed up front: there is no BuilderJS Blazor component or NuGet package — this is the standard JSInterop pattern for any imperative JS library, applied correctly. BuilderJS is the editor you embed; it does not send email, host pages, or publish anything. This page shows the exact, accurate interop so it works the first time.

Mount it

// wwwroot/js/builder-interop.js — the ONLY JS you write.
  // builder.js + builder.css are loaded in your host page (index.html / _Host),
  // which attaches the global window.Builder. There is no NuGet / npm import.
  let builder = null;
  
  export function initBuilder() {
    if (builder) return; // mount exactly once
    builder = new window.Builder({
      mainContainer: '#bjs-canvas',       // REQUIRED — CSS selector string
      widgetsContainer: '#bjs-widgets',   // optional — CSS selector string (null/omit to skip)
      settingsContainer: '#bjs-settings', // optional — CSS selector string (null/omit to skip)
    });
    // load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl[, onLoaded])
    // 4 positional data args; optional 5th callback fires once the design mounts.
    builder.load(
      window.THEME_JSON, window.THEME_TEMPLATES, window.THEME_CONFIG_DATA, window.MEDIA_URL,
      function () { /* design mounted — safe to enable Save, etc. */ }
    );
    // No destroy() exists; Blazor discards the container divs on unmount.
  }
  
  export function getData() {
    return JSON.stringify(builder.getData()); // marshalled back to C# as a string
  }
  
  /* ---- BuilderEditor.razor (C#) ----
  @implements IAsyncDisposable
  @inject IJSRuntime JS
  
  @* Static containers Blazor renders ONCE and never diffs into. *@
  <div id="bjs-widgets"></div>
  <div id="bjs-canvas"></div>
  <div id="bjs-settings"></div>
  
  @code {
      private IJSObjectReference? _module;
  
      protected override async Task OnAfterRenderAsync(bool firstRender)
      {
          if (!firstRender) return;            // construct exactly once
          _module = await JS.InvokeAsync<IJSObjectReference>(
              "import", "./js/builder-interop.js");
          await _module.InvokeVoidAsync("initBuilder");
      }
  
      // Call from a Save button: read editor JSON, POST from your own backend.
      public async Task<string> GetDesignJsonAsync()
          => await _module!.InvokeAsync<string>("getData");
  
      public async ValueTask DisposeAsync()
      {
          if (_module is not null) await _module.DisposeAsync(); // dispose the interop handle, not the editor
      }
  }
  */

Keep the editor's DOM out of Blazor's render tree

This is the whole game, so internalize it before writing any code. Blazor builds a render tree from your .razor markup and, on every render, diffs that tree against the live DOM and patches the differences. BuilderJS does the opposite: it queries a container and imperatively builds the drag-drop canvas, widget palette, and settings panel inside it. If Blazor ever re-renders the subtree the editor lives in, it will reconcile against a virtual tree that knows nothing about the nodes BuilderJS injected — and rip them out. The fix is to give BuilderJS containers Blazor treats as opaque and static. Render three plain divs in your component (<div id="bjs-widgets"></div>, <div id="bjs-canvas"></div>, <div id="bjs-settings"></div>) and then never put dynamic Razor inside or around them: no @if, no @foreach, no @bind, no interpolated content that changes after first render. Because nothing in that markup changes, Blazor's diff is a no-op there and it leaves the editor's DOM untouched. A common extra guard is to wrap the containers in a parent with @key set to a constant and no conditional siblings, so the subtree is never recreated. Treat those three divs as a handoff: Blazor places the empty containers, then steps back and lets BuilderJS own everything inside them.

It's a global window.Builder script — not a NuGet package or npm import

BuilderJS ships as a compiled browser bundle, not a .NET package and not an ES module. You self-host two files and load them on the page: <link rel="stylesheet" href="/builderjs/builder.css"> and <script src="/builderjs/builder.js"></script>. In Blazor that means adding both to wwwroot/index.html (WebAssembly) or _Host.cshtml / App.razor's host page (Server) — the same place you load any other static JS. Loading builder.js attaches a single global, window.Builder. There is no BuilderJS NuGet package, no Razor class library, and no import to write — searching NuGet for it will find nothing, by design, because the editor is framework-agnostic. The cleanest interop shape is to put your mount logic in a small JS init module (for example wwwroot/js/builder-interop.js) that exports an initBuilder() function, then call it from C# with module-based interop. That module is the only JavaScript you write; it calls new window.Builder({...}) and hands back what C# needs. Keeping the mount in a JS module rather than inline strings means you can pass an IJSObjectReference back to C# and call into it later with strong typing.

Mount once in OnAfterRenderAsync(firstRender) via IJSRuntime

BuilderJS is a client-side DOM-mount bundle: it can only run after its container divs are actually in the live DOM, which in Blazor means after the first render. The correct lifecycle hook is OnAfterRenderAsync(bool firstRender) — never OnInitializedAsync, which runs before the DOM exists (and, on Blazor Server prerender, runs on the server where there is no browser at all). Guard on firstRender so you construct the editor exactly once even though the method fires after every render. Inside the guard, resolve your JS module with await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/builder-interop.js"), then call await module.InvokeVoidAsync("initBuilder") to run new window.Builder({ mainContainer: '#bjs-canvas', widgetsContainer: '#bjs-widgets', settingsContainer: '#bjs-settings' }). Those options are CSS selector strings that BuilderJS resolves with querySelector — not element references and not ElementReference handles, so do not try to marshal a Blazor @ref across the boundary; just pass the id selectors. mainContainer is required; widgetsContainer and settingsContainer are optional and their panels simply stay unmounted if you pass null or omit them. On Blazor Server specifically, JSInterop is unavailable during prerender, which is the second reason firstRender plus OnAfterRenderAsync is the right gate. Hold the IJSObjectReference module in a field so you can call back into it to read or load data.

Read getData() back into C# and POST it yourself

Persistence runs over the same interop bridge, in reverse. BuilderJS does not send, host, or publish — it is the editor only, so your C# owns storage and delivery. Keep the live Builder instance inside your JS init module (a module-scoped variable) and export a getData() wrapper that returns JSON.stringify(builder.getData()). From C# you pull it across with var json = await module.InvokeAsync<string>("getData"); — a plain string you then POST from your own backend (an ASP.NET controller, a minimal API endpoint, or HttpClient on WASM). To restore a design, send the saved snapshot the other way and have your module call builder.load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl) — four positional data arguments, in that order: the saved theme JSON, the theme templates JSON, the theme config data, and the media base URL. load() also accepts an optional fifth argument, a completion callback that fires once the design has mounted, which is the right place to enable a Save button or signal readiness back to C#. If instead you want JavaScript to push data into C# on an editor event, use a DotNetObjectReference and a [JSInvokable] method: pass DotNetObjectReference.Create(this) into initBuilder, and from JS call dotNetRef.invokeMethodAsync("OnDesignSaved", json) where OnDesignSaved is a public [JSInvokable] method on your component. Either direction is plain JSON across the boundary; the round-trip through getData()/load() is lossless, so what you save reloads exactly. Dispose your IJSObjectReference and DotNetObjectReference in DisposeAsync — note that is disposing the interop handles, not the editor: BuilderJS has no destroy() method, and when Blazor removes the container divs the editor goes with them.

Own the source with a one-time license — for email and pages

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 wwwroot, and embed the editor in your Blazor app with no SaaS account, no monthly fee, and no per-seat metering. The same embedded editor produces two outputs from one JSON model — modern-CSS HTML for web and landing pages, and Outlook-safe email HTML through the EmailExportPipeline (flexbox and grid converted to nested tables, MSO conditional comments and ghost tables, role=presentation, @media mobile stacking, fluid images, SVG rasterized to PNG, and a CSS inliner), with templates Litmus-tested across 22 email clients. To be exact about the boundary: BuilderJS builds the markup; for email you bring your own sending, and for pages you bring your own hosting. Because the bundle is served from your origin and the interop is standard JSInterop, there is no external runtime dependency on a vendor service, which keeps your Blazor app private and fully under your control.

Frequently asked questions

Explore