BuilderJS

The email-template JSON schema, explained — store it, transform it, generate it

Before you commit a builder to your product, you want to see the data model — not a marketing diagram of it, the actual shape your database will hold. This page is that reference. BuilderJS is a JSON-first, embeddable drag-and-drop editor for HTML email and web pages: every design your users build is a plain JSON tree returned by getData(), and the editor is source you own and self-host, not a SaaS. So the schema below is not an opaque export format you have to reverse-engineer — it is the real, in-memory document the editor serializes, and because you own the source you can read it, diff it, migrate it, and generate it programmatically. The top-level object is small and predictable: a theme name, the root element's name and template, its formats, the page-level layout settings, and a blocks array that holds the design tree. Each node in that tree follows the same handful of keys, so once you understand one element you understand all of them. This is distinct from the broader JSON-builder overview — here we annotate the document field by field, walk the nesting from page to block to cell to leaf element, enumerate the roughly 30 element types you'll encounter, and explain the round-trip and conditional-emit rules that make a saved design safe to diff in code review and rewrite with a migration script. Everything described matches the real BuilderJS API and the actual saved JSON; nothing here is invented.

Mount it

// Walk a saved BuilderJS design and read its schema generically.
  // `design` is the object returned by builder.getData().
  //
  // Tree contract (checked by BuilderJS on load() — non-fatal, logs a
  // path-pointed warning if something is off):
  //   PageElement                      -> blocks[]
  //   BlockElement                     -> elements[]
  //   GridElement / PricingCardsElement-> cells[]
  //   CellElement / FormContainerCell  -> blocks[]
  //   (leaf elements: name + template + optional formats + type fields)
  
  const design = builder.getData();
  
  console.log('theme:', design.theme);     // theme name
  console.log('root: ', design.name);      // "PageElement"
  console.log('width:', design.container_width);
  
  // Child keys, by where each element type stores its children.
  const childKeys = ['blocks', 'elements', 'cells'];
  
  function walk(node, depth = 0) {
    // Every node speaks the same base dialect: name + template (+ formats).
    console.log(
      '  '.repeat(depth) + node.name +
      (node.formats ? '  (formats: ' + Object.keys(node.formats).join(', ') + ')' : '')
    );
  
    for (const key of childKeys) {
      if (Array.isArray(node[key])) {
        for (const child of node[key]) walk(child, depth + 1);
      }
    }
  }
  
  walk(design);
  
  // Example migration: rename a color across every styled node, in place.
  // Re-saving emits `formats` only where it already exists, so unchanged
  // nodes round-trip byte-identical and the git diff stays minimal.
  function recolor(node, from, to) {
    if (node.formats && node.formats.background_color === from) {
      node.formats.background_color = to;
    }
    for (const key of childKeys) {
      if (Array.isArray(node[key])) node[key].forEach(c => recolor(c, from, to));
    }
  }
  
  recolor(design, '#ffffff', '#f7f7f7');
  // Persist `design` to YOUR backend (MySQL/Postgres/SQLite/S3/file),
  // then rehydrate later: builder.load(design, templatesJson, themeConfig, mediaUrl)

The top-level object: theme, name, template, formats, blocks

Call builder.getData() and you get one JSON object describing the whole design. The keys are stable and few. `theme` is the theme name the design was built against (BuilderJS merges it from the loaded theme config). `name` is the root element's class name — for a full document this is `PageElement`. `template` is the template identifier that node renders with. `formats` is a flat map of the root's style values: background_color, padding_top, padding_bottom, padding_left, padding_right. Then come the page-level layout fields that belong only to the root: `page_title`, `block_gap`, `container_width`, the four `block_padding_*` values, and `default_block_background_color`. Finally, `blocks` is an array — the design tree itself. That's the entire top level: a little metadata, a little page styling, and the tree. There is no proprietary wrapper, no encoded blob, no versioned migration header you can't read. It is the document you'd design by hand if you were writing one, which is exactly why you can read it, store it as a JSON/text column, and inspect it in a pull request.

Every node speaks the same dialect: name + template + formats

