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.
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=openreplaygenerates a complete block plugin, and@wordpress/scriptsbuilds it withnpm startandnpm 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
sourceare stored as JSON in the block’s HTML comment delimiter. Attributes withsourceandselectorare 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 adeprecatedentry reproduces the old output. - A dynamic block returns
nullfromsaveand 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 concept | Block equivalent |
|---|---|
| Component JSX | edit function |
| Props | attributes |
setState | setAttributes |
| Server-rendered output | save 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.
| File | Purpose |
|---|---|
callout.php | Plugin header and block registration on init |
package.json | npm scripts that wrap @wordpress/scripts |
src/callout/block.json | Block declaration: name, attributes, supports, assets |
src/callout/index.js | Calls registerBlockType with edit and save |
src/callout/edit.js | Editor UI component |
src/callout/save.js | Markup that gets stored in post content |
src/callout/style.scss | Styles for the front end and the editor |
src/callout/editor.scss | Styles for the editor only |
build/ | Compiled output that WordPress actually loads |
build/blocks-manifest.php | Metadata 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 definition | Where 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
saveoutput doesn’t match the stored HTML, the editor ignores that entry, and itsmigratenever runs. - An optional
isEligiblefunction can make an entry run on a block that already passes validation.
Three habits prevent most validation errors:
- Never put non-deterministic values in
save, such as dates or random IDs. - Never read state or context in
save. - Treat
saveoutput 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 block | Dynamic block | |
|---|---|---|
| Markup lives in | Post content | PHP template |
| Updates when | Post is re-saved | Every request |
save returns | JSX | null |
| Server cost | None beyond serving content | Runs 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.
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