How to integrate your own mini-builder with metabox JavaScript in WordPress

Contents

Overview

This tutorial shows how to build and integrate a lightweight mini-builder inside a WordPress metabox using the Meta Box (metabox.io or the Meta Box plugin) approach plus plain JavaScript. The mini-builder provides a block palette, an editable canvas, reordering and removal controls, and stores the layout as structured JSON in post meta. The example is dependency-free (vanilla JS WP core) and intended to be a solid foundation you can extend (media pickers, advanced block types, REST saving, live previews).

What youll get

  • PHP metabox registration — register the metabox, output the builder UI, enqueue assets, and handle saving and sanitization.
  • Vanilla JavaScript — add/remove blocks, edit block attributes, reorder blocks, and serialize to a hidden meta field.
  • CSS — minimal styling for the metabox UI.
  • Front-end rendering — simple renderer that converts the saved JSON into HTML in the theme.
  • Security and sanitization — nonce checks, capability checks, and structured validation and escaping.

Prerequisites

  • WordPress site with ability to add plugin code or theme functions.php modifications.
  • Familiarity with PHP, JavaScript, and WordPress hooks (add_meta_box, save_post, wp_enqueue_script/style).
  • Optional: Meta Box plugin if you prefer to register the metabox via that plugin. This tutorial uses core WP add_meta_box for clarity and portability.

Architecture and data model

The mini-builder stores an ordered array of blocks as JSON in a single post meta key (for example, _mini_builder_data). Each block is an object with at least a type and attributes. Example data:

[
  { type: heading, attrs: { text: Welcome, level: 2 } },
  { type: paragraph, attrs: { text: Intro paragraph. } },
  { type: image, attrs: { url: https://example.com/image.jpg, alt: Alt text } }
]

Step 1 — Register the metabox, enqueue scripts and styles

Add the code below into a plugin file or themes functions.php. It demonstrates: registering metabox, printing nonce, enqueuing JS and CSS, and passing the available block definitions to JavaScript via localized data.

 [
            label => Heading,
            attrs => [ text => Heading text, level => 2 ]
        ],
        paragraph => [
            label => Paragraph,
            attrs => [ text => Paragraph text ]
        ],
        image => [
            label => Image,
            attrs => [ url => , alt =>  ]
        ]
    ]

    wp_localize_script(mini-builder-script, MiniBuilderData, [
        blocks => blocks,
        nonce  => wp_create_nonce(mini_builder_nonce)
    ])
}

// Meta box output
function mini_builder_meta_box_cb(post) {
    meta_key = _mini_builder_data
    value = get_post_meta(post->ID, meta_key, true)
    if (!value) {
        value = json_encode([]) // default empty array
    } else if (is_array(value)) {
        value = wp_json_encode(value)
    }
    // Output container, palette, canvas, hidden input and nonce
    echo 
echo
echo
echo wp_nonce_field(mini_builder_nonce, mini_builder_nonce_field) echo
} // Save and sanitize function mini_builder_save_post(post_id) { // Check autosave, post type, capabilities, and nonce if (defined(DOING_AUTOSAVE) DOING_AUTOSAVE) { return } if (!isset(_POST[mini_builder_nonce_field]) !wp_verify_nonce(_POST[mini_builder_nonce_field], mini_builder_nonce)) { return } if (!current_user_can(edit_post, post_id)) { return } meta_key = _mini_builder_data if (!isset(_POST[meta_key])) { // If field is missing, delete meta to avoid stale data delete_post_meta(post_id, meta_key) return } raw = wp_unslash(_POST[meta_key]) decoded = json_decode(raw, true) if (!is_array(decoded)) { // Invalid JSON -> clear or ignore here we clear delete_post_meta(post_id, meta_key) return } // Basic structure validation and sanitization allowed_types = [heading, paragraph, image] clean = [] foreach (decoded as block) { if (!is_array(block)) { continue } if (empty(block[type]) !in_array(block[type], allowed_types, true)) { continue } type = block[type] attrs = isset(block[attrs]) is_array(block[attrs]) ? block[attrs] : [] if (type === heading) { text = isset(attrs[text]) ? wp_kses_post(attrs[text]) : level = isset(attrs[level]) ? intval(attrs[level]) : 2 if (level < 1 level > 6) { level = 2 } clean[] = [type=>heading, attrs=>[text=>text, level=>level]] } elseif (type === paragraph) { text = isset(attrs[text]) ? wp_kses_post(attrs[text]) : clean[] = [type=>paragraph, attrs=>[text=>text]] } elseif (type === image) { url = isset(attrs[url]) ? esc_url_raw(attrs[url]) : alt = isset(attrs[alt]) ? sanitize_text_field(attrs[alt]) : clean[] = [type=>image, attrs=>[url=>url, alt=>alt]] } } // Save sanitized JSON update_post_meta(post_id, meta_key, clean) } ?>

