The block editor (Gutenberg) has changed how WordPress sites are built. Instead of shortcodes and page-builder widgets, content is now made of blocks: paragraphs, images, buttons, and any custom block you choose to add. A well-made custom block gives editors exactly the component they need, with the right settings and none of the clutter, and it outputs clean HTML that matches your design every time.
Under the hood, the editor side of a block is a React component. If you already know React, you are most of the way there; the rest is learning how WordPress describes, saves and renders a block. In this guide I build two real blocks step by step: a static “Notice” block and a dynamic “Latest Posts” block. Along the way I cover block.json, the edit and save functions, attributes, sidebar controls, server rendering, the build setup and the mistake that catches everyone at least once: block validation errors.
In this article
- Why build custom blocks?
- The anatomy of a block
- Scaffolding with create-block
- block.json: the block’s metadata
- The edit function: React in the editor
- The save function and how attributes are stored
- Dynamic blocks with render.php
- Static vs dynamic: which to choose
- The build toolchain and registration
- Validation errors and deprecations
Why build custom blocks?
Core blocks and block patterns cover a lot. I reach for a custom block when one of these is true:
- Editors keep rebuilding the same component by hand (a pricing card, a team member, a testimonial) and getting it slightly wrong each time.
- The component needs structured data, such as a rating, a date or a linked product, rather than free-form content.
- The output must stay in sync with data elsewhere: latest posts, upcoming events, product prices, anything that changes without the page being edited.
- You want to drop a heavy page builder and give editors a lean set of on-brand building blocks instead.
Custom blocks usually live in a small plugin, so they keep working when the theme changes. If you have not built a plugin before, my guide on how to build a custom WordPress plugin the right way covers the foundations.
The anatomy of a block
Every block has the same moving parts. A block.json file describes it; an edit component controls how it looks and behaves inside the editor; and either a save function or a render.php file produces the HTML visitors see.
One point that surprises developers coming from React apps: React does not run on the front end by default. The edit component exists only inside the editor. Visitors receive plain HTML, which is great for performance and SEO. If a block needs interactivity on the front end, you add a separate view script (or use the Interactivity API).
Scaffolding with create-block
You can wire up a block by hand, but the official @wordpress/create-block tool generates a working block plugin with a modern build setup in under a minute:
# Scaffold a block plugin (Node.js 20+ recommended)
npx @wordpress/create-block@latest fbm-notice --namespace fbm --title "Notice"
cd fbm-notice
npm start # development build, rebuilds on every save
npm run build # optimized production build
Activate the new plugin in WP Admin and the block appears in the inserter. The important files live in src/: block.json, index.js, edit.js, save.js and two stylesheets: editor.scss (editor only) and style.scss (editor and front end).
block.json: the block’s metadata
block.json is the single source of truth for a block. Both PHP and JavaScript read it, which lets WordPress register the block on the server, load its assets only on pages where it is used, and show it in the block directory. Here is the file for our Notice block:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "fbm/notice",
"version": "1.0.0",
"title": "Notice",
"category": "text",
"icon": "megaphone",
"description": "A highlighted notice with a title and message.",
"attributes": {
"title": {
"type": "string",
"source": "html",
"selector": ".notice-title"
},
"content": {
"type": "string",
"source": "html",
"selector": ".notice-content"
},
"showIcon": {
"type": "boolean",
"default": true
}
},
"supports": {
"html": false,
"color": { "background": true, "text": true },
"spacing": { "padding": true }
},
"textdomain": "fbm-notice",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css"
}
The parts worth understanding:
apiVersion: 3is the current block API. Among other things, it means the editor content can be rendered in an iframe, isolating the block’s styles from the admin’s.nameisnamespace/block-nameand should never change once the block is in use, because it is stored in every post that contains the block.attributesare the block’s data.titleandcontentuse"source": "html", so their values are read back from the saved HTML.showIconhas no source, so it is stored as JSON in the block’s comment delimiter.supportsswitches on built-in features for free: here, colour and padding controls in the sidebar, with the generated classes and styles applied automatically.
The edit function: React in the editor
index.js connects the pieces. It registers the block in JavaScript using the same metadata:
import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
import save from './save';
import metadata from './block.json';
import './style.scss';
registerBlockType( metadata.name, {
edit: Edit,
save,
} );
The edit component receives the block’s attributes and a setAttributes function, which works much like React’s setState. The editable title and message use RichText, and the icon toggle sits in the settings sidebar through InspectorControls:
import { __ } from '@wordpress/i18n';
import { useBlockProps, RichText, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, ToggleControl } from '@wordpress/components';
import './editor.scss';
export default function Edit( { attributes, setAttributes } ) {
const { title, content, showIcon } = attributes;
const blockProps = useBlockProps( {
className: showIcon ? 'has-icon' : undefined,
} );
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Settings', 'fbm-notice' ) }>
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Show icon', 'fbm-notice' ) }
checked={ showIcon }
onChange={ ( value ) => setAttributes( { showIcon: value } ) }
/>
</PanelBody>
</InspectorControls>
<div { ...blockProps }>
<RichText
tagName="p"
className="notice-title"
value={ title }
onChange={ ( value ) => setAttributes( { title: value } ) }
placeholder={ __( 'Notice title…', 'fbm-notice' ) }
/>
<RichText
tagName="p"
className="notice-content"
value={ content }
onChange={ ( value ) => setAttributes( { content: value } ) }
placeholder={ __( 'Write the message…', 'fbm-notice' ) }
/>
</div>
</>
);
}
Two details matter here. useBlockProps() must be spread onto the outermost element: it adds the classes, the support styles and the event handlers the editor needs to select and move the block. And anything placed inside InspectorControls is rendered in the sidebar rather than on the canvas, which keeps the content area clean.
The save function and how attributes are stored
For a static block, save returns the final HTML. It must be a pure function of the attributes: no API calls, no dates, no randomness, because WordPress re-runs it to check that the saved content is still valid.
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save( { attributes } ) {
const { title, content, showIcon } = attributes;
const blockProps = useBlockProps.save( {
className: showIcon ? 'has-icon' : undefined,
} );
return (
<div { ...blockProps }>
<RichText.Content tagName="p" className="notice-title" value={ title } />
<RichText.Content tagName="p" className="notice-content" value={ content } />
</div>
);
}
When the post is saved, the block is serialized into post_content as normal HTML wrapped in special comments. This is the full journey of an attribute, from a click in the editor to the page a visitor sees:
Notice that showIcon appears in the comment only because it differs from its default; default values are not stored. The title and message are not in the JSON at all, because their source tells WordPress to read them from the HTML. Because the stored markup is ordinary HTML, the content still makes sense even if the plugin is ever deactivated.
Dynamic blocks with render.php
A static block freezes its HTML at the moment the post is saved. That is wrong for content that changes on its own, like a list of the latest posts. A dynamic block stores only its attributes and builds the HTML in PHP on every request. create-block has a variant for it:
npx @wordpress/create-block@latest fbm-latest-posts --namespace fbm --variant dynamic
The key difference is the render property in block.json:
{
"apiVersion": 3,
"name": "fbm/latest-posts",
"title": "Latest Posts (FBM)",
"category": "widgets",
"attributes": {
"count": { "type": "number", "default": 3 }
},
"supports": { "html": false },
"editorScript": "file:./index.js",
"render": "file:./render.php"
}
render.php receives $attributes, the inner $content and the $block instance. Use get_block_wrapper_attributes() on the outer element so classes and support styles still apply, and escape everything you print:
<?php
/**
* Front-end output of fbm/latest-posts.
*
* Available variables: $attributes, $content, $block.
*/
$recent_posts = get_posts(
array(
'numberposts' => max( 1, (int) $attributes['count'] ),
'post_status' => 'publish',
)
);
if ( ! $recent_posts ) {
return;
}
?>
<ul <?php echo get_block_wrapper_attributes(); ?>>
<?php foreach ( $recent_posts as $recent ) : ?>
<li>
<a href="<?php echo esc_url( get_permalink( $recent ) ); ?>">
<?php echo esc_html( get_the_title( $recent ) ); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
In the editor, the quickest way to show a live preview is ServerSideRender, which calls the same PHP through the REST API. For a very interactive editing experience you can fetch the data in JavaScript instead, with useSelect and the core data store.
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, RangeControl } from '@wordpress/components';
import ServerSideRender from '@wordpress/server-side-render';
export default function Edit( { attributes, setAttributes } ) {
return (
<div { ...useBlockProps() }>
<InspectorControls>
<PanelBody title={ __( 'Settings', 'fbm-latest-posts' ) }>
<RangeControl
__nextHasNoMarginBottom
label={ __( 'Number of posts', 'fbm-latest-posts' ) }
value={ attributes.count }
onChange={ ( count ) => setAttributes( { count } ) }
min={ 1 }
max={ 10 }
/>
</PanelBody>
</InspectorControls>
{ /* Preview using the same render.php as the front end. */ }
<ServerSideRender block="fbm/latest-posts" attributes={ attributes } />
</div>
);
}
// index.js – no save function: the block stores only its attributes.
// registerBlockType( metadata.name, { edit: Edit } );
Static vs dynamic: which to choose
| Aspect | Static block (save.js) | Dynamic block (render.php) |
|---|---|---|
| HTML is generated | Once, when the post is saved | On every page request |
| Stored in post_content | Full HTML + attributes | Comment delimiter with attributes only |
| Best for | Content the editor writes: notices, cards, CTAs | Data from elsewhere: latest posts, products, user-specific output |
| Changing the markup later | Needs a deprecation to avoid validation errors | Just edit render.php; all posts update instantly |
| Server cost | None (just stored HTML) | PHP runs per request (cache if expensive) |
| Works if plugin is removed | HTML remains in the post | Output disappears |
My rule of thumb: if the editor types the content, make it static; if the content comes from somewhere else, or the markup is likely to change often while the design evolves, make it dynamic. Many teams prefer dynamic blocks for almost everything for that second reason, since it avoids deprecations entirely.
The build toolchain and registration
Browsers cannot run JSX or SCSS directly, so the source goes through a build step. @wordpress/scripts wraps webpack, Babel and Sass with WordPress-specific defaults, so you don’t have to configure any of it yourself:
The PHP side is short. One call per block reads the built block.json and registers everything it describes:
<?php
/**
* Plugin Name: FBM Blocks
* Description: Custom blocks for this site.
* Version: 1.0.0
* Requires at least: 6.5
*/
defined( 'ABSPATH' ) || exit;
add_action( 'init', function () {
// Reads build/notice/block.json and registers scripts, styles and attributes.
register_block_type( __DIR__ . '/build/notice' );
register_block_type( __DIR__ . '/build/latest-posts' );
} );
Recent versions of create-block may instead generate a blocks-manifest.php file and register all blocks at once with wp_register_block_types_from_metadata_collection() (WordPress 6.8+), which is faster when a plugin contains many blocks. Both approaches read the same block.json files. One more thing to remember: the build folder is what ships, so either commit it or build it in your deployment pipeline. The production server does not need Node.js.
Validation errors and deprecations
Sooner or later you will change a static block’s save function, reload a post, and see: “This block contains unexpected or invalid content.” This is block validation at work. When the editor loads a post, it runs your current save() with the stored attributes and compares the result with the HTML in the database. If they differ, it refuses to guess and asks the editor to recover the block.
The fix is a deprecation: you keep the old version of save (and of the attributes, if they changed) so the editor can recognise old content and migrate it to the new format the next time the post is saved. Suppose version 1 of the Notice block used an h4 for the title:
// The old save() output, kept so existing content still validates.
const v1 = {
attributes: metadata.attributes,
save( { attributes } ) {
return (
<div { ...useBlockProps.save() }>
<RichText.Content tagName="h4" className="notice-title" value={ attributes.title } />
<RichText.Content tagName="p" className="notice-content" value={ attributes.content } />
</div>
);
},
};
registerBlockType( metadata.name, {
edit: Edit,
save, // the new markup
deprecated: [ v1 ], // newest first
} );
Good to know
Visitors never see validation errors. The front end simply outputs whatever HTML was saved. The error only appears in the editor, which is why it is easy to miss in testing. Always open a few existing posts in the editor after changing a static block’s markup.
A few other habits that save time: keep blocks small and composable (use InnerBlocks to let editors nest core blocks instead of rebuilding them), lean on supports rather than writing your own colour and spacing controls, and use theme.json presets so your block matches the site’s design tokens. If you are building a decoupled front end instead, my post on connecting WordPress with Next.js using WPGraphQL shows how block content can be consumed outside the theme.
Key takeaways
- A block is
block.jsonplus an edit component (React, editor only) plus eithersave(static HTML) orrender.php(dynamic HTML). - Scaffold with
npx @wordpress/create-block; build with@wordpress/scripts; register withregister_block_type(). - Always spread
useBlockProps()on the wrapper, and usesupportsfor built-in controls. - Use static blocks for editor-written content, dynamic blocks for data that changes on its own.
- Changing a static block’s saved markup needs a deprecation, or editors will see validation errors.
Custom blocks are one of my favourite ways to make WordPress sites easier for the people who run them every day. If you would like a set of blocks designed around your content and brand, or help moving away from a page builder, let’s talk about your project.





