Type something to search...
How to create a custom Gutenberg block?

How to create a custom Gutenberg block?

To create a custom Gutenberg block, you scaffold a plugin with the official @wordpress/create-block tool, describe the block in a block.json file, write its editor interface in React, and register it in PHP with register_block_type(). After running the build, the block appears in the block inserter like any core block. The whole setup takes only a few minutes once Node.js is installed.

This guide walks you through the process from start to finish. You will build a "Callout" block with editable text, a color option, and sidebar settings, then learn how to turn it into a dynamic block rendered with PHP. Along the way you will understand what each file does so you can build your own blocks with confidence.

What Is a Gutenberg Block?

A block is a self-contained unit of content in the WordPress block editor, such as a paragraph, image, button, or columns layout. Each block has:

  • Attributes: The data the block stores, such as text, a URL, or a color.
  • An edit function: The React component shown inside the editor.
  • A save function or render callback: What the block outputs on the front end.
  • Metadata: Name, title, category, icon, and supported features, stored in block.json.

Custom blocks let you give editors purpose-built components, such as testimonials, pricing tables, or call-to-action boxes, that match your design exactly and are hard to break.

Static vs. Dynamic Blocks

Before you start, it helps to know the two main types:

  1. Static blocks: The save function produces HTML that is stored in the post content. The front end simply prints that HTML. Good for content that does not change after publishing.
  2. Dynamic blocks: Nothing (or minimal markup) is saved, and PHP generates the output on every page load. Good for content that changes, such as a list of latest posts or data from an API.

You will build a static block first, then see how to make it dynamic.

What You Need

  • Node.js and npm: Use a current LTS version of Node.js.
  • A local WordPress site: Local, DDEV, or wp-env all work well.
  • Basic JavaScript and React knowledge: You need to understand components, props, and JSX.
  • A code editor: Visual Studio Code is a popular choice.

Step 1: Scaffold the Block with create-block

Open a terminal in your site's wp-content/plugins folder and run:

npx @wordpress/create-block@latest sajjad-callout

The tool asks no questions by default and generates a complete plugin. When it finishes, move into the folder and start the development build:

cd sajjad-callout
npm start

npm start watches your files and rebuilds them whenever you save. For production, you will run npm run build instead.

Now go to Plugins > Installed Plugins, activate "Sajjad Callout", and add the block to a post. You already have a working block.

Step 2: Understand the File Structure

The scaffold creates something like this:

sajjad-callout/
├── build/                  # Compiled files (do not edit)
├── src/
│   └── sajjad-callout/
│       ├── block.json
│       ├── edit.js
│       ├── editor.scss
│       ├── index.js
│       ├── save.js
│       ├── style.scss
│       └── view.js
├── package.json
└── sajjad-callout.php
  • block.json: The block's metadata and the single source of truth for its settings.
  • index.js: Registers the block in JavaScript.
  • edit.js: The editor component.
  • save.js: The front-end markup for static blocks.
  • style.scss: Styles loaded in both the editor and the front end.
  • editor.scss: Styles loaded only in the editor.
  • view.js: Optional JavaScript that runs on the front end.
  • sajjad-callout.php: The main plugin file that registers the block with PHP.

Exact file names can vary slightly between versions of create-block, so follow what your scaffold generates.

Step 3: Configure block.json

Open src/sajjad-callout/block.json and adjust it for the callout:

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "sajjad/callout",
  "version": "1.0.0",
  "title": "Callout",
  "category": "text",
  "icon": "megaphone",
  "description": "A highlighted box for tips, warnings, and notes.",
  "keywords": ["notice", "tip", "alert"],
  "textdomain": "sajjad-callout",
  "attributes": {
    "message": {
      "type": "string",
      "source": "html",
      "selector": "p"
    },
    "variant": {
      "type": "string",
      "default": "info"
    },
    "showIcon": {
      "type": "boolean",
      "default": true
    }
  },
  "supports": {
    "html": false,
    "align": ["wide", "full"],
    "spacing": {
      "margin": true,
      "padding": true
    },
    "typography": {
      "fontSize": true
    }
  },
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css"
}

Key fields:

  • name: A unique namespace/block-name. Pick a namespace that is specific to you.
  • apiVersion: Version 3 is current and required for the iframed editor.
  • attributes: The data your block stores. Here message is read from the <p> element in saved HTML, while variant and showIcon are stored in the block's comment delimiter.
  • supports: Opt in to built-in features. Enabling spacing or typography gives you ready-made controls in the sidebar without writing any code.

