All articles

WordPress development

Custom Gutenberg blocks clients can edit without breaking

By Rolando Escobar, WordPress developer for agencies · · 6 min read

A redesigned homepage can fall apart within weeks. An editor changes a headline, drags a block out of its group, picks a color that isn’t in the brand, and a carefully built section stops matching the design. The opposite mistake, hard-coding the homepage in a PHP template, turns every text change into a developer ticket.

Custom blocks sit between those two. I redesigned the homepages of the Louis Armstrong House Museum and Counterintuity with custom Gutenberg blocks. What follows is the approach as a tutorial: blocks that let editors change words, images and links while the structure stays put.

One block per section, shipped in a plugin

Model the homepage as a stack of sections: a hero, featured items, upcoming events, a call to action. Each section becomes one block. Avoid a single “homepage” block with forty sidebar controls, because it’s hard to edit and even harder to change later.

Put the blocks in a plugin rather than the theme. The block markup lives in the post content, so if a future theme drops your blocks, editors get a notice that the site no longer supports them, right where the homepage used to be. A small plugin keeps the content working through the next redesign.

Describe the block in block.json

block.json is the single source of truth for a block’s name, attributes, supports and assets. WordPress reads it on the server and in the editor, so both sides agree.

{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "rw/home-hero",
	"title": "Home hero",
	"category": "design",
	"description": "Homepage hero with a fixed structure and editable content.",
	"textdomain": "rw-blocks",
	"attributes": {
		"layout": {
			"type": "string",
			"enum": [ "text-left", "text-center" ],
			"default": "text-left"
		}
	},
	"supports": {
		"html": false,
		"align": [ "wide", "full" ],
		"color": { "background": true, "text": true, "link": false },
		"spacing": { "padding": [ "vertical" ], "margin": [ "top", "bottom" ] }
	},
	"editorScript": "file:./index.js",
	"editorStyle": "file:./index.css",
	"style": "file:./style-index.css",
	"render": "file:./render.php"
}

A few lines in that file do most of the work:

  • "apiVersion": 3 declares that the block works in the iframed editor canvas. Version 3 arrived in WordPress 6.3.
  • supports adds color and spacing controls to the sidebar without custom code. The options come from theme.json, so editors pick from the brand palette and spacing scale instead of typing hex codes and pixel values.
  • "html": false removes “Edit as HTML” from the block menu, a common source of broken markup.
  • "style" loads the same stylesheet in the editor and on the front end.
  • "render" points to a PHP template, which makes this a dynamic block.

The block metadata reference lists every field, and the block supports reference lists every control you get for free.

Register every block from the plugin

<?php
/**
 * Plugin Name: RW Homepage Blocks
 * Description: Custom blocks for the homepage sections.
 * Requires at least: 6.5
 * Requires PHP: 8.1
 * Text Domain: rw-blocks
 */

defined( 'ABSPATH' ) || exit;

add_action( 'init', 'rw_blocks_register' );

function rw_blocks_register(): void {
	$build = __DIR__ . '/build';

	// WordPress 6.8+: one call registers every block in the generated manifest.
	if ( function_exists( 'wp_register_block_types_from_metadata_collection' ) ) {
		wp_register_block_types_from_metadata_collection( $build, $build . '/blocks-manifest.php' );
		return;
	}

	// Older versions: register each folder that contains a block.json file.
	foreach ( glob( $build . '/*/block.json' ) ?: [] as $metadata ) {
		register_block_type( dirname( $metadata ) );
	}
}

WordPress 6.8 added wp_register_block_types_from_metadata_collection(), which registers every block listed in a PHP manifest that wp-scripts build --blocks-manifest generates. It spares WordPress from reading and parsing each block.json file on every request. The loop keeps sites on 6.5 to 6.7 working.

Static or dynamic rendering

A static block saves its final HTML into the post content from a JavaScript save() function. A dynamic block saves only its attributes and inner content, and PHP builds the HTML on each request.

Static (save function) Dynamic (render.php)
Where the markup lives In every post that uses the block In your plugin
Changing the markup later Existing blocks show a validation error until you add a deprecation Every instance updates on the next page load
Live data (latest events, a query) Frozen at save time Read on every render
Editor preview Same JavaScript on both sides You mirror the markup in JSX

For homepage sections, a hybrid works well: the wrapper is dynamic, and the text inside it is made of core blocks saved as inner content. Editors get the paragraph, heading and button blocks they already know, and you can change the wrapper’s markup and classes later without invalidating a single saved post.

Lock the structure, free the content

InnerBlocks takes a template, the blocks it starts with, and a templateLock, which controls what editors can do with them.

// edit.js
import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
import { PanelBody, SelectControl } from '@wordpress/components';

const TEMPLATE = [
	[ 'core/heading', { level: 1, placeholder: __( 'Main headline', 'rw-blocks' ) } ],
	[ 'core/paragraph', { placeholder: __( 'One or two sentences that support the headline.', 'rw-blocks' ) } ],
	[ 'core/buttons', {}, [ [ 'core/button', { placeholder: __( 'Button text', 'rw-blocks' ) } ] ] ],
];

