12k
All articles

How to Build a Custom Gutenberg Block

Build a custom Gutenberg block with React, block.json, and PHP. See how attributes, static and dynamic rendering, and deprecated saves prevent validation errors.

OpenReplay Team
OpenReplay Team
How to Build a Custom Gutenberg Block

A Gutenberg block is a React component that WordPress registers under a namespaced name. It renders in the editor through an edit function and outputs on the front end through either a save function or a PHP render template.

If you can write a React component, you can write a block. Most of the difficulty is in the WordPress-specific parts around the component: a JSON manifest, a PHP registration call, and a validation step that flags “This block contains unexpected or invalid content” as soon as you change your markup.

Before blocks existed, you added custom behavior by adding custom JavaScript to WordPress themes with wp_enqueue_script. Blocks replace that for content: editors get a visual, editable component in place of a script that runs against static HTML. This guide builds a callout block with an editable RichText body and a URL field in the sidebar. It explains every scaffolded file and shows why validation errors happen and how to fix them.

Key Takeaways

  • npx @wordpress/create-block@latest callout --namespace=openreplay generates a complete block plugin, and @wordpress/scripts builds it with npm start and npm run build.
  • block.json declares the block. The PHP and JavaScript registration calls both read its name, attributes, supports and asset paths from that one file.
  • Attributes without a source are stored as JSON in the block’s HTML comment delimiter. Attributes with source and selector are parsed back out of the saved markup.
  • When a post loads, the editor re-runs save() and compares the result with the stored markup. Any difference triggers the invalid-content error until a deprecated entry reproduces the old output.
  • A dynamic block returns null from save and renders in PHP on every request. Use one for output that must change without anyone re-saving the post.

A Block Is a React Component

A Gutenberg block is a React component whose WordPress-specific API maps onto patterns React developers already know:

React conceptBlock equivalent
Component JSXedit function
Propsattributes
setStatesetAttributes
Server-rendered outputsave function or render.php

A Gutenberg block differs from a plain React component in two ways. First, save output is written into post content as HTML. Second, the attribute schema lives in JSON instead of in PropTypes or TypeScript.

Scaffold the Block with create-block

The official @wordpress/create-block package generates a working block plugin with its build tooling already configured. Run it inside wp-content/plugins:

npx @wordpress/create-block@latest callout --namespace=openreplay
cd callout
npm start        # watch mode during development
npm run build    # production build

If you leave out --namespace, the block is named create-block/callout. Once npm start is running, activate the plugin in the admin screen.

FilePurpose
callout.phpPlugin header and block registration on init
package.jsonnpm scripts that wrap @wordpress/scripts
src/callout/block.jsonBlock declaration: name, attributes, supports, assets
src/callout/index.jsCalls registerBlockType with edit and save
src/callout/edit.jsEditor UI component
src/callout/save.jsMarkup that gets stored in post content
src/callout/style.scssStyles for the front end and the editor
src/callout/editor.scssStyles for the editor only
build/Compiled output that WordPress actually loads
build/blocks-manifest.phpMetadata for every block, generated at build time

The PHP file is an ordinary plugin (see writing a WordPress plugin from scratch) with one registration call added. The underlying concept is register_block_type(), which accepts a folder that contains block.json. The scaffold wraps it in a batch call that requires WordPress 6.8 or later, and the generated plugin header sets Requires at least: 6.8 to match. A simplified version of that call:

add_action( 'init', function () {
	wp_register_block_types_from_metadata_collection(
		__DIR__ . '/build',
		__DIR__ . '/build/blocks-manifest.php'
	);
} );

The block registration guide explains both forms.

block.json: Where the Block Is Declared

The block.json file is the single declaration of a block. The PHP and JavaScript registration calls both read its name, attributes, supports and asset paths, so one file describes the block to both the editor and the server.

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "openreplay/callout",
  "version": "0.1.0",
  "title": "Callout",
  "category": "text",
  "icon": "info",
  "attributes": {
    "content": { "type": "string", "source": "html", "selector": "p" },
    "url": { "type": "string", "default": "" }
  },
  "supports": { "html": false },
  "textdomain": "callout",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css"
}

