Building Custom Gutenberg Blocks with React

Building Custom Gutenberg Blocks with React


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.

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.

Block anatomy: block.json describes the block. In the editor, edit.js renders a React component. On the front end, either save.js stores static HTML in the post, or render.php generates HTML on every request.block.jsonname, attributes, supports, assetsIN THE EDITORON THE FRONT ENDedit.jsReact component shown in the editor:RichText, sidebar controls, previews.Changes values with setAttributes().save.js static blockHTML saved into post_contentORrender.php dynamic blockHTML generated on every requestBlock editor canvaswhat the editor sees and editsVisitor's browserplain HTML + the block's CSS
Figure 1: The parts of a block. React runs in the editor; visitors only receive HTML and CSS.

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: 3 is 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.
  • name is namespace/block-name and should never change once the block is in use, because it is stored in every post that contains the block.
  • attributes are the block’s data. title and content use "source": "html", so their values are read back from the saved HTML. showIcon has no source, so it is stored as JSON in the block’s comment delimiter.
  • supports switches 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:

Attribute data flow: the editor UI calls setAttributes, the block is serialized into post_content as comment delimiters plus HTML, and on the front end the saved HTML is output or render.php receives the attributes.1. Editor UIUser types inRichText or flipsa ToggleControl2. setAttributesBlock state updates;edit() re-rendersinstantly3. SerializeOn save, save()output + attributesgo into post_content4. Front endSaved HTML is sentas-is, or render.phpgets $attributesWHAT IS STORED IN POST_CONTENT<!-- wp:fbm/notice {"showIcon":false} --><div class="wp-block-fbm-notice"><p class="notice-title">Heads up</p><p class="notice-content">The office is closed on Friday.</p></div><!-- /wp:fbm/notice -->
Figure 2: How attributes travel. Comment-only attributes live in the JSON on the first line; “html”-sourced ones are parsed back out of the markup.

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

AspectStatic block (save.js)Dynamic block (render.php)
HTML is generatedOnce, when the post is savedOn every page request
Stored in post_contentFull HTML + attributesComment delimiter with attributes only
Best forContent the editor writes: notices, cards, CTAsData from elsewhere: latest posts, products, user-specific output
Changing the markup laterNeeds a deprecation to avoid validation errorsJust edit render.php; all posts update instantly
Server costNone (just stored HTML)PHP runs per request (cache if expensive)
Works if plugin is removedHTML remains in the postOutput disappears
Static vs dynamic blocks.

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:

Build toolchain: source files in src are compiled by @wordpress/scripts into the build folder, which PHP registers with register_block_type, and WordPress then loads the assets wherever the block is used.src/what you writeblock.jsonindex.jsedit.js (JSX)save.js (JSX)editor.scssstyle.scss@wordpress/scriptswebpack + BabelSass, minificationdependency listnpm startnpm run buildbuild/what WordPress loadsblock.jsonindex.jsindex.asset.phpindex.cssstyle-index.cssrender.phpPHP pluginregister_block_type( build/notice )Registers the blockand loads its CSS/JSonly where it's used
Figure 3: From source to WordPress. index.asset.php lists the script’s WordPress dependencies and a version hash for cache busting.

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.json plus an edit component (React, editor only) plus either save (static HTML) or render.php (dynamic HTML).
  • Scaffold with npx @wordpress/create-block; build with @wordpress/scripts; register with register_block_type().
  • Always spread useBlockProps() on the wrapper, and use supports for 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.

Code examples in this post are free to use under the MIT licence. Article text © 2026 Farooq Bin Munir.