Step 4: Register the Block in PHP

The scaffolded plugin file registers the block from the build folder. A minimal version looks like this:

<?php
/**
 * Plugin Name:       Sajjad Callout
 * Description:       A callout block for tips and warnings.
 * Version:           1.0.0
 * Requires at least: 6.5
 * Requires PHP:      8.0
 * Author:            Sajjad
 * License:           GPL-2.0-or-later
 * Text Domain:       sajjad-callout
 */

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

function sajjad_callout_register_block() {
register_block_type( __DIR__ . '/build/sajjad-callout' );
}
add_action( 'init', 'sajjad_callout_register_block' );

register_block_type() reads the compiled block.json, registers all its scripts and styles, and makes the block available on both the server and the client. Recent versions of create-block may generate a blocks-manifest.php and use wp_register_block_types_from_metadata_collection() for better performance when a plugin contains several blocks. Both approaches are valid.

Step 5: Build the Editor Interface

Open src/sajjad-callout/edit.js and replace its contents:

import { __ } from "@wordpress/i18n";
import {
  useBlockProps,
  RichText,
  InspectorControls,
  BlockControls,
} from "@wordpress/block-editor";
import {
  PanelBody,
  SelectControl,
  ToggleControl,
  ToolbarGroup,
  ToolbarButton,
} from "@wordpress/components";
import "./editor.scss";

const VARIANTS = [
  { label: __("Info", "sajjad-callout"), value: "info" },
  { label: __("Success", "sajjad-callout"), value: "success" },
  { label: __("Warning", "sajjad-callout"), value: "warning" },
];

export default function Edit({ attributes, setAttributes }) {
  const { message, variant, showIcon } = attributes;

  const blockProps = useBlockProps({
    className: `is-variant-${variant}`,
  });

  return (
    <>
      <BlockControls>
        <ToolbarGroup>
          <ToolbarButton
            icon="info-outline"
            label={__("Toggle icon", "sajjad-callout")}
            isPressed={showIcon}
            onClick={() => setAttributes({ showIcon: !showIcon })}
          />
        </ToolbarGroup>
      </BlockControls>

      <InspectorControls>
        <PanelBody title={__("Callout settings", "sajjad-callout")}>
          <SelectControl
            label={__("Style", "sajjad-callout")}
            value={variant}
            options={VARIANTS}
            onChange={(value) => setAttributes({ variant: value })}
            __nextHasNoMarginBottom
          />
          <ToggleControl
            label={__("Show icon", "sajjad-callout")}
            checked={showIcon}
            onChange={(value) => setAttributes({ showIcon: value })}
            __nextHasNoMarginBottom
          />
        </PanelBody>
      </InspectorControls>

      <div {...blockProps}>
        {showIcon && (
          <span className="sajjad-callout__icon" aria-hidden="true">
            !
          </span>
        )}
        <RichText
          tagName="p"
          className="sajjad-callout__message"
          value={message}
          onChange={(value) => setAttributes({ message: value })}
          placeholder={__("Write your callout…", "sajjad-callout")}
          allowedFormats={["core/bold", "core/italic", "core/link"]}
        />
      </div>
    </>
  );
}

Here is what each piece does:

  • useBlockProps(): Adds the classes, attributes, and event handlers WordPress needs on the block's outer element. Always spread it on your wrapper.
  • RichText: An editable text field that supports formatting like bold and links.
  • InspectorControls: Renders controls in the block settings sidebar.
  • BlockControls: Adds buttons to the floating block toolbar.
  • setAttributes(): Updates the block's attributes and re-renders it.

Step 6: Write the Save Function

Open src/sajjad-callout/save.js:

import { useBlockProps, RichText } from "@wordpress/block-editor";

export default function save({ attributes }) {
  const { message, variant, showIcon } = attributes;

  const blockProps = useBlockProps.save({
    className: `is-variant-${variant}`,
  });

  return (
    <div {...blockProps}>
      {showIcon && (
        <span className="sajjad-callout__icon" aria-hidden="true">
          !
        </span>
      )}
      <RichText.Content
        tagName="p"
        className="sajjad-callout__message"
        value={message}
      />
    </div>
  );
}

The save function must be pure. It should output the same markup every time for the same attributes, with no side effects or data fetching. The markup it returns is stored in the post content.

Step 7: Register the Block in JavaScript

src/sajjad-callout/index.js connects the pieces:

import { registerBlockType } from "@wordpress/blocks";
import "./style.scss";
import Edit from "./edit";
import save from "./save";
import metadata from "./block.json";

registerBlockType(metadata.name, {
  edit: Edit,
  save,
});

All the other settings, such as title, icon, and attributes, come from block.json.

Step 8: Style the Block

Add front-end and editor styles to src/sajjad-callout/style.scss:

.wp-block-sajjad-callout {
  display: flex;
  gap: 0.75rem;
  align-items: flex-start;
  padding: 1rem 1.25rem;
  border-left: 4px solid currentColor;
  border-radius: 6px;
}

.wp-block-sajjad-callout.is-variant-info {
  background: #eff6ff;
  color: #1e40af;
}

.wp-block-sajjad-callout.is-variant-success {
  background: #f0fdf4;
  color: #166534;
}

.wp-block-sajjad-callout.is-variant-warning {
  background: #fffbeb;
  color: #92400e;
}

.wp-block-sajjad-callout .sajjad-callout__icon {
  flex-shrink: 0;
  width: 1.5rem;
  height: 1.5rem;
  border-radius: 50%;
  background: currentColor;
  color: #fff;
  font-weight: 700;
  text-align: center;
  line-height: 1.5rem;
}

.wp-block-sajjad-callout .sajjad-callout__message {
  margin: 0;
  color: inherit;
}

WordPress automatically adds a class based on the block name, wp-block-sajjad-callout, to the wrapper through useBlockProps().

Save your files and, with npm start running, reload the editor. Your callout now has a style selector in the sidebar and an icon toggle in the toolbar.

Handling Block Validation Errors

If you change the save function after publishing posts that use the block, you may see "This block contains unexpected or invalid content." That happens because the saved HTML no longer matches what the new save function produces.

To change markup safely, add a deprecated entry that describes the old version:

const v1 = {
  attributes: {
    message: { type: "string", source: "html", selector: "p" },
    variant: { type: "string", default: "info" },
  },
  save({ attributes }) {
    return (
      <div {...useBlockProps.save()}>
        <RichText.Content tagName="p" value={attributes.message} />
      </div>
    );
  },
};

registerBlockType(metadata.name, {
  edit: Edit,
  save,
  deprecated: [v1],
});

WordPress tries each deprecated version until one matches, then upgrades the block to the current format when the post is edited. This is one reason many developers prefer dynamic blocks for complex output.

Making the Block Dynamic

For output that should always be current, or that you want to change without breaking saved content, render the block in PHP.

Option 1: Use a render.php File

Add a render field to block.json:

"render": "file:./render.php"

Make save.js return null (or remove the save export and let index.js pass save: () => null). Then create src/sajjad-callout/render.php:

<?php
/**
 * Server-side rendering for the Callout block.
 *
 * @var array    $attributes Block attributes.
 * @var string   $content    Inner block content.
 * @var WP_Block $block      Block instance.
 */

$variant   = isset( $attributes['variant'] ) ? sanitize_html_class( $attributes['variant'] ) : 'info';
$message   = isset( $attributes['message'] ) ? $attributes['message'] : '';
$show_icon = ! empty( $attributes['showIcon'] );

$wrapper_attributes = get_block_wrapper_attributes(
array( 'class' => 'is-variant-' . $variant )
);
?>
<div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
<?php if ( $show_icon ) : ?>
<span class="sajjad-callout__icon" aria-hidden="true">!</span>
<?php endif; ?>
<p class="sajjad-callout__message"><?php echo wp_kses_post( $message ); ?></p>
</div>

For a dynamic block, change the message attribute in block.json so it is stored in the block comment instead of parsed from saved HTML. Simply remove the source and selector keys:

"message": {
"type": "string",
"default": ""
}

get_block_wrapper_attributes() is the PHP counterpart of useBlockProps(). It outputs the block class plus any classes and inline styles generated by supports such as spacing and font size. It returns already-escaped attributes.

Option 2: A render_callback in PHP

You can also pass a callback when registering the block:

function sajjad_callout_register_block() {
register_block_type(
__DIR__ . '/build/sajjad-callout',
array(
'render_callback' => 'sajjad_callout_render',
)
);
}
add_action( 'init', 'sajjad_callout_register_block' );