The tree is uniform, and that uniformity is the whole reason it's tractable. Every element node — root, block, cell, or leaf — serializes with the same base keys: `name` (the registered element type, e.g. `BlockElement`, `HeadingElement`, `PElement`, `ImageElement`) and `template` (which template HTML it renders through). If the element carries styling, it adds a `formats` map; if it holds text or content, it adds the type-specific fields (a `HeadingElement` adds `text` and `type` — `type` being the heading level such as h1 or h2; an `ImageElement` adds `src`; a `LinkElement` adds `text` and `url`). Because the base shape never changes, a migration script can walk the tree generically — recurse on `blocks`, `elements`, and `cells`, switch on `name`, and rewrite a `formats` key across hundreds of stored designs in one pass. This is the practical payoff of a self-owned schema: it isn't just readable, it's *changeable*. You can write the walker, run it in CI, diff the before/after JSON, and roll back with git if a design drifts. An opaque builder database gives you none of that.

The nesting contract: page → block → grid → cell → leaf

The blocks array is not free-form — it follows a structural contract that BuilderJS checks on load() via its built-in structure validator, so a malformed or hand-edited document surfaces a precise, path-pointed warning in the console (for example `root.blocks.0.elements.2.url is required for LinkElement.`) instead of silently misbehaving. The hierarchy is: a `PageElement` contains `blocks`; each `BlockElement` contains `elements`; layout elements like `GridElement` and `PricingCardsElement` contain `cells`; each cell (`CellElement`, `PricingCardElement`, `FormContainerCell`) contains its own `blocks`, which lets you nest columns and rows arbitrarily deep. The leaves are the content elements that actually render — headings, paragraphs, images, buttons, dividers, social icons, lists, menus, form fields, and so on. The validator also knows the required attributes per type (a `LinkElement` must have `text` and `url`; an `ImageElement` must have `src`; a `SpacerElement` must have `height`), so the schema is self-describing enough that you can generate documents programmatically and let the validator tell you — by exact JSON path — anything that breaks the contract. The check is advisory rather than fatal: it logs the violation and the load continues, so wire it into your own tests or CI if you want a hard gate. This is the answer to 'can I generate templates from data?' — yes, as long as you honor the parent/child keys and the required attributes, the same validator that guards the editor will flag your generated JSON the moment it drifts.

Around 30 element types — and the registry you can extend

The leaf and structural elements you'll see in saved JSON number around 30 in the stock build: headings (a generic HeadingElement plus H1–H5), paragraph, label, link, alert, image, video, YouTube, social icons, spacer, divider, button, submit button, list, menu, RSS, raw HTML, the form inputs (field, text input, select, radio, checkbox), checkout and simple-checkout, feature list, and structural containers (block, grid, cell, pricing cards). The full element registry seeds a few dozen entries total — it's a mutable map keyed by the JSON `name`, so the exact count depends on which optional widgets you ship. The important part for the schema reader is that the registry is *yours*: because you own the source, you can register new element types with Builder.registerElement(name, klass) without forking the engine, and your custom element serializes through the same name/template/formats contract as the stock ones. So 'around 30' is the floor, not a ceiling — and any element you add becomes a first-class citizen of the same JSON tree, diffable and migratable exactly like the built-ins.

Round-trip and idempotency: what makes it safe to diff

The reason this schema is safe to commit to git is that getData() → load() → getData() is a stable round-trip. load() takes four positional arguments — load(themeJson, themeTemplatesJson, themeConfigData, mediaUrl) — and reconstructs the editor state from the JSON you saved; getData() then serializes it back to the same shape. Two design choices keep the diffs clean. First, optional keys are emitted only when they carry a value: `formats` appears only when an element actually has a formatter, and per-device flags like `hide_mobile` / `hide_desktop` and any artefact `meta` are written only when set. That conditional emit is deliberate — it keeps legacy and unchanged documents byte-identical across a load/save cycle, so a re-save doesn't churn unrelated keys and pollute your code-review diff. Second, the shape is flat and ordered, not a nondeterministic dump, so a structural diff shows exactly which element or property changed. The HTML BuilderJS renders (modern page markup, or Outlook-safe email through the EmailExportPipeline) is always a derivative you regenerate from this JSON — the JSON is the canonical source you keep. Store it, transform it, generate it: the document is yours to own and change, which is the entire point of a source-owned schema.

Frequently asked questions

Explore