Build Container Blocks with Gutenberg InnerBlocks: Template, allowedBlocks, and templateLock

Gutenberg blocks are normally self-contained — each block renders a fixed piece of content with its own attributes. InnerBlocks is the component that breaks this constraint, allowing a custom block to act as a container for other blocks. It is the mechanism behind the Group block, Columns, Cover, and every other layout block in core. When you add InnerBlocks to your custom block’s edit component, the block editor renders a full inserter inside the block’s editing area and the contents become nested blocks in the serialised post content. The save function uses InnerBlocks.Content to output all nested blocks without manually iterating them. Three key props control the editor experience: allowedBlocks restricts which block types can be inserted, template pre-populates the inner area with a specific arrangement of blocks, and templateLock controls whether editors can add, remove, or reorder blocks inside the template.

Problem: You want a custom "Feature Card" block with a fixed layout — an image, a heading, and a paragraph side by side — where editors can change the content but not rearrange or add other blocks inside the card.

Solution: Add InnerBlocks with a template defining the three blocks and templateLock="all" to prevent structural changes. Use allowedBlocks to restrict insertion to only the block types used in the template.

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

const ALLOWED_BLOCKS = [ 'core/image', 'core/heading', 'core/paragraph' ];

const TEMPLATE = [
    [ 'core/image', { className: 'feature-card__image' } ],
    [ 'core/heading', { level: 3, placeholder: 'Card Title' } ],
    [ 'core/paragraph', { placeholder: 'Card description…' } ],
];

registerBlockType( 'my-plugin/feature-card', {
    title: 'Feature Card',
    category: 'layout',
    edit: () => {
        const blockProps = useBlockProps( { className: 'feature-card' } );
        return (
            <div { ...blockProps }>
                <InnerBlocks
                    allowedBlocks={ ALLOWED_BLOCKS }
                    template={ TEMPLATE }
                    templateLock="all"   // 'all' | 'insert' | false
                    // 'all'    = no add/remove/move
                    // 'insert' = can reorder but not add/remove
                    // false    = fully flexible (default)
                />
            </div>
        );
    },
    save: () => {
        const blockProps = useBlockProps.save( { className: 'feature-card' } );
        return (
            <div { ...blockProps }>
                <InnerBlocks.Content />
            </div>
        );
    },
} );

PHP registration (no render_callback needed for static save output):

<?php
add_action( 'init', function () {
    register_block_type( 'my-plugin/feature-card', [
        'editor_script' => 'my-plugin-blocks',
        'style'         => 'my-plugin-style',
    ] );
} );

NOTE: When you use InnerBlocks in a static save function, the nested block content is serialised as HTML comments inside the parent block's serialised markup in the database. If you later change the parent block's save output in a way that changes the wrapper HTML, WordPress will flag a "Block validation failed" error for previously saved posts. Plan your wrapper HTML carefully, or use a render_callback (server-side rendering) to avoid serialisation validation issues entirely.