← Back to blog

Building a Vue.js Drag-and-Drop Builder for WordPress

· 8 min read · Vue.js · WordPress

At some point during my time working on a WordPress page builder plugin, I stopped thinking of it as a WordPress plugin and started thinking of it as a distributed application that happened to live inside WordPress. That changed every architectural decision — starting the moment we committed to building the drag-and-drop canvas in Vue.js.

This post is about what that actually looks like in practice. The real decisions, the places where the obvious choice turned out to be wrong, and the WordPress-specific friction that no tutorial warns you about.

Why Vue.js for a WordPress Page Builder

When we were scoping out the builder rewrite, React was the obvious corporate answer. Gutenberg had just landed and the WordPress core team was betting everything on it. But we chose Vue 2, and I’d make the same call again today, with caveats.

The honest reason: Vue’s single-file components map almost perfectly to the mental model of a page builder block. A block has a template, scoped styles, and reactive props. That’s a .vue file. The Options API at the time was also easier to hand off to developers who came from a jQuery-heavy WordPress background — and in agency work, that’s most of your team.

If you’re starting fresh today, go Vue 3 with Pinia. We spent real time fighting Vuex’s rigid module structure once the data model got complicated — more on that in the drag-and-drop section.

The JSON Schema Problem

The first thing you need to nail down is how you represent a page. We went with a flat-ish JSON structure where the page is an array of sections, each section contains rows, each row contains columns, and columns contain blocks. Classic nested tree.

The schema looked roughly like this:

{
  "id": "page_xyz",
  "sections": [
    {
      "id": "section_001",
      "settings": { "background": "#fff", "padding": "60px 0" },
      "rows": [
        {
          "id": "row_001",
          "columns": [
            {
              "id": "col_001",
              "width": "50",
              "blocks": [
                {
                  "id": "block_001",
                  "type": "heading",
                  "attrs": {
                    "text": "Hello World",
                    "tag": "h1",
                    "alignment": "left"
                  }
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

The key decision here was keeping IDs flat and globally unique rather than using path-based references. When you’re implementing drag-and-drop, you need to find any node in the tree by ID without traversing the whole structure on every drag event. We kept a separate flat index — a plain object keyed by block ID — that we updated alongside the nested tree. Double maintenance, yes, but drag performance made it worth it.

What I’d change: the nested array structure creates mutation headaches in Vuex. Every time you need to move a block, you’re splicing arrays three levels deep inside a mutation, which means you need to touch the parent column, not the block itself. Pinia’s direct state mutation and Vue 3’s reactive() proxy handle this much more naturally.

Recursive Component Rendering

The builder canvas renders the page tree recursively. Each component type knows how to render itself and its children. There’s a specific trap with Vue 2’s reactivity system that burned us.

If you add a property to a block’s attrs object after it’s been made reactive — say, because a user picks an option that unlocks a new setting — Vue 2 won’t detect it. You have to use Vue.set() or restructure so all possible keys exist at initialization time with null defaults. We ended up defining a full default schema for every block type and deep-merging incoming data against it on load. Annoying boilerplate, but it meant the reactivity was always predictable.

The recursive component itself was straightforward:

<!-- BlockRenderer.vue -->
<template>
  <component
    :is="resolveBlockComponent(block.type)"
    :block="block"
    :selected="selectedBlockId === block.id"
    @select="$emit('select', block.id)"
    @update="handleBlockUpdate"
  />
</template>

<script>
export default {
  name: 'BlockRenderer',
  props: {
    block: { type: Object, required: true },
    selectedBlockId: { type: String, default: null }
  },
  methods: {
    resolveBlockComponent(type) {
      // Dynamic import cached after first load
      return this.$options.components[type] || 'UnknownBlock'
    },
    handleBlockUpdate(payload) {
      this.$store.commit('UPDATE_BLOCK_ATTRS', {
        id: this.block.id,
        attrs: payload
      })
    }
  }
}
</script>

The resolveBlockComponent method is where lazy loading happens. We didn’t bundle every block type into the main chunk. Video blocks, countdown timers, WooCommerce blocks — those got split out. Shaved roughly 180ms off the initial builder load time on average hardware, which matters when you’re already asking WordPress to bootstrap an admin page.

Drag-and-Drop: The Part Nobody Warns You About

We evaluated several libraries before settling on a custom implementation built on top of the HTML5 Drag and Drop API with a thin Vue directive wrapper. SortableJS was tempting, but its Vue integration at the time had reactivity edge cases that were hard to debug. The native API is lower-level but it gives you full control over the data you pass through the drag event, which matters when a “drop” needs to carry a full block schema, not just an index.

The drag state itself lived in Vuex (later we’d move this to a separate composable in a Vue 3 project). The pattern looked like this:

// store/modules/dragDrop.js
const state = {
  dragging: false,
  sourceBlockId: null,
  sourceColumnId: null,
  targetColumnId: null,
  targetIndex: null,
  blockType: null // when dragging from the block panel, not canvas
}

const mutations = {
  START_DRAG(state, { blockId, columnId, blockType }) {
    state.dragging = true
    state.sourceBlockId = blockId
    state.sourceColumnId = columnId
    state.blockType = blockType || null
  },
  SET_DROP_TARGET(state, { columnId, index }) {
    state.targetColumnId = columnId
    state.targetIndex = index
  },
  END_DRAG(state) {
    state.dragging = false
    state.sourceBlockId = null
    state.sourceColumnId = null
    state.targetColumnId = null
    state.targetIndex = null
    state.blockType = null
  }
}

const actions = {
  commitDrop({ state, commit, dispatch }) {
    if (!state.targetColumnId) {
      commit('END_DRAG')
      return
    }
    if (state.blockType) {
      dispatch('insertNewBlock', {
        type: state.blockType,
        columnId: state.targetColumnId,
        index: state.targetIndex
      })
    } else {
      dispatch('moveBlock', {
        blockId: state.sourceBlockId,
        fromColumnId: state.sourceColumnId,
        toColumnId: state.targetColumnId,
        toIndex: state.targetIndex
      })
    }
    commit('END_DRAG')
  }
}

The distinction between dragging an existing block and dragging a new block type from the panel is something a lot of tutorials gloss over. They’re different operations that need to resolve to the same drop target logic. Keeping blockType in drag state lets the drop handler make that decision in one place.

WordPress-Specific Friction

WordPress is a PHP application with decades of jQuery assumptions baked into its admin. You’re building a modern Vue.js application inside that environment, and the conflicts are real.

Script loading order. WordPress loads jQuery in the footer by default for some scripts and the header for others. Your Vue bundle needs to know that jQuery exists globally if any of your dependencies reach for window.$. We externalized jQuery in our webpack config and declared it as a dependency via wp_enqueue_script. This also means your bundle stays smaller because jQuery isn’t bundled twice.

Passing data from PHP to Vue. The standard WordPress pattern is wp_localize_script(), and it works fine for initial page data. We used it to pass the saved page JSON, available block types, REST API nonce, and user capability flags. The nonce is important — every REST API call from the builder needs it in the X-WP-Nonce header, otherwise WordPress rejects the request with a 403.

wp_localize_script(
    'my-builder',
    'builderData',
    [
        'page'       => $page_data,
        'nonce'      => wp_create_nonce( 'wp_rest' ),
        'restUrl'    => rest_url( 'builder/v1/' ),
        'blocks'     => $registered_blocks,
        'siteUrl'    => get_site_url(),
    ]
);

The iframe sandbox problem. The builder preview renders the actual page inside an iframe so styles don’t bleed between the builder UI and the preview. This sounds simple. It is not. Communication between the parent Vue app and the iframe goes through window.postMessage, which means you’re serializing and deserializing state changes constantly. We had a message bus module that debounced updates so we weren’t blasting the iframe with a message on every keystroke in a text field. Even with debouncing at 150ms, getting the preview to feel live without lag required careful batching of which state changes actually needed to re-render the full preview versus just updating a CSS variable.

Plugin conflicts. Some WordPress plugins — security plugins especially — add their own Content Security Policy headers that break inline scripts, which Vue 3 uses for compiled template rendering in some configurations. We ended up documenting a list of known conflicts and writing detection code that warned users before they loaded the builder. That warning ended up saving more support tickets than anything else we shipped that quarter.

Undo/Redo

Undo/redo in a page builder looks like a checkbox on a feature list but it’s actually an architectural constraint that affects everything. The naive approach — snapshot the entire page state on every change — works until your page JSON hits 200kb and you’re holding 50 snapshots in memory.

We moved to a command pattern where each user action is recorded as a pair of forward/reverse operations. A “move block” action stores the block ID, the original position, and the new position. Undo replays the reverse. This means the history stack is compact regardless of page size. The downside is you have to implement the reverse operation for every action type, which is more code but the right tradeoff.

What I’d Tell Someone Starting This Today

Keep your page state in one place and keep it serializable. The moment you let ephemeral UI state (which panel is open, which block is hovered) leak into the same store slice as your page data, saving becomes complicated and undo/redo breaks in subtle ways. Separate them physically in your store from day one.

The WordPress REST API is good enough. We spent time early on evaluating custom AJAX endpoints for performance, but the REST API with proper nonce auth, a sensible schema, and server-side caching handled our save/load requirements without drama. The one place we needed custom endpoints was for server-side block rendering — blocks that call PHP functions can’t be rendered in the JavaScript preview, so we had a render endpoint that took a block’s attrs and returned HTML.

Vue.js drag and drop builder development is not a solved problem you import from npm. Every library I’ve used has edge cases on mobile, inside iframes, or with nested droppable zones. Budget time to understand the underlying browser APIs so you can debug when abstractions fail.

If you’re building a WordPress page builder plugin today, reach for Vue 3 and Pinia, not Vue 2 and Vuex. The composition API makes it significantly easier to share stateful logic between the builder canvas and the settings panel without prop drilling or event bus spaghetti. The migration from Vue 2 we eventually did was worth it, but it was expensive — starting right is cheaper.

One last thing that took me too long to learn: instrument before you optimize. We had assumptions about where the builder was slow that turned out to be wrong. The actual bottleneck was the iframe postMessage serialization frequency, not Vue’s reactivity overhead. Measure first, then fix.