name is namespace/slug. Treat it as permanent, because it is written into the markup of every post that uses the block.

apiVersion: 3 arrived in WordPress 6.3 and declares that the block works inside the iframed editor. Since WordPress 6.9 the schema accepts only version 3, and blocks registered with an older version log a console warning when SCRIPT_DEBUG is enabled. From WordPress 7.1, the post editor always runs in an iframe, whatever apiVersion a block declares, so every block has to work inside it.

attributes define the block’s data. type and default work the way you would expect. source controls where the value lives. The Attributes reference explains that a value with no source ends up in the comment delimiter, while source: "html" reads the inner HTML of the matched element, which is what RichText fields usually rely on.

Attribute definitionWhere the value lives
No source (url)JSON inside the <!-- wp:... --> comment
source + selector (content)Parsed from the saved HTML on each load

This is what the stored post content looks like (illustrative):

<!-- wp:openreplay/callout {"url":"https://example.com"} -->
<div class="wp-block-openreplay-callout"><p>Deploys freeze Friday at noon.</p></div>
<!-- /wp:openreplay/callout -->

supports turns on editor features. html: false hides the “Edit as HTML” option. Keys such as align and color add toolbar and sidebar controls with no extra code, as listed in the supports reference.

file: paths resolve relative to the block.json being registered. That is the copy in build/callout/, next to the compiled index.js, index.css and style-index.css. This is why PHP points at build, not src. The block metadata reference documents every key.

edit.js: RichText, InspectorControls and useBlockProps

The edit component renders only inside the editor. It receives attributes and setAttributes as props, and setAttributes does the same job as a state setter: it merges the patch you pass and triggers a re-render.

import { __ } from '@wordpress/i18n';
import { useBlockProps, RichText, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';

export default function Edit( { attributes, setAttributes } ) {
  const { content, url } = attributes;
  return (
    <>
      <InspectorControls>
        <PanelBody title={ __( 'Link', 'callout' ) }>
          <TextControl
            label={ __( 'URL', 'callout' ) }
            value={ url }
            onChange={ ( value ) => setAttributes( { url: value } ) }
          />
        </PanelBody>
      </InspectorControls>
      <div { ...useBlockProps() }>
        <RichText
          tagName="p"
          value={ content }
          onChange={ ( value ) => setAttributes( { content: value } ) }
          placeholder={ __( 'Write the callout…', 'callout' ) }
        />
      </div>
    </>
  );
}

useBlockProps() adds the class names and data attributes the editor needs for selection and styling, so spread it onto the outermost element. RichText lets users edit text inside the block itself. InspectorControls moves settings that aren’t edited inline, such as a URL, into the sidebar. PanelBody groups those controls into a collapsible section.

save.js and the Block Validation Error

The save function produces the markup stored in post content. It must be a pure function of attributes: no state, no effects and no hooks apart from useBlockProps.save().

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

export default function save( { attributes } ) {
  return (
    <div { ...useBlockProps.save() }>
      <RichText.Content tagName="p" value={ attributes.content } />
    </div>
  );
}

RichText.Content outputs the stored value as plain markup, and useBlockProps.save() adds the wrapper class on the front end.

Why Does Block Validation Fail?

When a post opens in the editor, WordPress runs the block’s current save function with the parsed attributes and compares the result with the markup stored in the post. The Edit and Save guide treats any gap between that fresh output and the stored HTML as an invalid block. The editor then shows “This block contains unexpected or invalid content” and logs a diff to the browser console.

Changing a save function does not rewrite posts that already contain the block. Suppose v2 adds a class and a link:

export default function save( { attributes } ) {
  const { content, url } = attributes;
  return (
    <div { ...useBlockProps.save() }>
      <RichText.Content tagName="p" className="callout__body" value={ content } />
      { url && <a href={ url }>Learn more</a> }
    </div>
  );
}

Every existing callout now fails validation, because the new save output no longer matches the old stored markup.

The Fix: deprecated

A deprecated entry fixes the block validation error by holding the previous attributes and the previous save function. It can also include an optional migrate function that maps old attributes to a new shape. With this entry in place, the editor can recognise the old markup and upgrade it the next time the post is saved.

import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps, RichText } from '@wordpress/block-editor';
import Edit from './edit';
import save from './save';
import metadata from './block.json';

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

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

