Himanshu SharmaSoftware engineer
Let’s talk

WordPress · Lesson 40 of 45

Creating a custom block

Build a Gutenberg block in a plugin with block.json, attributes, and edit and save functions, in plain JavaScript with no build step to start.

18 minCustom blocksUpdated 23 October 2026

By the end of this lessonRegister a custom block from block.json, write its editor and saved markup in JavaScript, and know how the saved HTML and the block's attributes relate.

What a block is

Everything in the editor is a block: paragraphs, images, columns. You can add your own, for example a "Recipe card" or a notice box, so editors insert it from the block inserter, edit it visually, and get consistent markup.

A block has three parts:

  1. block.json: the block's description (name, title, icon, attributes, which files to load);
  2. JavaScript: an edit function for the editor and a save function for the stored HTML;
  3. PHP: a call to register_block_type().

A block can be written without any build tools, using the scripts WordPress already provides. This lesson does that, so there is nothing to install. Real projects later add a build step (see the end).

The plugin and block.json

json
{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "bakery/notice",
	"title": "Bakery notice",
	"category": "text",
	"icon": "megaphone",
	"description": "A highlighted message for visitors.",
	"keywords": [ "notice", "alert" ],
	"textdomain": "bakery-notice-block",
	"attributes": {
		"message": {
			"type": "string",
			"source": "html",
			"selector": "p"
		},
		"tone": {
			"type": "string",
			"default": "info"
		}
	},
	"supports": {
		"html": false,
		"color": { "background": true, "text": true },
		"spacing": { "padding": true }
	},
	"editorScript": "file:./index.js",
	"style": "file:./style.css"
}

The important fields:

  • name: namespace/block-name. Use your own namespace (bakery), not core.
  • attributes: the data of one block. message is read from the HTML (source: html with a selector), so the text is stored once, in the markup. tone has no source, so it is stored in the block's comment in the post.
  • supports: free features from WordPress: colour pickers, spacing controls, alignment. They are applied through useBlockProps().
  • editorScript and style: files WordPress loads in the editor, and in both the editor and the front end.

The plugin file

php
<?php
/**
 * Plugin Name: Bakery Notice Block
 * Description: A notice block.
 * Version:     1.0.0
 * Requires at least: 6.5
 */
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

add_action( 'init', function () {
	register_block_type( __DIR__ );   // reads block.json in this folder
} );
bash
wp plugin activate bakery-notice-block
result
Plugin 'bakery-notice-block' activated.
Success: Activated 1 of 1 plugins.
php
<?php
$type = WP_Block_Type_Registry::get_instance()->get_registered( 'bakery/notice' );
echo $type->title, ' (', $type->category, ")\n";
echo "your attributes: ", implode( ', ', array_intersect( array_keys( $type->attributes ), [ 'message', 'tone' ] ) ), "\n";
echo "editor script handle: ", $type->editor_script_handles[0], "\n";
result
Bakery notice (text)
your attributes: message, tone
editor script handle: bakery-notice-editor-script

register_block_type() with a folder reads block.json and registers the scripts and styles under generated handles. The block exists on the server now, and the files it points to need to exist next.

The editor script

This file uses WordPress's global wp object (wp.blocks, wp.blockEditor, wp.element) and plain createElement instead of JSX, so it runs as it is. When WordPress loads index.js because block.json named it, it also loads the dependencies for you if a file called index.asset.php lists them:

php
<?php
return [
	'dependencies' => [ 'wp-blocks', 'wp-block-editor', 'wp-components', 'wp-element', 'wp-i18n' ],
	'version'      => '1.0.0',
];
javascript
( function ( blocks, blockEditor, components, element, i18n ) {
	const el = element.createElement;
	const { useBlockProps, RichText, InspectorControls } = blockEditor;
	const { PanelBody, SelectControl } = components;
	const { __ } = i18n;

	blocks.registerBlockType( 'bakery/notice', {
		// The editor view.
		edit: function ( props ) {
			const { attributes, setAttributes } = props;
			const blockProps = useBlockProps( { className: 'is-tone-' + attributes.tone } );

			return el(
				element.Fragment,
				null,
				el(
					InspectorControls,
					null,
					el(
						PanelBody,
						{ title: __( 'Notice settings', 'bakery-notice-block' ) },
						el( SelectControl, {
							label: __( 'Tone', 'bakery-notice-block' ),
							value: attributes.tone,
							options: [
								{ label: 'Information', value: 'info' },
								{ label: 'Warning', value: 'warning' },
							],
							onChange: function ( tone ) {
								setAttributes( { tone: tone } );
							},
						} )
					)
				),
				el(
					'div',
					blockProps,
					el( RichText, {
						tagName: 'p',
						value: attributes.message,
						placeholder: __( 'Write the notice…', 'bakery-notice-block' ),
						onChange: function ( message ) {
							setAttributes( { message: message } );
						},
					} )
				)
			);
		},

		// The HTML stored in the post.
		save: function ( props ) {
			const blockProps = useBlockProps.save( { className: 'is-tone-' + props.attributes.tone } );
			return el( 'div', blockProps, el( RichText.Content, { tagName: 'p', value: props.attributes.message } ) );
		},
	} );
} )( window.wp.blocks, window.wp.blockEditor, window.wp.components, window.wp.element, window.wp.i18n );
css
.wp-block-bakery-notice {
	border-left: 4px solid #2563eb;
	background: #eff6ff;
	padding: 1rem 1.25rem;
}
.wp-block-bakery-notice.is-tone-warning {
	border-left-color: #d97706;
	background: #fffbeb;
}

