PageBuilder is Magento 2’s drag-and-drop content editor that replaced the plain HTML textarea in admin. A custom content type lets merchants create complex layouts without HTML knowledge while giving developers full control over the output. I show the complete implementation: XML configuration, JavaScript preview in admin, and frontend rendering.
PageBuilder architecture
A custom content type consists of four layers:
- config.xml – declares the type, fields, and appearance
- Preview JS component – how the block looks in admin’s PageBuilder editor
- Master/data form – what fields appear in the side panel when editing
- Frontend template (PHTML) – how the block is rendered on the storefront
Module structure
Vendor/PageBuilderExtension/
etc/
module.xml
config.xml <- content type config
view/
adminhtml/
web/
js/content-type/
product-showcase/
appearance/
default/
preview.js <- admin preview
template/
product-showcase/
default/
master.html <- saved HTML master format
preview.html <- admin preview template
base/
ui_component/
pagebuilder_product_showcase_form.xml <- edit form
frontend/
templates/
content-type/
product-showcase/
default.phtml <- storefront rendering
config.xml – declare the content type
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_PageBuilder:etc/content_type.xsd">
<type name="product_showcase"
label="Product Showcase"
component="Magento_PageBuilder/js/content-type"
preview_component="Vendor_PageBuilderExtension/js/content-type/product-showcase/appearance/default/preview"
master_component="Magento_PageBuilder/js/content-type/master"
form="pagebuilder_product_showcase_form"
menu_section="content"
icon="icon-pagebuilder-product"
sortOrder="40"
translate="label">
<children default_policy="deny"/>
<appearances>
<appearance name="default"
default="true"
preview_template="Vendor_PageBuilderExtension/content-type/product-showcase/default/preview"
master_template="Vendor_PageBuilderExtension/content-type/product-showcase/default/master"
reader="Magento_PageBuilder/js/master-format/read/configurable">
<elements>
<element name="main">
<style name="text_align" source="text_align"/>
<style name="border" source="border_style" converter="Magento_PageBuilder/js/converter/style/border-style"/>
<style name="border_color" source="border_color"/>
<style name="border_width" source="border_width" converter="Magento_PageBuilder/js/converter/style/border-width"/>
<style name="border_radius" source="border_radius" converter="Magento_PageBuilder/js/converter/style/border-radius"/>
<attribute name="data-content-type" source="name"/>
<attribute name="data-appearance" source="appearance"/>
<attribute name="data-sku" source="sku"/>
<attribute name="data-show-price" source="show_price"/>
<attribute name="data-button-label" source="button_label"/>
</element>
</elements>
</appearance>
</appearances>
</type>
</config>
Edit form – UI Component
<!-- view/adminhtml/ui_component/pagebuilder_product_showcase_form.xml -->
<form xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
<fieldset name="appearance_fieldset"
component="Magento_PageBuilder/js/form/element/dependent-fieldset">
<field name="appearance" formElement="select" component="Magento_PageBuilder/js/form/element/appearance">
<settings>
<dataType>text</dataType>
<label translate="true">Appearance</label>
</settings>
</field>
</fieldset>
<fieldset name="product_fieldset">
<settings><label translate="true">Product</label></settings>
<field name="sku" formElement="input">
<settings>
<dataType>text</dataType>
<label translate="true">Product SKU</label>
<validation><rule name="required-entry" xsi:type="boolean">true</rule></validation>
</settings>
</field>
<field name="show_price" formElement="checkbox">
<settings>
<dataType>boolean</dataType>
<label translate="true">Show Price</label>
</settings>
</field>
<field name="button_label" formElement="input">
<settings>
<dataType>text</dataType>
<label translate="true">Button Label</label>
</settings>
</field>
</fieldset>
</form>
Admin preview JS component
// view/adminhtml/web/js/content-type/product-showcase/appearance/default/preview.js
define([
'Magento_PageBuilder/js/content-type/preview',
'Magento_PageBuilder/js/events',
'mage/translate',
], function (PreviewBase, events, $t) {
'use strict';
return PreviewBase.extend({
defaults: {
productData: null,
isLoading: true,
},
initialize: function () {
this._super();
// Watch for SKU changes and refresh the preview
this.contentType.dataStore.subscribe((state) => {
if (state.sku) {
this.loadProductPreview(state.sku);
}
}, 'sku');
},
loadProductPreview: function (sku) {
this.isLoading(true);
fetch(`/rest/V1/products/${sku}`)
.then(r => r.json())
.then(product => {
this.productData(product);
this.isLoading(false);
})
.catch(() => {
this.productData(null);
this.isLoading(false);
});
},
});
});
Frontend template – PHTML
<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\Escaper $escaper */
$sku = $escaper->escapeHtmlAttr($block->getData('sku') ?? '');
$showPrice = (bool) ($block->getData('show_price') ?? false);
$btnLabel = $escaper->escapeHtml($block->getData('button_label') ?? 'Add to Cart');
$objectManager = \Magento\Framework\App\ObjectManager::getInstance();
$productRepo = $objectManager->get(\Magento\Catalog\Api\ProductRepositoryInterface::class);
try {
$product = $productRepo->get($sku);
} catch (\Exception $e) {
return; // SKU not found - render nothing
}
?>
<div class="product-showcase"
data-content-type="product_showcase"
data-appearance="default">
<div class="product-showcase__image">
<img src="<?= $escaper->escapeUrl(
$block->getUrl('catalog/product/image', ['image' => $product->getImage()])
) ?>" alt="<?= $escaper->escapeHtmlAttr($product->getName()) ?>" />
</div>
<div class="product-showcase__info">
<h3><?= $escaper->escapeHtml($product->getName()) ?></h3>
<?php if ($showPrice): ?>
<p class="price"><?= $escaper->escapeHtml(
number_format((float)$product->getPrice(), 2, ',', ' ') . ' PLN'
) ?></p>
<?php endif; ?>
<a href="<?= $escaper->escapeUrl($product->getProductUrl()) ?>"
class="action primary">
<?= $escaper->escapeHtml($btnLabel) ?>
</a>
</div>
</div>
Summary
PageBuilder custom content types have a significant XML and JavaScript overhead upfront. Once the pattern is established, adding new fields is straightforward. The key parts are: config.xml for field-to-attribute mapping, the JS preview for admin UX, and the PHTML template for frontend. The biggest pitfall is the preview component – invest time here because it is what merchants interact with daily in the editor.
