A custom Gutenberg block is a plugin with a build step. This covers scaffolding one with @wordpress/create-block, what each generated file is for, how the plugin registers it, sidebar controls, nesting with InnerBlocks, and the four APIs that save us from writing a second block: styles, variations, context and patterns.
Why JSX
JSX is JavaScript with markup written inline. React DOM escapes every value embedded in JSX before rendering it, so user input dropped into <h1>{ title }</h1> cannot inject a tag. That is one whole class of XSS closed by the tooling rather than by memory, until someone reaches for dangerouslySetInnerHTML.
@wordpress/scripts runs webpack and Babel over it, turning the JSX into JavaScript a browser accepts.
Scaffolding a block
create-block writes the plugin as well as the block, so run it in the plugins directory:
npx @wordpress/create-block@latest testblockIt needs Node.js 22.13 or newer. To add a block to a plugin that already exists, run it inside that plugin with --no-plugin, which writes only the block files. --variant dynamic scaffolds a block rendered in PHP instead.
The block’s files land in src/testblock/, not straight in src, which is the first change from the 2022 notes:
block.json-> name, attributes, icon and the rest of the metadataedit.js-> how the block behaves in the editorsave.js-> the markup saved into post content, which the front end shows. A dynamic block hasrender.phpinsteadindex.js-> importseditandsaveand registers the blockeditor.scssandstyle.scss-> editor-only and shared stylesview.js-> front-end script
None of that is production code yet. It is source:
npm run buildThat fills build/, which holds the files WordPress actually loads, plus a blocks-manifest.php.
Registering it from PHP
The second change. The 2022 notes called register_block_type() once per build directory. The current scaffold registers every block in build/ in one call:
function create_block_testblock_block_init() {
wp_register_block_types_from_metadata_collection( __DIR__ . '/build', __DIR__ . '/build/blocks-manifest.php' );
}
add_action( 'init', 'create_block_testblock_block_init' );That function arrived in WordPress 6.8, so the scaffold sets “Requires at least” to 6.8. The manifest is generated by wp-scripts build --blocks-manifest, which saves WordPress reading and parsing each block.json from disk separately. register_block_type( __DIR__ . '/build/testblock' ) still works for one block.
When a block refuses to appear, ask the registry: WP_Block_Type_Registry::get_instance()->is_registered( 'create-block/testblock' ), or get_all_registered() for the full list.
The scaffold also sets apiVersion 3 in block.json, which declares that the block works inside an iframe. WordPress 7.1 always loads the post editor in an iframe, whatever the apiVersion, so that is no longer optional.
Controls in the sidebar
InspectorControls puts fields in the editor sidebar, and @wordpress/components supplies the fields:
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
export default function Edit( { attributes, setAttributes } ) {
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Test settings', 'movie-library' ) }>
<TextControl
label={ __( 'Setting 1', 'movie-library' ) }
value={ attributes.setting }
onChange={ ( setting ) => setAttributes( { setting } ) }
/>
</PanelBody>
</InspectorControls>
<p { ...useBlockProps() }>{ __( 'Hello from the editor', 'movie-library' ) }</p>
</>
);
}onChange writes through setAttributes, and the attribute has to exist in block.json before it will hold a value:
"attributes": {
"setting": { "type": "string", "default": "" }
}Every label goes through __(), which only works if the text domain is set up correctly.
Nesting with InnerBlocks
InnerBlocks is what lets a block contain blocks, the way group and columns do. useInnerBlocksProps is the hook form of it and gives us control over the wrapper markup:
import { useBlockProps, useInnerBlocksProps, InnerBlocks } from '@wordpress/block-editor';
const ALLOWED_BLOCKS = [ 'core/image', 'core/paragraph' ];
const TEMPLATE = [ [ 'core/image' ], [ 'core/paragraph', { placeholder: 'Synopsis' } ] ];
export function Edit() {
const innerBlocksProps = useInnerBlocksProps( useBlockProps(), {
allowedBlocks: ALLOWED_BLOCKS,
template: TEMPLATE,
} );
return <div { ...innerBlocksProps } />;
}
export function save() {
return (
<div { ...useBlockProps.save() }>
<InnerBlocks.Content />
</div>
);
}The options that do the work:
allowedBlocks-> what the inserter offers inside our block. Since 6.5 it can also go inblock.jsonorientation->horizontalorvertical. It does not lay anything out. It sets which way the mover arrows point and how drag and drop behavestemplate-> the default content a fresh block starts withtemplateLock-> four values, not three.allblocks every operation.insertallows moving but not adding or removing.contentOnlyblocks everything, hides blocks with no content from the list view, and is the one value a child cannot override.falseopts out of a lock a parent imposed
parent and ancestor
Both go in block.json and both restrict where a block can be used:
"parent": [ "core/columns" ],
"ancestor": [ "core/columns" ]parent requires that block to be the direct parent. ancestor, since 6.0, only requires it somewhere above in the tree. Reach for ancestor unless the block really must sit one level down, because parent is the one that makes a block vanish from the inserter.
Block styles
To restyle a core block without touching core, register a style:
wp.blocks.registerBlockStyle( 'core/quote', {
name: 'fancy-quote',
label: 'Fancy Quote',
} );Selecting it adds the class is-style-fancy-quote and nothing else. The class is the whole feature. We still write the CSS.
Block variations
When two blocks differ only by attributes, they are one block with variations. The embed block covers every provider this way:
variations: [
{
name: 'test1',
isDefault: true,
title: __( 'Test 1', 'movie-library' ),
attributes: { providerNameSlug: 'wordpress' },
},
{
name: 'test2',
title: __( 'Test 2', 'movie-library' ),
attributes: { providerNameSlug: 'google' },
keywords: [ __( 'search', 'movie-library' ) ],
},
],The 2022 notes counted three inserter entries here. It is two: isDefault makes Test 1 replace the plain block in the inserter. One block in the markup, and only the attribute value differs. Variations can also live in block.json, and since 6.7 that field can point at a PHP file that builds the list on the server.
Block context
Context is how a parent hands data down without either block knowing much about the other. Parent block.json:
"attributes": { "movie": { "type": "number", "default": 0 } },
"providesContext": { "movie-library/movie": "movie" }Child block.json:
"usesContext": [ "movie-library/movie" ]In the child’s edit it arrives as the context prop, context[ 'movie-library/movie' ], and in render.php as $block->context['movie-library/movie']. Keep the plugin namespace in the key, so it cannot collide with context WordPress provides itself.
Block patterns
A pattern is prewritten block markup offered in the inserter. Registered in PHP on init, no build step:
register_block_pattern(
'movie-library/test-pattern',
array(
'title' => __( 'Test pattern', 'movie-library' ),
'description' => __( 'Two paragraphs', 'movie-library' ),
'categories' => array( 'text' ),
'content' => '<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph -->',
)
);content is literal block markup, comment delimiters included. Since 6.5, filePath can point at a file instead, loaded only when needed. A theme does not need the call at all: PHP files with a pattern header in its patterns/ directory register themselves.
What this does not cover
Dynamic blocks in depth, block supports, and deprecations, which is the API we need the moment a saved block’s markup changes after people have used it.

Leave a comment