function sajjad_callout_render( $attributes, $content, $block ) {
$message = isset( $attributes['message'] ) ? $attributes['message'] : '';

return sprintf(
'<div %1$s><p class="sajjad-callout__message">%2$s</p></div>',
get_block_wrapper_attributes(),
wp_kses_post( $message )
);
}

The render.php approach is generally preferred for new blocks because everything stays next to block.json.

Previewing Dynamic Blocks in the Editor

For dynamic blocks, your edit component can still render a live React preview as shown earlier. Alternatively, the ServerSideRender component from @wordpress/server-side-render fetches the PHP output and displays it in the editor, which is useful for blocks that list posts or pull external data.

Adding Front-End Interactivity

If your block needs JavaScript on the front end, such as a dismiss button, you have two options:

  • viewScript in block.json: A plain script that loads only on pages where the block appears.
  • The Interactivity API: WordPress's official system for declarative front-end behavior, available since WordPress 6.5. Scaffold with npx @wordpress/create-block@latest my-block --template @wordpress/create-block-interactive-template to see an example.

Both approaches load scripts only when the block is on the page, which keeps your site fast.

Building for Production

When you are ready to deploy, stop npm start and run:

npm run build

This creates minified files in build/ along with index.asset.php files that list script dependencies and versions. Deploy the plugin folder with the build directory. You do not need to upload node_modules or, strictly speaking, src, although keeping src in version control is essential.

Other handy scripts included by @wordpress/scripts:

npm run lint:js
npm run lint:css
npm run format
npm run plugin-zip

plugin-zip creates a clean ZIP file ready to install on another site.

Best Practices for Custom Blocks

Use block.json for everything you can: It enables performance optimizations, the block directory, and server-side registration.

Lean on supports: Built-in color, spacing, and typography supports integrate with theme.json and global styles, so you write less code and editors get a consistent experience.

Keep save functions simple: Complex markup in save leads to validation errors later. Consider dynamic rendering for anything likely to change.

Escape in PHP: Use wp_kses_post(), esc_html(), and esc_url() in render templates.

Make blocks translatable: Wrap strings in __() with your text domain, and use wp_set_script_translations() if you manage translations yourself.

Think about accessibility: Use semantic HTML, sensible heading levels, and aria attributes where needed.


FAQ: Creating Custom Gutenberg Blocks

Basic React knowledge helps a lot, since the editor interface is built with React components. You can start with the scaffolded code and learn as you go.

Yes, you can write plain JavaScript using wp.element.createElement and enqueue it manually, but create-block with a build step is the recommended and far more comfortable workflow.

A static block saves its HTML into the post content. A dynamic block is rendered by PHP on every page load, so its output can change without re-saving the post.

The saved HTML no longer matches your current save function, usually because you changed the markup. Add a deprecated version or convert the block to dynamic rendering.

You can register blocks from a theme's functions.php, but a plugin is usually better because the content keeps working if you switch themes.

Yes, for many layouts. Patterns combine existing core blocks into reusable designs with no JavaScript, so try a pattern first and build a custom block when you need custom data or behavior.

Attributes with a source such as html are parsed from the saved markup. Other attributes are stored as JSON inside the block's HTML comment delimiter in the post content.


Conclusion

Creating a custom Gutenberg block is much more approachable than it once was. With @wordpress/create-block, you get a complete plugin and build pipeline in one command. From there, block.json defines your block, edit.js shapes the editor experience with React components like RichText and InspectorControls, and either save.js or render.php controls the front-end output.

Start with a simple static block like the callout in this guide, then experiment with block supports, deprecations, and dynamic rendering. Once you are comfortable, explore the Interactivity API for front-end behavior. Custom blocks let you give editors exactly the tools they need while keeping your site's design consistent and your content future-proof.

Tags :
Share :

Related Posts

What are the best WordPress security plugins?

What are the best WordPress security plugins?

The best WordPress security plugins for most sites are Wordfence, Sucuri Security, Solid Security, MalCare, All-In-One Security (AIOS), Patchstack, a

Dive Deeper
What is the difference between posts and pages in WordPress?

What is the difference between posts and pages in WordPress?

The main difference between posts and pages in WordPress is that posts are timely, dated entries that appear in your blog feed, archives, and RSS fee

Dive Deeper
How much does it cost to build a WordPress website?

How much does it cost to build a WordPress website?

Building a WordPress website can cost anywhere from roughly the price of a domain and a year of budget hosting, if you do it yourself with free tools

Dive Deeper