Step 2 — Metabox UI HTML structure (what the JS will build)

The PHP meta box callback rendered three basic elements: a palette container, a canvas container, and the hidden input that stores JSON. The JavaScript is responsible for populating the palette (using localized block definitions), adding blocks to the canvas, editing attributes of each block, reordering, deleting, and persisting the JSON value in the hidden input.

Step 3 — JavaScript: the mini-builder behavior

Below is a complete vanilla JavaScript implementation. It:

  • Builds the palette from MiniBuilderData.blocks
  • Adds blocks to the canvas (pushes default attrs)
  • Renders block items with Edit / Up / Down / Delete controls
  • Supports inline editors for block attributes
  • Serializes to the hidden input #mb-data on every change
  • Initializes from existing post meta JSON
// File: js/mini-builder.js
(function(){
  // Helper shorteners
  var qs = function(sel, ctx){ return (ctx  document).querySelector(sel) }
  var qsa = function(sel, ctx){ return Array.prototype.slice.call((ctx  document).querySelectorAll(sel)) }

  // Elements
  var paletteEl = qs(#mb-palette)
  var canvasEl = qs(#mb-canvas)
  var dataInput = qs(#mb-data)

  // Block registry passed via wp_localize_script as MiniBuilderData.blocks
  var blocks = window.MiniBuilderData  window.MiniBuilderData.blocks ? window.MiniBuilderData.blocks : {}

  // Current model (array of block objects)
  var model = []

  function init() {
    buildPalette()
    // Load initial data
    try {
      var raw = dataInput.value  []
      var parsed = JSON.parse(raw)
      if (Array.isArray(parsed)) {
        model = parsed
      } else {
        model = []
      }
    } catch (e) {
      model = []
    }
    renderCanvas()
  }

  function buildPalette() {
    paletteEl.innerHTML = 

Blocks

var ul = document.createElement(ul) ul.className = mb-palette-list Object.keys(blocks).forEach(function(key){ var b = blocks[key] var li = document.createElement(li) var btn = document.createElement(button) btn.type = button btn.className = mb-add-button btn.textContent = b.label key btn.dataset.blockType = key btn.addEventListener(click, function(){ addBlock(key) }) li.appendChild(btn) ul.appendChild(li) }) paletteEl.appendChild(ul) } function addBlock(type) { var def = blocks[type] if (!def) return // Deep copy of default attrs var attrs = JSON.parse(JSON.stringify(def.attrs {})) model.push({ type: type, attrs: attrs }) renderCanvas() syncToInput() } function renderCanvas() { canvasEl.innerHTML = var title = document.createElement(h4) title.textContent = Canvas canvasEl.appendChild(title) if (model.length === 0) { var p = document.createElement(p) p.className = mb-empty p.textContent = No blocks yet. Add a block from the palette. canvasEl.appendChild(p) return } var list = document.createElement(ul) list.className = mb-canvas-list model.forEach(function(block, index){ var li = document.createElement(li) li.className = mb-block-item li.dataset.index = index var header = document.createElement(div) header.className = mb-block-header var title = document.createElement(strong) title.textContent = (blocks[block.type] blocks[block.type].label) ? blocks[block.type].label : block.type header.appendChild(title) var controls = document.createElement(div) controls.className = mb-controls // Edit button var edit = document.createElement(button) edit.type = button edit.className = mb-btn mb-edit edit.textContent = Edit edit.addEventListener(click, function(){ toggleEditor(li, block, index) }) controls.appendChild(edit) // Up button var up = document.createElement(button) up.type = button up.className = mb-btn mb-up up.textContent = Up up.disabled = index === 0 up.addEventListener(click, function(){ moveBlock(index, index - 1) }) controls.appendChild(up) // Down button var down = document.createElement(button) down.type = button down.className = mb-btn mb-down down.textContent = Down down.disabled = index === model.length - 1 down.addEventListener(click, function(){ moveBlock(index, index 1) }) controls.appendChild(down) // Delete var del = document.createElement(button) del.type = button del.className = mb-btn mb-delete del.textContent = Delete del.addEventListener(click, function(){ deleteBlock(index) }) controls.appendChild(del) header.appendChild(controls) li.appendChild(header) // Render simple preview var preview = document.createElement(div) preview.className = mb-preview preview.innerHTML = getBlockPreviewHTML(block) li.appendChild(preview) // Editor container (hidden by default) var editor = document.createElement(div) editor.className = mb-editor editor.style.display = none buildEditorFields(editor, block, index) li.appendChild(editor) list.appendChild(li) }) canvasEl.appendChild(list) } function getBlockPreviewHTML(block) { if (block.type === heading) { var level = parseInt(block.attrs.level, 10) 2 level = Math.min(Math.max(level,1),6) return escapeHtml(block.attrs.text ) } else if (block.type === paragraph) { return

escapeHtml(block.attrs.text )

} else if (block.type === image) { var url = block.attrs.url var alt = escapeHtml(block.attrs.alt ) if (!url) { return
No image set
} return alt } return
Unknown block
} function toggleEditor(listItem, block, index) { var editor = listItem.querySelector(.mb-editor) if (!editor) return if (editor.style.display === none editor.style.display === ) { editor.style.display = block } else { editor.style.display = none } } function buildEditorFields(container, block, index) { container.innerHTML = if (block.type === heading) { var labelText = document.createElement(label) labelText.textContent = Text var inputText = document.createElement(input) inputText.type = text inputText.className = mb-input-text inputText.value = block.attrs.text inputText.addEventListener(input, function(){ block.attrs.text = inputText.value onModelChange() }) container.appendChild(labelText) container.appendChild(inputText) var labelLevel = document.createElement(label) labelLevel.textContent = Level var select = document.createElement(select) select.className = mb-select-level [1,2,3,4,5,6].forEach(function(l){ var opt = document.createElement(option) opt.value = l opt.textContent = H l if ((block.attrs.level 2) == l) { opt.selected = true } select.appendChild(opt) }) select.addEventListener(change, function(){ block.attrs.level = parseInt(select.value, 10) onModelChange() }) container.appendChild(labelLevel) container.appendChild(select) } else if (block.type === paragraph) { var labelP = document.createElement(label) labelP.textContent = Text var textarea = document.createElement(textarea) textarea.className = mb-textarea textarea.value = block.attrs.text textarea.addEventListener(input, function(){ block.attrs.text = textarea.value onModelChange() }) container.appendChild(labelP) container.appendChild(textarea) } else if (block.type === image) { var labelUrl = document.createElement(label) labelUrl.textContent = Image URL var inputUrl = document.createElement(input) inputUrl.type = text inputUrl.className = mb-input-text inputUrl.value = block.attrs.url inputUrl.addEventListener(input, function(){ block.attrs.url = inputUrl.value onModelChange() }) container.appendChild(labelUrl) container.appendChild(inputUrl) var labelAlt = document.createElement(label) labelAlt.textContent = Alt text var inputAlt = document.createElement(input) inputAlt.type = text inputAlt.className = mb-input-text inputAlt.value = block.attrs.alt inputAlt.addEventListener(input, function(){ block.attrs.alt = inputAlt.value onModelChange() }) container.appendChild(labelAlt) container.appendChild(inputAlt) } else { container.textContent = No editor available for this block type. } } function moveBlock(from, to) { if (to < 0 to >= model.length) return var item = model.splice(from, 1)[0] model.splice(to, 0, item) renderCanvas() syncToInput() } function deleteBlock(index) { model.splice(index, 1) renderCanvas() syncToInput() } function onModelChange() { // Update previews for all items without fully re-rendering var items = qsa(.mb-block-item, canvasEl) items.forEach(function(li){ var idx = parseInt(li.dataset.index, 10) var preview = li.querySelector(.mb-preview) if (preview model[idx]) { preview.innerHTML = getBlockPreviewHTML(model[idx]) } }) syncToInput() } function syncToInput() { try { dataInput.value = JSON.stringify(model) } catch (e) { dataInput.value = [] } } // Small escaping helpers function escapeHtml(s) { return String(s ).replace(/[<>]/g, function(m){ return { :amp, <:lt, >:gt, :quot, :#39 }[m] }) } function escapeAttr(s) { return String(s ).replace(//g, quot) } // Initialize on DOM ready if (document.readyState === loading) { document.addEventListener(DOMContentLoaded, init) } else { init() } })()

Step 4 — Minimal CSS for the metabox

Place this CSS in css/mini-builder.css or inline it in your plugin. It keeps the UI readable and usable inside the WP metabox.

/ File: css/mini-builder.css /
#mini-builder-app { font-family: Arial, sans-serif }
.mb-palette, .mb-canvas { margin-bottom: 1em padding: 8px background: #fff border: 1px solid #ddd border-radius: 3px }
.mb-palette-list, .mb-canvas-list { list-style: none margin: 0 padding: 0 }
.mb-palette-list li { display: inline-block margin-right: 6px }
.mb-add-button { background:#0073aacolor:#fffborder:nonepadding:6px 10pxborder-radius:3pxcursor:pointer }
.mb-add-button:hover { background:#006799 }
.mb-block-item { border:1px solid #e2e2e2 margin-bottom:8px padding:8px border-radius:3px background:#fafafa }
.mb-block-header { display:flex justify-content:space-between align-items:center margin-bottom:6px }
.mb-controls button { margin-left:6px }
.mb-preview { margin-bottom:6px }
.mb-editor label { display:block margin:6px 0 2px font-size:12px color:#333 }
.mb-input-text, .mb-select-level, .mb-textarea { width:100% padding:6px border:1px solid #ddd border-radius:3px box-sizing:border-box }
.mb-textarea { min-height:60px resize:vertical }
.mb-empty { color:#666 font-style:italic }
.mb-image-placeholder { color:#999 font-style:italic }
.mb-btn { background:#f1f1f1border:1px solid #dddpadding:3px 8pxborder-radius:3px cursor:pointer }
.mb-btn:disabled { opacity:0.5 cursor:default }

Step 5 — Front-end rendering of saved layout

To render the saved JSON in your theme template (single.php or a block template), get the post meta and convert each block to HTML. Keep escaping in mind. Example:

 . text . 
        } elseif (type === paragraph) {
            text = isset(attrs[text]) ? wp_kses_post(attrs[text]) : 
            echo 

. text .

} elseif (type === image) { url = isset(attrs[url]) ? esc_url(attrs[url]) : alt = isset(attrs[alt]) ? esc_attr(attrs[alt]) : if (url) { echo . } } } } ?>

Security and sanitization notes

  1. Nonces: A nonce is created and printed in the metabox and verified on save_post. This prevents CSRF in the admin area.
  2. Capability checks: Verify current_user_can(edit_post, post_id) before saving. Do not rely solely on nonce.
  3. Sanitization: Sanitize each attribute according to its type: use wp_kses_post() for rich text, esc_url_raw() for URLs, sanitize_text_field() for plain strings, and cast numbers carefully.
  4. Structured validation: Only accept allowed block types and shape attributes before saving. This prevents stored XSS or unexpected structures.

Advanced extensions and tips

  • Media picker integration: If you want a WordPress media uploader for image blocks, integrate wp.media in admin scripts and accept attachment IDs instead of raw URLs. That requires enqueuing wp-media scripts and probably using jQuery for the canonical WP examples, but its also doable in vanilla JS.
  • Drag-and-drop / Sortable: For a better UX you can integrate a drag-and-drop reorderer such as SortableJS or use HTML5 drag-and-drop on the list. If using external libraries, enqueue them properly and adjust sync logic.
  • REST or AJAX saving: Instead of saving through post meta on save_post, implement a REST endpoint to autosave builder state or to fetch block previews on demand.
  • Block types registry: Keep your block definitions centralized (labels, default attrs, editor schema, and renderer functions). You may create a PHP registry that can be extended by themes/plugins via filters.
  • Gutenberg compatibility: If your site uses Gutenberg, consider whether the mini-builder is an internal editing tool or if you should build custom blocks or a block category for the Block Editor.

Troubleshooting and common gotchas

  • If the hidden inputs JSON looks malformed, ensure JavaScript properly escapes values use JSON.stringify before saving and confirm dataInput is not overwritten by other scripts.
  • When registering scripts, ensure wp_localize_script is called with the correct handle used in wp_enqueue_script.
  • If styles look broken within the metabox, check for CSS specificity conflicts with admin styles—use clear class names and concise rules.
  • Remember to clear or migrate old meta if you change the data structure use versioning if required (store a schema_version in meta).

Extending with templates, repeaters, and layouts

You can extend the mini-builder to support templates (presets of multiple blocks), repeating sections (rendering as repeaters where each item is data-driven), or layout blocks (columns with nested blocks). For nested structures, adapt the schema to allow a block to have a children array and update the editor and renderer accordingly. Keep validation strict and consider limiting nesting depth to avoid complexity.

Final design notes

This tutorial produces a minimal but complete implementation: a metabox UI, a safe server-side save routine, front-end rendering guidance, and a dependency-free JavaScript editor. Use it as a foundation: replace the editor widgets with richer inputs, integrate the media library, or swap the persistence model to REST for autosaves. Keep security and sanitization at the forefront when adding richer HTML or file inputs.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *