Computer on desk with clean website design on screen

Create a Great WordPress Editing Experience and Save Time with Gutenberg InnerBlocks

Don’t Repeat Yourself

For developers, laziness can be a virtue. A well-made, reusable component saves time and allows for efficient reuse and consistency. This lends itself well to creating editing experiences in WordPress, as consistency breeds familiarity and knowledge for those using a website on a day-to-day basis.

WordPress made a lot of noise with the release of the Gutenberg block editor, now known simply as “The Editor.” For content editing, drafting, and publishing, it provides an excellent experience, far better than older WYSIWYGs. As a developer, I want to provide the best editing experience for my clients, I want to leverage core functionality, and I don’t want to reinvent the wheel. Luckily, WordPress lets us do all of those things with custom blocks. 

Enter InnerBlocks

InnerBlocks is a great feature in the WordPress editor. Developers can use core blocks like the paragraph, headings, and buttons to create a consistent experience for a client. Instead of rewriting text sections and redeclaring fields, the client gets an experience that they become familiar with; editing and combining the same blocks. Let’s take a look at a block we might need to build for a client, and see how InnerBlocks can help us achieve it. 

Building a Block with InnerBlocks

An example Call to Action Block with headline, paragraph, and button links
Figure: an example Call to Action Block. This includes a headline, paragraph, and some button links.

Let’s examine this block and consider how we might build it. A common tool we reach for when making blocks is Advanced Custom Fields (ACF). ACF Pro adds custom block support, which lets developers write blocks in PHP for the editor and the front end. It also lets us use custom fields—a paradigm many WordPress developers are familiar with. ACF supports InnerBlocks, meaning we can create a custom block, but won’t even need to write custom code for that heading, paragraph, or buttons. 

With ACF Blocks, we can leverage core blocks to make a great editing experience for all the text we see in this block. Another potential option would be to use block patterns, but as developers and curators of a fantastic WordPress experience, creating a custom block achieves the desired goal. 

Block Registration Code

Here’s the code to register our custom ACF block. Read more about using the acf_register_block_type function here

<?php
/**
 * Template for registering an ACF powered custom block.
 *
 * More info: https://www.advancedcustomfields.com/resources/blocks/
 */

/**
 * Register the block.
 */
add_action(
	'acf/init',
	function() {
		/**
		 * ACF block registration options here: https://www.advancedcustomfields.com/resources/acf_register_block_type/
		 */
		acf_register_block_type(
			array(
				'name'            => 'call-to-action-demo', // JS will register as: acf/{block-name}.
				'title'           => __( 'Call to Action ACF Demo', 'locale' ),
				'description'     => __( 'A custom ACF Call to Action block.', 'locale' ),
				'render_template' => 'partials/blocks/call-to-action-demo.php', // Change to block template.
				'category'        => 'design', // Category in the block inserter.
				'keywords'        => array( 'action', 'buttons', 'cta' ), // Searchable keywords.
				'supports'        => array(
					'align'  => false, // Disable support for align.
					'anchor' => true, // Enable support for anchor.
					'jsx'    => true, // Enable support for JSX.
					'mode'   => false, // Disable ACF block edit/preview mode switching as we are only using InnerBlocks for editable content.
				),
			)
		);
	}
);

Let’s break the code down. We’re using the acf_register_block_type function to register our block, and we’re passing an array of arguments that define our block and turn functionality on and off.

  • Notice we’re following the recommended procedure to add a unique name for our block, as well as a human-readable title, and description. We point to a render template, where we’ll set up our PHP template for the block later. We’ve also added a category where we want our block to be grouped in the Block Inserter.
  • We’ve got searchable keywords to make the block easier to search for in the inserter.
  • We add a ‘supports’ array to enable functionality like setting an HTML anchor and we’ve turned off alignment as this block will always sit in the center of the page. 
  • That supports array is where we turn on the InnerBlocks magic! Setting ‘jsx’ to true tells ACF that we will support rendering React jsx templating inside our block.

Let’s review the content we need in our block:

  • Heading
  • Paragraph text
  • Buttons to take action!

Now, normally we would assign fields to our block, as outlined here. But in this case, we don’t need to—we can leverage core blocks to achieve all of this. If the block needs an image background, for example, adding an ACF image field to the block could be a great way to achieve that. For now, though, let’s stick to our InnerBlocks example and jump to the template. 

Block Template

Here’s the template for the new block. Make sure to point the block registration to the block template location. A common practice is putting these in a partials folder in the theme. 

<?php
/**
 * ACF Call to Action example block template.
 *
 * More info: https://www.advancedcustomfields.com/resources/acf_register_block_type/
 *
 * @param   array $block The block settings and attributes.
 * @param   string $content The block inner HTML (empty).
 * @param   bool $is_preview True during AJAX preview.
 * @param   (int|string) $post_id The post ID this block is saved to.
 *
 */

// Create id attribute allowing for custom "anchor" value.
$block_id = 'acf-call-to-action-block-demo-' . $block['id'];
if ( ! empty( $block['anchor'] ) ) {
	$block_id = $block['anchor'];
}

// Create class attribute allowing for custom "className" and "align" values.
$class_name = 'acf-call-to-action-demo';
if ( ! empty( $block['className'] ) ) {
	$class_name .= ' ' . $block['className'];
}
if ( ! empty( $block['align'] ) ) {
	$class_name .= ' align' . $block['align'];
}

?>


<div id="<?php echo esc_attr( $block_id ); ?>" class="<?php echo esc_html( $class_name ); ?>">

	<div class="cta__inner">
			<?php

			// Set up innerBlocks and provide a template.

			// Restrict InnerBlocks to allowed block list.
			$allowed_blocks = array( 'core/heading', 'core/paragraph', 'core/buttons' );

			// Start InnerBlocks with a template.
			$template = array(
				array(
					'core/heading',
					array(
						'placeholder' => __( 'CTA Heading', 'locale' ),
						'align'       => 'center',
						'level'       => '2',
					),
				),
				array(
					'core/paragraph',
					array(
						'placeholder' => __( 'Add CTA text here', 'locale' ),
						'align'       => 'center',
					),
				),
				array(
					'core/buttons',
					array(
						'placeholder' => __( 'Add CTA buttons here', 'locale' ),
						'align'       => 'center',
					),
					array(
						array(
							'core/button',
							array(
								'text' => __( 'Take action', 'locale' ),
							),
						),
						array(
							'core/button',
							array(
								'text' => __( 'Learn more', 'locale' ),
							),
						),
					),
				),
			);
			// Echo out our JSX InnerBlocks compoennt for the editor.
			echo '<InnerBlocks allowedBlocks="' . esc_attr( wp_json_encode( $allowed_blocks ) ) . '" template="' . esc_attr( wp_json_encode( $template ) ) . '" templateLock="false" />';
			?>
	</div> 

</div>

Let’s break this code down.

  • At the start, we have a generic block template boilerplate that ACF provides in their block guidance, such as support for an anchor and custom class name.
  • The InnerBlocks component can receive properties. We are using three in this template.
    • Allowed Blocks: We add an array of allowed blocks to curate. Only these blocks will be available for selection inside our custom block’s InnerBlocks space.
    • Template: We can pass an array of blocks and we can pass block attributes for the blocks to start with when they first load into the editor.
      • Notice that we can pass block attributes to set our users up for success. The heading has level 2 set already, and the paragraph is centered.
      • Note: Core blocks will always be referred to in JavaScript as {plugin}/blockname. In our case, we’re using core blocks, but if you wanted to use a custom ACF block, you’d write ‘acf’ in front of the block name. Remember, when using InnerBlocks, we are passing this component to the WordPress Editor, which uses React. That’s why we’re thinking in JS here. 
      • Another note: notice that the core/buttons block uses InnerBlocks itself! We’re actually passing an array of two core/button blocks within! 
    • Template Locking: templateLock is set on the InnerBlocks component. If we set it to ‘all’, none of the blocks in our template provided to InnerBlocks could be moved or removed. If we set it to ‘insert’, the blocks within could only be moved around, no blocks could be removed, and no new blocks could be added. If we set it to ‘false’, the InnerBlocks instance will be unlocked and it will be unlocked regardless of any parent template locks (more on this further down). Read more about Block Templates in the WordPress Block Editor Handbook. We’ll talk about block locking more.

We have a new block! After all that, and some styling, of course, the example block  looks like this in the editor:

The Call to Action Block with the heading block selected.
Figure: The Call to Action Block in the editor. The heading block is selected.

Nesting Deeper: Curating the Experience Even More with Nested InnerBlocks

We’ve got a great block, and we didn’t have to set up a WYSIWYG or even handle custom fields in our template. But what if we wanted to curate and fine-tune this experience even further for the client? Let’s say we get a request that we ensure the heading is always present, so there’s always a heading block, but the content after is flexible and could even include a list or another type of block. Our goal is to achieve that flexibility, while also preserving consistency.

Let’s consider some rules when working with InnerBlocks:

  • A single block may only have one instance of InnerBlocks within.
  • Multiple blocks inside InnerBlocks may use their own InnerBlocks components.
  • InnerBlocks may be locked via the templateLock property, but instances of InnerBlocks deeper within a nested block structure may be unlocked again by setting templateLock to false.

One solution is to create a new block that serves as a wrapper for InnerBlocks. We’d include that block in our parent block template and lock it, but explicitly unlock our wrapper block within by setting ‘templateLock’ to false. We also may want to make sure this special wrapper block is only available within the parent block we choose, by setting a parent for our block. We could allow multiple block types within that space to offer editors lists and more, while still only allowing the heading, buttons, and our wrapper block in the parent Call to Action block.

Individual Block Locking

A new feature in WordPress 5.9 is the ability to lock individual blocks in a template. This is another possible solution to our flexible-yet-consistent problem. 

Here’s what our template looks like with some individual blocks locked:

<?php
/**
 * ACF Call to Action example block template.
 *
 * More info: https://www.advancedcustomfields.com/resources/acf_register_block_type/
 *
 * @param   array $block The block settings and attributes.
 * @param   string $content The block inner HTML (empty).
 * @param   bool $is_preview True during AJAX preview.
 * @param   (int|string) $post_id The post ID this block is saved to.
 *
 */

// Create id attribute allowing for custom "anchor" value.
$block_id = 'acf-call-to-action-block-demo-' . $block['id'];
if ( ! empty( $block['anchor'] ) ) {
	$block_id = $block['anchor'];
}

// Create class attribute allowing for custom "className" and "align" values.
$class_name = 'acf-call-to-action-demo';
if ( ! empty( $block['className'] ) ) {
	$class_name .= ' ' . $block['className'];
}
if ( ! empty( $block['align'] ) ) {
	$class_name .= ' align' . $block['align'];
}

?>


<div id="<?php echo esc_attr( $block_id ); ?>" class="<?php echo esc_html( $class_name ); ?>">

	<div class="cta__inner">
			<?php

			// Set up innerBlocks and provide a template.

			// Restrict InnerBlocks to allowed block list.
			$allowed_blocks = array( 'core/heading', 'core/paragraph', 'core/buttons' );

			// Start InnerBlocks with a template.
			$template = array(
				array(
					'core/heading',
					array(
						'placeholder' => __( 'Heading', 'locale' ),
						'align'       => 'center',
						'level'       => '2',
						'lock'        => array(
							'move'   => true, // Block may nto be moved.
							'remove' => true, // Block may not be removed.
						),
					),
				),
				array(
					'core/paragraph',
					array(
						'placeholder' => __( 'Add CTA text here', 'locale' ),
						'align'       => 'center',
					),
				),
				array(
					'core/buttons',
					array(
						'placeholder' => __( 'Add CTA buttons here', 'locale' ),
						'align'       => 'center',
						'lock'        => array(
							'move'   => true, // Block may not be moved.
							'remove' => true, // Block may not be removed.
						),
					),
					array(
						array(
							'core/button',
							array(
								'text' => __( 'Take action', 'locale' ),
							),
						),
						array(
							'core/button',
							array(
								'text' => __( 'Learn more', 'locale' ),
							),
						),
					),
				),
			);
			// Echo out our JSX InnerBlocks compoennt for the editor.
			echo '<InnerBlocks allowedBlocks="' . esc_attr( wp_json_encode( $allowed_blocks ) ) . '" template="' . esc_attr( wp_json_encode( $template ) ) . '" />';
			?>
	</div> 

</div>

The heading and the buttons blocks should no longer be able to be moved or removed. Be sure to refresh the editor, remove, and then add the block again to get the templating changes. 

Bonus: Making the Same Block Natively

Once you have a build process in place, making native WordPress blocks with React is surprisingly easy and not too terribly different from templating out a block in php. Setting up a block development environment is outside the scope of this article, but there are a number of resources to get you started, such as the WordPress Block Editor Handbook. Once you’re able to include custom blocks, you can quickly build blocks using InnerBlocks.

Here is an example of our Call to Action block index.js in React. You’ll see the same strategy we discussed above with ACF applied here.

import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { InnerBlocks } from '@wordpress/block-editor';
import { useBlockProps } from '@wordpress/block-editor';

/**
 * Block Name.
 * Create an example Call to Action Block
 * Uses InnerBlocks for editable content within.
 */
export const blockName = 'call-to-action';

/**
 * Block Config.
 * Set basic params for controlling the editor.
 */
export const BLOCK_CONFIG = {
	// Set up the block template.
	CTA_TEMPLATE: [
		[
			'core/heading',
			{
				placeholder: __('CTA Headline', 'locale'),
				align: 'center',
				level: 2,
				lock: {
					move: true,
					remove: true,
				},
			},
		],
		[
			'core/paragraph',
			{
				placeholder: 'Optional CTA text',
				align: 'center',
				lock: {
					move: true,
				},
			},
		],
		[
			'core/buttons',
			{
				lock: {
					move: true,
					remove: true,
				},
				className: 'is-content-justification-center',
				align: 'center',
				// __experimentalLayout - https://github.com/WordPress/gutenberg/blob/trunk/packages/block-library/src/buttons/block.json
				layout: {
					type: 'flex',
					justifyContent: 'center',
				},
			},
			[
				[
					'core/button',
					{
						text: 'Apply now',
						lock: {
							move: true,
							remove: true,
						},
					},
				],
				['core/button', { text: 'Learn more' }],
			],
		],
	],
	// Set up the allowed blocks.
	ALLOWED_BLOCKS: ['core/paragraph', 'core/heading', 'core/buttons'],
};

// Register the block via WP func. Change 'myplugin' to your plugin or theme.
registerBlockType(`myplugin/${blockName}`, {
	title: __('Call to Action', 'locale'), // Change 'locale' to your locale for internationalization.
	description: __(
		'Call to action block with headline and buttons',
		'locale'
	),
	keywords: [__('call'), __('action'), __('cta')],
	category: 'design',
	supports: {
		anchor: true,
		defaultStylePicker: false,
		html: false,
		align: false,
	},
	attributes: {
		anchor: {
			type: 'string',
			default: '',
		},
	},
	transforms: {},
	variations: [],
	edit: (props) => {
		const blockProps = useBlockProps({
			className: `wp-block-myplugin-${blockName}`,
		});

		return (
			<div {...blockProps}>
				<div className="cta__inner">
					<div className="cta__inner-blocks-wrapper">
						<InnerBlocks
							template={BLOCK_CONFIG.CTA_TEMPLATE}
							allowedBlocks={BLOCK_CONFIG.ALLOWED_BLOCKS}
							renderAppender={false}
						/>
					</div>
				</div>
			</div>
		);
	},
	save: () => {
		return (
			<div>
				<div className="cta__inner">
					<InnerBlocks.Content />
				</div>
			</div>
		);
	},
});

Go forth and build! And to ensure you’re getting the best WordPress hosting experience, make sure to check out WP Engine’s managed hosting solutions.

Additional Resources

Get started

Build faster, protect your brand, and grow your business with a WordPress platform built to power remarkable online experiences.