Building Divi 5 Modules Yourself: The Complete Step-by-Step Guide

Cube of wooden and white building blocks, loose parts and a screwdriver, a cobalt-blue sphere beside themAI-generated
Contents

The Divi Module Builder is nice for quick wrappers – but if you want to build serious modules with their own logic, you can’t get around the code-first approach. Divi 5 has completely rewritten the rules for it: TypeScript instead of JSX, traits instead of mega-classes, JSON instead of shortcodes. Sounds like more work. It is – but with a system.

What you will have built by the end

So the tutorial doesn’t stay abstract, we’ll build a concrete module together: a Feature Box. You know it from a hundred landing pages – icon on top, bold title, short description, optionally a call-to-action button.

Sounds simple. It is simple. But that’s exactly why it’s perfect for learning: you get text fields, an icon picker, a link field, design options for colors and spacing – in other words, everything Divi 5 offers in settings APIs, without drowning in the example.

What this tutorial is NOT

This is not a Divi-for-dummies guide. You should have seen WordPress hooks, a bit of React and PHP OOP before. But: you don’t have to be a TypeScript pro or a webpack wizard. We’ll go through every term cleanly, once.

Prerequisites

Before we start, a quick inventory. You need three things up and running:

  • WordPress + Divi 5 (theme or plugin version) on a local or staging environment. Never develop directly on a live site.
  • Node.js 18+ and npm – for the build process. Check quickly with node -v.
  • PHP 8.0+ – Divi 5 uses modern language features such as constructor property promotion and traits.

You also need a code editor with TypeScript support – VS Code with the TypeScript extension is perfectly sufficient. And Git, because we’re about to clone an official boilerplate.

Use a local environment!

During development you’ll run lots of builds, often wreck the database and flatten caches. Do that on LocalWP, DDEV or Docker – not on a production site. Not even on a staging environment with real customer data.

The anatomy of a Divi 5 module

Divi 5 strictly separates the frontend (TypeScript/React) from the backend (PHP). Both parts live in the same extension – but in separate directories, with separate responsibilities.

feature-box-extension/
│
├── modules/                          # PHP – läuft serverseitig
│   └── FeatureBox/
│       ├── FeatureBoxTrait/
│       │   ├── CustomCssTrait.php          # Custom-CSS-Definitionen
│       │   ├── ModuleClassnamesTrait.php   # CSS-Klassen am Wrapper
│       │   ├── ModuleStylesTrait.php       # dynamische Styles
│       │   └── RenderCallbackTrait.php     # das eigentliche HTML
│       └── FeatureBox.php             # Modul-Hauptklasse
│
├── src/                              # TypeScript – läuft im Visual Builder
│   └── components/
│       └── feature-box/
│           ├── module.json            # ← Herzstück: Metadaten
│           ├── types.ts               # Attribute-Interfaces
│           ├── edit.tsx               # Visual-Builder-Komponente
│           ├── settings-content.tsx   # Content-Tab
│           ├── settings-design.tsx    # Design-Tab
│           ├── styles.tsx             # CSS-in-JS
│           ├── custom-css.ts          # Custom-CSS-Slots
│           └── index.ts               # Export-Sammelstelle
│
├── feature-box-extension.php         # Plugin-Bootstrap
├── package.json                      # npm-Dependencies
├── tsconfig.json                     # TypeScript-Config
└── webpack.config.js                 # Build-Setup

That looks like a lot at first. But every file has exactly one job – and most of them are copy-paste routine after the first module.

Understanding the two worlds

TypeScript world: everything that happens in the Visual Builder – settings, live preview, drag&drop. It runs in the browser.
PHP world: everything that delivers the finished page to the frontend – the HTML the visitor sees. The two worlds share only the module.json as a contract.

Step 1: Set up the extension boilerplate

You could write everything by hand. But nobody does. Elegant Themes maintains an official example repo that takes care of the entire build configuration for you:

# Offizielles Boilerplate klonen
git clone https://github.com/elegantthemes/d5-extension-example-modules.git feature-box-extension

cd feature-box-extension

# Dependencies installieren
npm install
composer install

In the boilerplate you’ll find several example modules. You can keep them as a reference before you delete them. Now we’ll create our own module directory next to them:

# Verzeichnisse für unser Modul
mkdir -p src/components/feature-box
mkdir -p modules/FeatureBox/FeatureBoxTrait

Before we write code, a quick look at the package.json. In the boilerplate, everything we need is already there under scripts:

{
  "scripts": {
    "dev": "webpack --mode development --watch",
    "build": "webpack --mode production",
    "lint": "eslint src --ext .ts,.tsx"
  }
}

npm run dev starts a watcher that recompiles on every code change. From now on, it’s your constant companion.

Step 2: Define module.json

The module.json is the heart of your module. It tells Divi:

  • What is the module called (internally and for the builder)?
  • Which attributes can the user set?
  • Which default values apply?
  • Which settings components should be loaded?
{
  "d5": {
    "name": "d5/feature-box",
    "d4Name": "",
    "label": "Feature Box",
    "category": "module",
    "description": "Icon, Titel, Beschreibung und CTA-Button in einer Box.",
    "icon": "feature-box-icon",
    "keywords": ["feature", "box", "card", "cta"],
    "styles": [
      "./styles.tsx"
    ],
    "settings": {
      "content": "./settings-content.tsx",
      "design": "./settings-design.tsx"
    },
    "attributes": {
      "title": {
        "default": {
          "desktop": { "value": "Dein Feature-Titel" }
        }
      },
      "description": {
        "default": {
          "desktop": { "value": "Eine kurze, knackige Beschreibung des Features." }
        }
      },
      "icon": {
        "default": {
          "desktop": { "value": "\\f005" }
        }
      },
      "buttonText": {
        "default": {
          "desktop": { "value": "Mehr erfahren" }
        }
      },
      "buttonUrl": {
        "default": {
          "desktop": { "value": "#" }
        }
      }
    }
  }
}

Three fields deserve special attention:

  • name – must begin with d5/ and be globally unique.
  • d4Name – only relevant if you’re replacing an existing Divi 4 module (e.g., "et_pb_my_old_module"). For a completely new module: leave it empty.
  • attributes – the data structure. Every default value is structured by breakpoint (desktop, tablet, phone) – Divi is responsive to the core.

Step 3: TypeScript types

The types.ts describes your attributes as a TypeScript interface. Sounds like bureaucracy, but it’s worth its weight in gold: as soon as you misspell an attribute anywhere in the code, the compiler yells – instead of you only noticing the bug in the browser.

import {
  ModuleAttrs,
  ResponsiveValue,
} from '@divi/types';

export interface FeatureBoxAttrs extends ModuleAttrs {
  title: ResponsiveValue<string>;
  description: ResponsiveValue<string>;
  icon: ResponsiveValue<string>;
  buttonText: ResponsiveValue<string>;
  buttonUrl: ResponsiveValue<string>;
}

export interface FeatureBoxProps {
  attrs: FeatureBoxAttrs;
  id: string;
  name: string;
  parentId?: string;
}

Important: ResponsiveValue<T> wraps your value in the responsive structure. Later you won’t work with attrs.title directly, but read out the matching breakpoint – Divi helpers take care of that, though, not you.

Step 4: Settings (Content & Design tab)

In the Visual Builder, every module has three tabs: Content, Design and Advanced. We define the first two – Divi provides Advanced automatically.

The Content tab

This is where the content fields go – texts, icon, link. Each Field is its own React component from Divi that you only need to configure:

import React from 'react';
import {
  GroupContainer,
  Field,
  TextInput,
  TextArea,
  IconPicker,
  LinkInput,
} from '@divi/field-library';
import { useAttributes } from '@divi/module';

export const SettingsContent = () => {
  const [attrs, setAttrs] = useAttributes();

  return (
    <>
      <GroupContainer id="text" title="Text">
        <Field label="Titel" attrName="title">
          <TextInput />
        </Field>
        <Field label="Beschreibung" attrName="description">
          <TextArea />
        </Field>
      </GroupContainer>

      <GroupContainer id="icon" title="Icon">
        <Field label="Icon" attrName="icon">
          <IconPicker />
        </Field>
      </GroupContainer>

      <GroupContainer id="button" title="Button">
        <Field label="Button-Text" attrName="buttonText">
          <TextInput />
        </Field>
        <Field label="Button-Link" attrName="buttonUrl">
          <LinkInput />
        </Field>
      </GroupContainer>
    </>
  );
};

Three things happen here:

  • GroupContainer bundles related fields into a collapsible group in the builder.
  • Field attrName="title" connects the field to the attribute from the module.json.
  • The input components (TextInput, IconPicker, …) come from Divi’s field library – you don’t build them yourself.

The Design tab

For the Design tab, Divi uses so-called style groups. These are ready-made bundles of settings for typical tasks – spacing, border, box shadow, typography. You only need to include them:

import React from 'react';
import {
  BackgroundGroup,
  SpacingGroup,
  BorderGroup,
  BoxShadowGroup,
  FontGroup,
  ButtonGroup,
} from '@divi/module-library';

export const SettingsDesign = () => (
  <>
    <FontGroup
      groupLabel="Titel-Typografie"
      attrName="titleFont"
    />
    <FontGroup
      groupLabel="Beschreibungs-Typografie"
      attrName="descriptionFont"
    />
    <ButtonGroup
      groupLabel="Button"
      attrName="button"
    />
    <BackgroundGroup attrName="background" />
    <SpacingGroup attrName="spacing" />
    <BorderGroup attrName="border" />
    <BoxShadowGroup attrName="boxShadow" />
  </>
);

Style groups are a gift

In Divi 4, you had to implement spacing, border and shadow logic manually for each module. In Divi 5 they are ready-made components – including responsive tabs, hover states and sticky states. Use them wherever you can.

Step 5: Edit component (Visual Builder)

The edit.tsx is what the user sees in the Visual Builder. Important: this is not the final frontend HTML – PHP does that later. But it has to be visually as close as possible so that WYSIWYG works.

import React from 'react';
import { ModuleContainer } from '@divi/module';
import { FeatureBoxProps } from './types';

export const FeatureBoxEdit: React.FC<FeatureBoxProps> = ({
  attrs,
  id,
  name,
  parentId,
}) => {
  const title = attrs?.title?.desktop?.value ?? '';
  const description = attrs?.description?.desktop?.value ?? '';
  const icon = attrs?.icon?.desktop?.value ?? '';
  const buttonText = attrs?.buttonText?.desktop?.value ?? '';
  const buttonUrl = attrs?.buttonUrl?.desktop?.value ?? '#';

  return (
    <ModuleContainer
      id={id}
      name={name}
      parentId={parentId}
      classnames="feature-box"
    >
      {icon && (
        <div className="feature-box__icon">
          <span data-icon={icon} />
        </div>
      )}
      <h3 className="feature-box__title">{title}</h3>
      <p className="feature-box__description">{description}</p>
      {buttonText && (
        <a href={buttonUrl} className="feature-box__button">
          {buttonText}
        </a>
      )}
    </ModuleContainer>
  );
};

ModuleContainer wraps your component in the standard wrapper element that Divi needs for hover controls, drag&drop and sticky states. Never leave it out.

Step 6: Styles

The styles.tsx translates attributes from the Design tab into real CSS. Divi calls this StyleDeclarations: each style group registers itself, looks up its value in the attrs object and pumps out the matching CSS.

import React from 'react';
import {
  StyleContainer,
  CommonStyle,
  FontStyle,
  BackgroundStyle,
  SpacingStyle,
  BorderStyle,
  BoxShadowStyle,
} from '@divi/module';
import { FeatureBoxAttrs } from './types';

interface StylesProps {
  attrs: FeatureBoxAttrs;
  orderClass: string;
}

export const FeatureBoxStyles: React.FC<StylesProps> = ({
  attrs,
  orderClass,
}) => (
  <StyleContainer>
    {/* Wrapper-Styles */}
    <CommonStyle selector={`${orderClass}.feature-box`}>
      <BackgroundStyle attr={attrs.background} />
      <SpacingStyle attr={attrs.spacing} />
      <BorderStyle attr={attrs.border} />
      <BoxShadowStyle attr={attrs.boxShadow} />
    </CommonStyle>

    {/* Titel */}
    <CommonStyle selector={`${orderClass} .feature-box__title`}>
      <FontStyle attr={attrs.titleFont} />
    </CommonStyle>

    {/* Beschreibung */}
    <CommonStyle selector={`${orderClass} .feature-box__description`}>
      <FontStyle attr={attrs.descriptionFont} />
    </CommonStyle>
  </StyleContainer>
);

