Step-by-Step Guide to building a custom dynamic lead collector block for Gutenberg using Vue micro-frontends
Project Setup: WordPress Plugin & Vue Micro-Frontend
This guide details the construction of a custom Gutenberg block for WordPress, designed to dynamically collect lead information. We’ll leverage Vue.js for the block’s frontend interface, treating it as a self-contained micro-frontend within the WordPress ecosystem. This approach offers a modern, component-driven development experience for complex block UIs.
Our setup begins with a standard WordPress plugin structure. We’ll create a new directory within wp-content/plugins/, let’s call it custom-lead-collector. Inside this directory, we’ll establish the necessary files and subdirectories for our plugin and the Vue application.
Plugin Structure
The core plugin files will manage block registration and asset enqueuing. The Vue micro-frontend will reside in its own dedicated directory, allowing for independent development and build processes.
custom-lead-collector/custom-lead-collector.php(Main plugin file)build/(Output directory for compiled Vue assets)src/js/admin.js(Enqueues block assets)frontend.js(Enqueues frontend assets if needed)
css/admin.css
blocks/lead-collector/index.js(Block registration and editor script)editor.scssstyle.scss
vue-app/(Vue micro-frontend source)
Vue Micro-Frontend Development Environment
We’ll use Vue CLI to scaffold and manage our Vue micro-frontend. Navigate to the custom-lead-collector/vue-app/ directory and initialize a new Vue project. For simplicity, we’ll opt for a minimal setup without routing, as the block will be embedded directly.
Vue CLI Initialization
Execute the following commands in your terminal:
cd wp-content/plugins/custom-lead-collector mkdir vue-app cd vue-app vue create . --template webpack --router false --history-mode false --css sass --use-eslint false --use-babel false --plugins @vue/cli-plugin-unit-jest
This command initializes a Vue project in the current directory (.), using the webpack template, disabling routing and history mode, selecting Sass for styling, and omitting ESLint and Babel for a leaner setup. We’ll manually handle transpilation and bundling.
Integrating Vue with Gutenberg
The core of our integration lies in how we compile the Vue application and inject its output into the WordPress Gutenberg editor. We’ll configure Vue CLI to build a JavaScript file that can be directly enqueued by WordPress.
Vue CLI Configuration (vue.config.js)
Create a vue.config.js file in the root of the vue-app/ directory. This file will define our build process.
const path = require('path');
module.exports = {
// Output directory relative to the plugin's root
outputDir: path.resolve(__dirname, '../build'),
// Filename for the main JS bundle
filenameHashing: false, // Important for consistent enqueueing
// Disable CSS extraction to keep everything in one JS file for simplicity
css: {
extract: false,
},
// Configure webpack to output a single JS file
configureWebpack: {
output: {
filename: 'lead-collector-block.js', // Name of the output file
libraryTarget: 'umd', // Universal Module Definition
library: 'LeadCollectorBlock', // Global variable name
},
// Ensure Vue is treated as an external dependency if already loaded by WordPress
// This can be adjusted based on your WordPress environment's Vue loading strategy
externals: {
vue: 'Vue',
react: 'React',
'react-dom': 'ReactDOM',
wp: 'wp',
'@wordpress/element': 'wp.element',
'@wordpress/blocks': 'wp.blocks',
'@wordpress/components': 'wp.components',
'@wordpress/i18n': 'wp.i18n',
'@wordpress/editor': 'wp.editor',
},
},
// Disable the dev server for production builds
devServer: {
enabled: false,
},
};
Key configurations here:
outputDir: Specifies where the compiled assets will be placed, relative to the plugin’s root.filenameHashing: false: Prevents cache-busting hashes in filenames, making it easier to reference the script in PHP.css.extract: false: Embeds CSS directly into the JavaScript bundle.output.libraryTarget: 'umd'andoutput.library: 'LeadCollectorBlock': Configures the output as a Universal Module Definition, exposing a global variable. This is crucial for WordPress to access the compiled Vue code.externals: This is critical. It tells Webpack not to bundle certain libraries (like Vue, React, and WordPress core packages) but to assume they are already available globally or via WordPress’s dependency management. This significantly reduces the size of our compiled asset. Adjust this list based on how WordPress loads its own scripts.
Vue Component Structure
Inside vue-app/src/, create your Vue components. For a lead collector, you might have a main component like LeadCollectorForm.vue and potentially smaller, reusable components.
// vue-app/src/LeadCollectorForm.vue
<template>
<div class="lead-collector-form">
<h3>{{ title }}</h3>
<p>{{ description }}</p>
<form @submit.prevent="submitLead">
<div class="form-group">
<label for="name">Name:</label>
<input type="text" id="name" v-model="leadData.name" required />
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" v-model="leadData.email" required />
</div>
<button type="submit">{{ submitButtonText }}</button>
</form>
<p v-if="message" :class="{'success': isSuccess, 'error': !isSuccess}">{{ message }}</p>
</div>
</template>
<script>
export default {
name: 'LeadCollectorForm',
props: {
title: {
type: String,
default: 'Get Our Latest Updates'
},
description: {
type: String,
default: 'Sign up for our newsletter to stay informed.'
},
submitButtonText: {
type: String,
default: 'Subscribe'
}
},
data() {
return {
leadData: {
name: '',
email: ''
},
message: '',
isSuccess: false
};
},
methods: {
async submitLead() {
this.message = '';
this.isSuccess = false;
try {
const response = await fetch('/wp-json/custom-lead-collector/v1/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': window.leadCollectorNonce // Assuming nonce is available
},
body: JSON.stringify(this.leadData)
});
const result = await response.json();
if (response.ok) {
this.message = result.message || 'Thank you for subscribing!';
this.isSuccess = true;
this.leadData = { name: '', email: '' }; // Clear form
} else {
this.message = result.message || 'An error occurred. Please try again.';
this.isSuccess = false;
}
} catch (error) {
console.error('Submission error:', error);
this.message = 'A network error occurred. Please try again.';
this.isSuccess = false;
}
}
}
};
</script>
<style scoped>
.lead-collector-form {
padding: 20px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="text"],
input[type="email"] {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 3px;
box-sizing: border-box; /* Include padding and border in the element's total width and height */
}
button {
background-color: #4CAF50;
color: white;
padding: 10px 15px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background-color: #45a049;
}
.success {
color: green;
margin-top: 10px;
}
.error {
color: red;
margin-top: 10px;
}
</style>
This Vue component defines a simple form with name and email fields. It uses v-model for data binding and makes a POST request to a WordPress REST API endpoint (/wp-json/custom-lead-collector/v1/submit) to submit the lead data. A nonce is included for security.
Main Vue Entry Point
Create an App.vue (or similar) and an index.js file in vue-app/src/ to mount your Vue application.
// vue-app/src/App.vue
<template>
<div id="app">
<LeadCollectorForm
:title="blockTitle"
:description="blockDescription"
:submitButtonText="buttonText"
/>
</div>
</template>
<script>
import LeadCollectorForm from './LeadCollectorForm.vue';
export default {
name: 'App',
components: {
LeadCollectorForm
},
props: {
// These props will be passed from the Gutenberg block registration
blockTitle: String,
blockDescription: String,
buttonText: String
}
};
</script>
// vue-app/src/index.js
import { createApp } from 'vue';
import App from './App.vue';
// This function will be called by Gutenberg to render the block
export function renderBlock(props, element) {
// Check if Vue is available globally
if (typeof Vue === 'undefined') {
console.error('Vue is not loaded. Please ensure Vue is enqueued.');
return;
}
// Create a Vue app instance for each block instance
const app = createApp(App, props);
// Mount the Vue app to the target element
app.mount(element);
}
The renderBlock function is the bridge. It receives the block’s attributes (props) and the DOM element where it should be rendered. It then creates and mounts a Vue application instance.
Gutenberg Block Registration (PHP & JavaScript)
Now, let’s register the Gutenberg block in WordPress and enqueue our compiled Vue application.
Plugin Main File (custom-lead-collector.php)
This file handles the plugin activation and enqueues the necessary scripts and styles.
<?php
/**
* Plugin Name: Custom Lead Collector
* Description: A custom Gutenberg block for collecting leads using Vue.js.
* Version: 1.0.0
* Author: Your Name
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Register REST API route for lead submission.
*/
function clc_register_rest_route() {
register_rest_route( 'custom-lead-collector/v1', '/submit', array(
'methods' => 'POST',
'callback' => 'clc_handle_lead_submission',
'permission_callback' => '__return_true', // Adjust permissions as needed
) );
}
add_action( 'rest_api_init', 'clc_register_rest_route' );
/**
* Handle lead submission.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error
*/
function clc_handle_lead_submission( WP_REST_Request $request ) {
$data = $request->get_json_params();
$nonce = $request->get_header('X-WP-Nonce');
// Basic nonce verification (more robust checks might be needed)
if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
return new WP_Error( 'rest_nonce_invalid', 'Nonce is invalid.', array( 'status' => 403 ) );
}
$name = sanitize_text_field( $data['name'] ?? '' );
$email = sanitize_email( $data['email'] ?? '' );
if ( empty( $name ) || ! is_email( $email ) ) {
return new WP_Error( 'rest_invalid_data', 'Invalid name or email provided.', array( 'status' => 400 ) );
}
// TODO: Implement actual lead storage (e.g., custom database table, CRM integration, email notification)
// For now, we'll just return a success message.
error_log( "New lead collected: Name: {$name}, Email: {$email}" );
return new WP_REST_Response( array(
'message' => 'Lead submitted successfully!',
'data' => array( 'name' => $name, 'email' => $email ),
), 200 );
}
/**
* Enqueue block editor assets.
*/
function clc_enqueue_block_editor_assets() {
// Enqueue Vue.js if not already loaded by WordPress core or another plugin
// This is a common scenario in modern WordPress setups.
// If Vue is not globally available, uncomment and adjust the following:
/*
wp_enqueue_script(
'vue-js',
'https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js', // Or your local path
array(),
'3.2.0',
true
);
*/
// Enqueue our compiled Vue application
wp_enqueue_script(
'custom-lead-collector-block-editor',
plugins_url( 'build/lead-collector-block.js', __FILE__ ),
array( 'wp-blocks', 'wp-element', 'wp-components', 'wp-i18n', 'vue-js' ), // Dependencies, including Vue
filemtime( plugin_dir_path( __FILE__ ) . 'build/lead-collector-block.js' )
);
// Enqueue editor-specific styles
wp_enqueue_style(
'custom-lead-collector-block-editor-styles',
plugins_url( 'src/css/admin.css', __FILE__ ), // Assuming you have this file
array( 'wp-edit-blocks' ),
filemtime( plugin_dir_path( __FILE__ ) . 'src/css/admin.css' )
);
// Pass nonce to the JavaScript for API requests
wp_localize_script( 'custom-lead-collector-block-editor', 'leadCollectorData', array(
'nonce' => wp_create_nonce( 'wp_rest' ),
) );
}
add_action( 'enqueue_block_editor_assets', 'clc_enqueue_block_editor_assets' );
/**
* Enqueue block frontend assets (if needed for non-editor rendering).
*/
function clc_enqueue_block_frontend_assets() {
// If your Vue app needs to render on the frontend outside the editor,
// enqueue its assets here. For this example, we assume rendering
// happens via PHP's render_callback or block's save function.
// If you need a separate frontend JS bundle, create one and enqueue it.
}
// add_action( 'wp_enqueue_scripts', 'clc_enqueue_block_frontend_assets' );
/**
* Register the Gutenberg block.
*/
function clc_register_lead_collector_block() {
register_block_type( 'custom-lead-collector/lead-collector', array(
'editor_script' => 'custom-lead-collector-block-editor',
'editor_style' => 'custom-lead-collector-block-editor-styles',
'render_callback' => 'clc_render_lead_collector_block',
'attributes' => array(
'title' => array(
'type' => 'string',
'default' => 'Get Our Latest Updates',
),
'description' => array(
'type' => 'string',
'default' => 'Sign up for our newsletter to stay informed.',
),
'buttonText' => array(
'type' => 'string',
'default' => 'Subscribe',
),
),
) );
}
add_action( 'init', 'clc_register_lead_collector_block' );
/**
* Render callback for the lead collector block.
* This function is responsible for outputting the block's HTML on the frontend.
*
* @param array $attributes Block attributes.
* @return string HTML output.
*/
function clc_render_lead_collector_block( $attributes ) {
// We need to ensure our Vue app is mounted on the frontend if it's not
// handled by the editor script. For simplicity, we'll assume the editor
// script also handles frontend rendering or that the Vue app is globally available.
// A more robust solution might involve a separate frontend JS bundle or
// server-side rendering of initial Vue component state.
// For this example, we'll output a placeholder div and rely on the
// editor script (if it also runs on frontend) or a separate frontend
// enqueue to mount the Vue app.
// Ensure Vue is available globally on the frontend if needed.
// If you uncommented the Vue enqueue in editor assets, you might need
// a similar enqueue here for the frontend.
// wp_enqueue_script('vue-js'); // Ensure Vue is loaded
// Pass attributes to the Vue app.
// Note: The Vue app needs to be initialized on the frontend as well.
// This often requires a separate frontend JS enqueue that calls renderBlock.
// For simplicity, we'll assume the editor script's enqueue also runs on frontend
// or that Vue is globally available and the block's JS initializes it.
// A common pattern is to have the editor script also enqueue for the frontend.
// If not, you'd add a wp_enqueue_script call here for 'custom-lead-collector-block-editor'
// or a dedicated frontend script.
// We need to pass the attributes to the Vue app. This is typically done
// by the JavaScript that mounts the Vue app. The PHP `render_callback`
// primarily outputs the container element.
// The actual mounting logic should be in your frontend JavaScript.
// If `custom-lead-collector-block-editor` is enqueued on frontend,
// you'd need JS to find these divs and mount Vue apps.
// Example of how the JS would find and mount:
// document.querySelectorAll('.wp-block-custom-lead-collector-lead-collector').forEach(el => {
// const blockId = el.dataset.blockId; // Assuming you add a data-block-id attribute
// const attributes = JSON.parse(el.dataset.attributes); // Assuming you pass attributes
// renderBlock(attributes, el);
// });
// For this example, we'll output a div that the JS can target.
// We'll pass attributes as data attributes.
$attributes_json = htmlspecialchars( json_encode( $attributes ), ENT_QUOTES, 'UTF-8' );
return sprintf(
'<div class="wp-block-custom-lead-collector-lead-collector" data-attributes="%s">%s</div>',
$attributes_json,
'' // Vue app will render content inside this div
);
}
// Add a filter to ensure the editor script is also loaded on the frontend
// if your Vue app needs to render there.
function clc_block_frontend_scripts( $block_content, $block ) {
if ( isset( $block['blockName'] ) && 'custom-lead-collector/lead-collector' === $block['blockName'] ) {
// Ensure Vue is loaded on the frontend
wp_enqueue_script('vue-js'); // Make sure 'vue-js' is correctly registered and enqueued
// Enqueue the main block script for frontend rendering
wp_enqueue_script('custom-lead-collector-block-editor');
// The render_callback already outputs the container div.
// The JS enqueued here (custom-lead-collector-block-editor) should
// contain the logic to find these divs and mount the Vue app.
}
return $block_content;
}
add_filter( 'render_block', 'clc_block_frontend_scripts', 10, 2 );
// Ensure Vue is registered if not already present
function clc_register_vue_script() {
// Only register if Vue isn't already registered by WordPress or another plugin.
// This is a common check. You might need to adjust the handle 'vue-js'.
if ( ! wp_script_is( 'vue-js', 'registered' ) ) {
wp_register_script(
'vue-js',
'https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js', // Use a CDN or local copy
array(),
'3.2.0',
true
);
}
}
add_action( 'init', 'clc_register_vue_script' );
// Add a basic CSS file for the block's appearance in the editor and frontend
function clc_enqueue_block_styles() {
wp_enqueue_style(
'custom-lead-collector-block-styles',
plugins_url( 'src/css/style.css', __FILE__ ), // Assuming you have this file
array(),
filemtime( plugin_dir_path( __FILE__ ) . 'src/css/style.css' )
);
}
add_action( 'enqueue_block_assets', 'clc_enqueue_block_styles' );
?>
Explanation:
- REST API Route:
clc_register_rest_routeandclc_handle_lead_submissionset up a POST endpoint at/wp-json/custom-lead-collector/v1/submitto receive lead data. Basic validation and nonce verification are included. Crucially, you need to implement actual lead storage logic here. - Editor Assets:
clc_enqueue_block_editor_assetsenqueues the compiledlead-collector-block.js. It also enqueues Vue.js (assuming it's not globally available) and any necessary WordPress scripts.wp_localize_scriptis used to pass the nonce to the JavaScript. - Block Registration:
clc_register_lead_collector_blockregisters the block typecustom-lead-collector/lead-collector. It specifies the editor script, editor style, and importantly, arender_callback. It also defines the block's attributes (title,description,buttonText) which will be editable in the Gutenberg sidebar. - Render Callback:
clc_render_lead_collector_blockis responsible for the frontend HTML output. It outputs a containerdivwith the block's attributes encoded as JSON data attributes. The frontend JavaScript will use this to initialize the Vue app. - Frontend Script Loading: The
clc_block_frontend_scriptsfilter ensures that the Vue.js library and our compiled block script are enqueued when the block appears on the frontend. - Vue Registration:
clc_register_vue_scriptensures Vue.js is registered, making it available as a dependency. - Block Styles:
clc_enqueue_block_stylesenqueues a general stylesheet for the block.
Block Registration JavaScript (blocks/lead-collector/index.js)
This file defines the block's behavior within the Gutenberg editor. It uses the renderBlock function from our Vue app.
/**
* WordPress dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
// Import the render function from your Vue app's entry point
// Ensure the path is correct relative to this file.
// We assume the compiled JS will make 'LeadCollectorBlock.renderBlock' available.
// If you are building the Vue app as a separate module, you'd import it directly.
// For this setup, we rely on the global `LeadCollectorBlock` defined in vue.config.js.
// const { renderBlock } = LeadCollectorBlock; // This line might be needed if not using global
/**
* Block registration.
*/
registerBlockType( 'custom-lead-collector/lead-collector', {
title: __( 'Custom Lead Collector', 'custom-lead-collector' ),
icon: 'email-alt', // Choose an appropriate icon
category: 'widgets', // Or 'common', 'design', etc.
keywords: [ __( 'lead', 'custom-lead-collector' ), __( 'form', 'custom-lead-collector' ), __( 'subscribe', 'custom-lead-collector' ) ],
// Attributes are defined in PHP, but can also be mirrored here for clarity
// or if you need editor-only attributes.
attributes: {
title: {
type: 'string',
default: 'Get Our Latest Updates',
},
description: {
type: 'string',
default: 'Sign up for our newsletter to stay informed.',
},
buttonText: {
type: 'string',
default: 'Subscribe',
},
},
/**
* Settings for the block editor.
*/
edit: function( { attributes, setAttributes } ) {
const blockProps = useBlockProps();
// Ensure Vue is loaded and the renderBlock function is available
if ( typeof Vue === 'undefined' || typeof LeadCollectorBlock === 'undefined' || typeof LeadCollectorBlock.renderBlock !== 'function' ) {
return (
<div { ...blockProps }>
{ __( 'Vue.js or the block\'s Vue app is not loaded. Please check console for errors.', 'custom-lead-collector' ) }
</div>
);
}
// We need a container element for Vue to mount into.
// The actual rendering will be handled by Vue.
// We pass attributes to the Vue component.
const element = document.createElement('div');
// The renderBlock function expects props and the element.
// We pass the block's attributes as props.
LeadCollectorBlock.renderBlock(attributes, element);
// The returned element from renderBlock will be the mounted Vue app.
// We need to return this element from the edit function.
// However, Gutenberg's edit function expects a React element or similar.
// A common pattern is to use a wrapper component or a portal.
// For simplicity, we'll use a placeholder and rely on the frontend rendering.
// A more direct approach for the editor:
// Render the Vue component directly within the editor context.
// This requires the Vue app to be built to be compatible with Gutenberg's React environment.
// If using Vue 3, this often involves using a wrapper or a specific integration library.
// Alternative: Render a placeholder in the editor and rely on the frontend render_callback.
// This is simpler but provides less visual feedback in the editor.
// Let's try to render the Vue component directly in the editor.
// This requires the Vue app to be built with compatibility in mind.
// If `LeadCollectorBlock.renderBlock` is designed to mount into a DOM element,
// we can use `wp.element.createPortal` or similar.
// For a simpler approach, let's render a placeholder in the editor and
// rely on the `render_callback` for frontend display.
// The user can still edit attributes in the sidebar.
// To make the Vue app render in the editor, we need to mount it.
// We'll use a ref to get the DOM element and then mount Vue.
const editorElementRef = wp.element.useRef();
wp.element.useEffect(() => {
if (editorElementRef.current) {
// Ensure Vue is available
if (typeof Vue !== 'undefined' && typeof LeadCollectorBlock !== 'undefined' && typeof LeadCollectorBlock.renderBlock === 'function') {
// Mount the Vue app into the editor element
LeadCollectorBlock.renderBlock(attributes, editorElementRef.current);
} else {
console.error('Vue or LeadCollectorBlock.renderBlock not available in editor.');
}
}
// Cleanup function to unmount the Vue app when the block is removed or updated
return () => {
if (editorElementRef.current && editorElementRef.current.__vue_app__) {
// This is a simplified unmount. Actual unmounting depends on Vue version and setup.
// For Vue 3, you might need to call app.unmount() if you stored the app instance.
// If renderBlock returns the app instance, store it.
// For now, we'll assume a basic cleanup or rely on Gutenberg's DOM management.
}
};
}, [attributes]); // Re-render when attributes change
return (
<div { ...blockProps } ref={ editorElementRef }>
{ /* The Vue app will render inside this div */ }
{ __( 'Loading Lead Collector Block...', 'custom-lead-collector' ) }
</div>
);
},
/**
* Save function for the block.
* This defines the markup that will be saved to the database.
* Since our Vue app handles rendering, we'll use the render_callback defined in PHP.
* The save function here should return null or an empty string if using render_callback.
*/
save: () => {