The edit function and the save function work together:

  • edit is a React component that draws the block in the editor. RichText gives editable text, InspectorControls puts controls in the sidebar, and setAttributes() changes the block's data.
  • save returns the HTML saved into the post content. It must depend only on the attributes. useBlockProps.save() adds the generated class wp-block-bakery-notice and anything from supports.

The editor itself cannot run in this lesson's checks, but how the block is stored can. A saved block is ordinary HTML wrapped in a special comment that holds the attributes:

php
<?php
$post_id = wp_insert_post( [
	'post_title'   => 'Opening hours',
	'post_status'  => 'publish',
	'post_content' => '<!-- wp:bakery/notice {"tone":"warning"} -->' . "\n"
		. '<div class="wp-block-bakery-notice is-tone-warning"><p>Closed on Mondays</p></div>' . "\n"
		. '<!-- /wp:bakery/notice -->',
] );

$blocks = parse_blocks( get_post_field( 'post_content', $post_id ) );
echo $blocks[0]['blockName'], ' ', wp_json_encode( $blocks[0]['attrs'] ), "\n";
echo trim( render_block( $blocks[0] ) ), "\n";
result
bakery/notice {"tone":"warning"}
<div class="wp-block-bakery-notice is-tone-warning"><p>Closed on Mondays</p></div>

parse_blocks() turns post content into a list. The tone attribute comes from the comment, and message is read back from the HTML by the editor through the selector. On the front end WordPress prints the stored HTML as it is, so a static block costs nothing to display.

Validation: why save must stay stable

When the editor opens a post, it runs save again for every block and compares the result with the stored HTML. If they differ, you see "This block contains unexpected or invalid content" and the editor offers to attempt recovery. This is why:

  • changing a static block's save output breaks every existing instance, unless you add a deprecation entry that describes the old version;
  • a block whose output depends on changing data (latest posts, the current user, the date) should be dynamic, rendered by PHP, the topic of the next lesson.

A real build step

Writing createElement calls by hand is only good for learning. In practice you write JSX and let a tool compile it. The official tool creates a plugin with everything configured:

bash
npx @wordpress/create-block@latest bakery-notice-block --namespace bakery
cd bakery-notice-block
npm start        # compiles on every change
npm run build    # production build into build/

That gives you src/edit.js, src/save.js, block.json and a ready build with @wordpress/scripts. The concepts in this lesson are the same; only the way the JavaScript is written and compiled differs. You need Node.js installed to use it.

Common mistakes

  • Changing save of a live block without a deprecation.
  • Putting changing data in a static block, which then goes stale.
  • Skipping useBlockProps(), so the wrapper loses its classes and the supports features do nothing.
  • A block name without a namespace, or with capital letters.
  • Forgetting the dependencies, so wp.blocks is undefined in the editor.

Exercise

Add a showIcon boolean attribute (default true) to block.json. Where would you change the editor and saved output to use it?

Show one possible answer

In block.json:

json
"showIcon": { "type": "boolean", "default": true }

In edit, add a ToggleControl to the PanelBody that calls setAttributes( { showIcon: value } ). In save, add the icon inside the wrapper only when props.attributes.showIcon is true, and give the wrapper a class such as has-icon. Because save output changes, existing notices need a deprecation, or you add the icon with CSS only (a has-icon class), which keeps the stored HTML the same.

Key points

  • A block is described by block.json (name, title, category, attributes, scripts and styles) and registered in PHP with register_block_type().
  • The edit function draws the block in the editor; the save function returns the static HTML stored in the post content.
  • Attributes hold the block's data and are read from the saved HTML (or stored in the block comment); useBlockProps() adds the classes and wrapper attributes.
  • Real projects use @wordpress/scripts or @wordpress/create-block to compile JSX, but a block can be written in plain JavaScript with no build step.

Check yourself

1. Which file describes a block's name, attributes and assets?
2. What is the difference between edit and save?
3. What happens if you change a block's save output after posts already use it?
4. What does useBlockProps() do?