The orderClass is a unique class per module instance (e.g., .et_pb_feature_box_0_tb_body) – so the settings of different module instances don’t clash.

Step 7: PHP class with traits

With that, we leave the TypeScript world. On the PHP side, you define the module class that Divi renders on the frontend. In Divi 4, that was an 800-line class. In Divi 5, it’s a slim main class plus traits that split up the logic.

The main class

<?php
declare(strict_types=1);

namespace MyExtension\Modules\FeatureBox;

use ET\Builder\Framework\Utility\Conditions;
use ET\Builder\Packages\Module\Module;
use ET\Builder\Packages\Module\Options\Css\CssTrait;
use MyExtension\Modules\FeatureBox\FeatureBoxTrait\CustomCssTrait;
use MyExtension\Modules\FeatureBox\FeatureBoxTrait\ModuleClassnamesTrait;
use MyExtension\Modules\FeatureBox\FeatureBoxTrait\ModuleStylesTrait;
use MyExtension\Modules\FeatureBox\FeatureBoxTrait\RenderCallbackTrait;

class FeatureBox
{
    use CssTrait;
    use CustomCssTrait;
    use ModuleClassnamesTrait;
    use ModuleStylesTrait;
    use RenderCallbackTrait;

    public static function load(): void
    {
        $module_json_folder_path = dirname(__DIR__, 2)
            . '/src/components/feature-box/';

        add_filter(
            'divi_conversion_presets_attrs_map',
            [self::class, 'presetAttrsMap'],
            10,
            2
        );

        Module::register(
            $module_json_folder_path,
            [
                'render_callback' => [self::class, 'renderCallback'],
            ]
        );
    }

    public static function presetAttrsMap(array $map, string $module): array
    {
        if ('d5/feature-box' !== $module) {
            return $map;
        }
        return $map; // hier ggf. Preset-Mapping ergänzen
    }
}

What happens here:

  • Module::register() is the central API – Divi reads the module.json and marries it to your render callback.
  • The use statements pull in the traits. Each trait encapsulates one aspect: CSS, class names, styles, render HTML.

The class name trait

<?php
declare(strict_types=1);

namespace MyExtension\Modules\FeatureBox\FeatureBoxTrait;

use ET\Builder\Packages\Module\Layout\Components\Classnames;

trait ModuleClassnamesTrait
{
    public static function moduleClassnames(array $args): void
    {
        /** @var Classnames $classnamesInstance */
        $classnamesInstance = $args['classnamesInstance'];
        $attrs = $args['attrs'] ?? [];

        $classnamesInstance->add('feature-box', true);

        if (! empty($attrs['icon']['desktop']['value'])) {
            $classnamesInstance->add('feature-box--has-icon', true);
        }
    }
}

Here you attach conditional classes to the wrapper – for example feature-box--has-icon if the user has chosen an icon.

Step 8: Render callback

The most important trait: this is where the HTML that actually goes to the browser is created.

<?php
declare(strict_types=1);

namespace MyExtension\Modules\FeatureBox\FeatureBoxTrait;

use ET\Builder\Packages\Module\Module;
use ET\Builder\Packages\Module\Options\Element\ElementComponents;