Each entry carries its own attributes because parsing depends on them. If v2 had also changed the selector from p to .callout__body, v1 would still need selector: 'p' to read old posts correctly.

The deprecation reference sets these rules:

  • List entries newest first.
  • The editor tests every entry separately against the markup as it was saved. One entry never passes its result on to the next.
  • If an entry’s save output doesn’t match the stored HTML, the editor ignores that entry, and its migrate never runs.
  • An optional isEligible function can make an entry run on a block that already passes validation.

Three habits prevent most validation errors:

  1. Never put non-deterministic values in save, such as dates or random IDs.
  2. Never read state or context in save.
  3. Treat save output like a database schema: every change needs a migration.

What Is a Dynamic Block?

A dynamic block returns null from save, stores only its attributes in post content, and is rendered by PHP on every request. Choose one when output must change without anyone re-saving the post, such as a list of the latest entries.

Static blockDynamic block
Markup lives inPost contentPHP template
Updates whenPost is re-savedEvery request
save returnsJSXnull
Server costNone beyond serving contentRuns PHP per render

Scaffold one with npx @wordpress/create-block@latest callout --namespace=openreplay --variant dynamic, or add "render": "file:./render.php" to block.json yourself. The template below lists entries from a custom post type. It assumes you have added a count attribute of type number:

<?php
$query = new WP_Query( array(
	'post_type'      => 'project',
	'posts_per_page' => isset( $attributes['count'] ) ? (int) $attributes['count'] : 3,
) );
?>
<ul <?php echo get_block_wrapper_attributes(); ?>>
	<?php while ( $query->have_posts() ) : $query->the_post(); ?>
		<li><a href="<?php echo esc_url( get_permalink() ); ?>"><?php echo esc_html( get_the_title() ); ?></a></li>
	<?php endwhile; wp_reset_postdata(); ?>
</ul>

$attributes holds the block’s parsed attributes. get_block_wrapper_attributes() is the PHP counterpart to useBlockProps. If you prefer a function to a template file, pass a render_callback to register_block_type(). Dynamic blocks never hit validation errors, because the editor has no stored markup to compare against.

Conclusion

A block is a React component plus a JSON declaration and a stored-markup contract. The component is the easy part for a React developer. The contract is where blocks break: once real posts contain your block, every change to save needs a matching deprecated entry. Scaffold the callout, add a class to its save output, and watch the error appear in the console. Then write the deprecation that clears it. After you have seen that cycle once, the invalid-content error stops being a mystery.

FAQs

Should a custom Gutenberg block live in a plugin or a theme?

Put it in a plugin. Block markup is saved into post content, so the block has to stay registered for as long as posts use it. If the code that registers it is turned off, for example by switching themes, the editor shows a message that the site does not include support for that block and offers to leave it intact or remove it. A plugin keeps the block available through theme changes.

What does Attempt Block Recovery do to an invalid block?

Attempt Block Recovery rebuilds the block from its parsed attributes with the current save function and replaces the stored markup. It fixes one block in one post, and it drops any content that the attributes do not capture. It works around the problem in the editor but does not fix the code. A deprecated entry lets every existing post upgrade without anyone recovering blocks by hand.

How do I add a second block to the same plugin?

Create a new folder under src with its own block.json, index.js, edit.js and save.js. The start and build commands of @wordpress/scripts scan src and its subfolders for block.json files, build each one as a separate entry point into a matching folder under build, and write every block into the blocks manifest that the plugin registers. Restart npm start after you add the folder.

Can a dynamic block contain nested blocks?

Yes, but save cannot return null. Render the child area with InnerBlocks or useInnerBlocksProps in edit, and return InnerBlocks.Content from save so the child markup is stored in post content. The PHP template receives that rendered child markup in the $content variable, next to $attributes. Each block supports one InnerBlocks area, and allowedBlocks limits which child blocks editors can insert.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.