export default function Edit( { attributes, setAttributes } ) {
	const blockProps = useBlockProps( { className: `rw-hero rw-hero--${ attributes.layout }` } );
	const innerBlocksProps = useInnerBlocksProps(
		{ className: 'rw-hero__content' },
		{ template: TEMPLATE, templateLock: 'all' } // Content editable, structure fixed.
	);

	return (
		<>
			<InspectorControls>
				<PanelBody title={ __( 'Layout', 'rw-blocks' ) }>
					<SelectControl
						label={ __( 'Text position', 'rw-blocks' ) }
						value={ attributes.layout }
						options={ [
							{ label: __( 'Left', 'rw-blocks' ), value: 'text-left' },
							{ label: __( 'Centered', 'rw-blocks' ), value: 'text-center' },
						] }
						onChange={ ( layout ) => setAttributes( { layout } ) }
						__next40pxDefaultSize
						__nextHasNoMarginBottom
					/>
				</PanelBody>
			</InspectorControls>
			<section { ...blockProps }>
				<div { ...innerBlocksProps } />
			</section>
		</>
	);
}

// index.js
import { registerBlockType } from '@wordpress/blocks';
import { InnerBlocks } from '@wordpress/block-editor';
import metadata from './block.json';
import Edit from './edit';
import './style.scss';
import './editor.scss';

registerBlockType( metadata.name, {
	edit: Edit,
	save: () => <InnerBlocks.Content />, // Store the inner blocks; render.php builds the wrapper.
} );

The lock values behave like this, according to the InnerBlocks documentation:

  • 'all' prevents inserting, moving and removing blocks. Text, links and images stay editable, which makes it the right default for a hero.
  • 'insert' prevents inserting and removing, but allows reordering.
  • 'contentOnly' is the strictest. Editors can only change content, blocks without content are hidden from the list view, and nested areas can’t override it. The editing experience around it has kept changing between releases, so test it in your WordPress version.
  • false lifts a parent’s lock for a nested area. Use it for a row of cards where editors must add items, together with allowedBlocks to limit what they can add.

The only custom control is a layout choice the design actually needs. Color, padding and margin come from supports, so there are no custom controls for them to write or maintain. The template also starts the hero with an h1, so the homepage gets its main heading in the right place from the first insert.

Render on the server

Because the text lives in inner blocks, the save function returns only <InnerBlocks.Content />, which stores those blocks in the post. The PHP template wraps them:

<?php
/**
 * Front-end markup for rw/home-hero.
 *
 * @var array    $attributes Block attributes.
 * @var string   $content    Inner blocks, already rendered by core.
 * @var WP_Block $block      Block instance.
 */

defined( 'ABSPATH' ) || exit;

// Attributes live in post content, so validate them before use.
$layout = in_array( $attributes['layout'] ?? '', [ 'text-left', 'text-center' ], true )
	? $attributes['layout']
	: 'text-left';

$wrapper = get_block_wrapper_attributes( [ 'class' => 'rw-hero rw-hero--' . $layout ] );
?>
<section <?php echo $wrapper; // Escaped by core. ?>>
	<div class="rw-hero__content">
		<?php echo $content; // Rendered inner blocks. ?>
	</div>
</section>

get_block_wrapper_attributes() prints the classes and inline styles produced by block supports and merges in your own classes. Skip it and the color and spacing controls appear to do nothing on the front end. $content holds the inner blocks, already rendered by core. The layout value is checked against an allowlist before it becomes a class name, because attributes are stored in the post content and anyone who can edit the post can change them.

Give editors a starting point with patterns

A block pattern is a saved arrangement of blocks with sample content. Themes register patterns automatically from PHP files in a /patterns folder, and plugins use register_block_pattern(). A pattern for each section, or for the whole homepage, lets an editor rebuild a page or start a campaign landing page in one click.

<?php
/**
 * Title: Homepage hero
 * Slug: rw-theme/home-hero
 * Categories: featured
 * Block Types: core/post-content
 * Post Types: page
 * Description: Hero with a headline, a short intro and one button.
 */
?>
<!-- wp:rw/home-hero {"layout":"text-center","align":"full"} -->
<!-- wp:heading {"level":1} -->
<h1 class="wp-block-heading"><?php esc_html_e( 'Your main headline', 'rw-theme' ); ?></h1>
<!-- /wp:heading -->

<!-- wp:paragraph -->
<p><?php esc_html_e( 'One or two sentences that support the headline.', 'rw-theme' ); ?></p>
<!-- /wp:paragraph -->

<!-- wp:buttons -->
<div class="wp-block-buttons"><!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#"><?php esc_html_e( 'Primary action', 'rw-theme' ); ?></a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons -->
<!-- /wp:rw/home-hero -->

The Block Types: core/post-content header offers the pattern in the dialog that opens when someone creates a new page. Patterns insert a copy, so later edits to the pattern file don’t change pages already built from it. Use synced patterns only for content that must stay identical everywhere, such as a shared call to action.

Keep the editor and the front end in sync

  • Load one stylesheet in both places through the style field. Put editor-only rules, such as placeholder hints, in editorStyle.
  • Make the JSX in Edit output the same elements and classes as render.php. In the example, both sides produce a section with the rw-hero classes and an inner rw-hero__content wrapper.
  • Limit choices in theme.json: set color.custom and typography.customFontSize to false and define spacing presets, so every option in the sidebar is one the design allows.
  • Test with real content: a headline that wraps to three lines, a missing image, a translated button label twice as long as the English one.
  • Avoid ServerSideRender for sections with inner blocks. It can’t show editable inner blocks, and it sends a request on every change.

What to watch out for

  • Never rename a block after launch. Its name is stored in every post that uses it.
  • If you change a static block’s save() output, add a deprecated entry, or editors will see validation errors on existing content.
  • Keep HTML out of string attributes. Formatted text belongs in inner blocks or in rich text sourced from the saved markup.
  • Check accessibility inside each template: one h1 per page, alt text on images and button labels that make sense out of context.
  • Give editors a one-page guide with a screenshot of each block. Locking prevents accidents; a guide prevents confusion.

This is the kind of work I take on for clients: Figma to WordPress