trait RenderCallbackTrait
{
    public static function renderCallback(
        array $attrs,
        string $content,
        \WP_Block $block,
        array $elements
    ): string {
        $title       = $attrs['title']['desktop']['value']       ?? '';
        $description = $attrs['description']['desktop']['value'] ?? '';
        $icon        = $attrs['icon']['desktop']['value']        ?? '';
        $buttonText  = $attrs['buttonText']['desktop']['value']  ?? '';
        $buttonUrl   = $attrs['buttonUrl']['desktop']['value']   ?? '#';

        $iconHtml = $icon
            ? sprintf(
                '<div class="feature-box__icon"><span data-icon="%s"></span></div>',
                esc_attr($icon)
            )
            : '';

        $buttonHtml = $buttonText
            ? sprintf(
                '<a href="%s" class="feature-box__button">%s</a>',
                esc_url($buttonUrl),
                esc_html($buttonText)
            )
            : '';

        $children = sprintf(
            '%s<h3 class="feature-box__title">%s</h3>'
          . '<p class="feature-box__description">%s</p>%s',
            $iconHtml,
            esc_html($title),
            esc_html($description),
            $buttonHtml
        );

        return Module::render([
            'attrs'              => $attrs,
            'elements'           => $elements,
            'id'                 => $block->parsed_block['id'] ?? '',
            'name'               => $block->block_type->name,
            'classnamesFunction' => [self::class, 'moduleClassnames'],
            'stylesComponent'    => [self::class, 'moduleStyles'],
            'children'           => $children,
        ]);
    }
}

Don’t forget escaping

esc_html(), esc_attr(), esc_url() – mandatory. As soon as you output user input unescaped, you have an XSS vulnerability. WordPress is strict here, and so is the WP Plugin Reviewer.

Plugin bootstrap

For WordPress to know your module, you register it in the main plugin file:

<?php
/**
 * Plugin Name: Feature Box for DIVI 5
 * Description: Custom Feature Box module for DIVI 5.
 * Version:     1.0.0
 * Requires PHP: 8.0
 */

declare(strict_types=1);

if (! defined('ABSPATH')) {
    exit;
}

require_once __DIR__ . '/vendor/autoload.php';

add_action('divi_module_library_modules_dependency_tree', function () {
    \MyExtension\Modules\FeatureBox\FeatureBox::load();
});

Step 9: Build & test

Now the moment of truth. Start the build:

npm run dev

Webpack compiles your TypeScript code, hooks into the Divi builder bundles and stays in watch mode. It rebuilds on every change.

  1. Activate the plugin

    WordPress admin → Plugins → activate “Feature Box for Divi 5”.

  2. Create a test page

    Create a new page with the Divi builder, and search for “Feature Box” in the module picker.

  3. Click through the settings

    Set Title, Description, Icon, Button URL. The preview should update live with every keystroke.

  4. Check the Design tab

    Change spacing, border, background. Does it look the same in the builder as on the rendered page?

  5. Test frontend rendering

    Publish the page and open it in an incognito tab. Is the HTML right? Are the styles applied correctly?

  6. Test responsiveness

    Switch between desktop, tablet and phone in the builder. Set values per breakpoint and check that the frontend follows.

Common pitfalls

You’re guaranteed to run into at least three of them with your first module. To speed things up:

Module doesn’t show up in the builder

99% of cases: build didn’t run or cache wasn’t cleared. Run npm run build again, then clear Divi’s internal static cache (Divi → Theme Options → Builder → Advanced → disable Static CSS File Generation during development).

Settings change nothing

If you change values in the settings panel and nothing happens in the builder: most often the attrName in settings-content.tsx doesn’t match the key in the module.json. Check for typos first.

Frontend shows different output than the builder

The classic. Cause: your edit.tsx renders different HTML than your PHP render callback. Both must be structurally identical – same class names, same nesting. Otherwise the style selectors hit nothing on one side.

PHP fatal error after plugin activation

Usually a missing Composer autoload. Run composer dump-autoload and check whether the namespace mapping in composer.json matches your directory structure.

Quality checklist

Before you present the module to a client – go through everything once:

Checklist

Conclusion

Building Divi 5 modules is more work than in Divi 4 – but the architecture has finally grown up. You get type safety, a clear separation of frontend and backend, a modular trait system instead of mega-classes. Once you’ve got through the first module, the next one is a matter of hours, not days.

The biggest hurdle isn’t the code – it’s understanding that you now maintain two worlds in parallel: TypeScript for the builder, PHP for the frontend. Anyone who internalizes that is halfway there.

Michael Rademacher, realmaker

Resources

Michael Rademacher

Founder of realmaker

Michael Rademacher founded realmaker in 2003. He develops WordPress websites and software, produces films and live streams, and writes here about what he learns in client projects.

About Michael

Rather have it maintained?

Updates, backups and monitoring are handled by wp-einfach.de – for a fixed monthly price (service in German).

Go to wp-einfach.de