How to Build a Custom Gutenberg Block Without Losing Your Mind
Build a custom Gutenberg block that survives updates: block.json, dynamic rendering, deprecations, inner blocks and the Interactivity API, explained properly.
You ship a client site, everyone’s happy, and three months later the content editor opens a page and gets the red bar: This block contains unexpected or invalid content. One button says “Attempt Block Recovery”. The other says “Convert to HTML”. Whatever they click, something is now wrong on a live page and nobody noticed for a week.
That single failure mode is responsible for most of the pain people associate with block editor development. It isn’t React that’s hard. It isn’t webpack. It’s the fact that a static block stores its own markup in post_content and then re-validates that markup against your save() function on every edit, forever.
Here’s how we build a custom Gutenberg block at SemiColonWeb so that doesn’t happen: the decision tree before you write any code, the parts of the scaffold that actually matter, and the 2026-era APIs (Interactivity, block bindings, metadata collections) that replaced a lot of what the older tutorials teach.
- Default to dynamic blocks with a
renderPHP file. They store attributes only, never markup, so they cannot throw block validation errors and you can change the output whenever you like. block.jsonwith"apiVersion": 3is the contract. The editor is iframed at API v3, so editor CSS must be declared viaeditorStyle, not injected into the admin page.- Before building anything, check whether block bindings, a block variation, a registered block style, or a pattern solves it. Roughly half the “we need a custom block” requests we get don’t need one.
- Use the Interactivity API with
viewScriptModulefor front-end behaviour. It ships a few KB of runtime instead of React, and it hydrates progressively. - If you must ship a static block, write
deprecatedentries withmigratethe moment you changesave(). There’s no retrofitting this after the content is broken.
First, decide whether you need a block at all
Every custom block is a permanent maintenance liability. It has a build step, a JS dependency tree, and content in the database that only it can render. So the first question is whether core already does it.
Four things to rule out, in order of cheapness:
- Block styles.
registerblockstyle()adds a class-based variant to a core block. A “boxed quote” is a style, not a block. - Block variations. Same block, different preset attributes and inner block template. A “Team Grid” is usually
core/grouppluscore/columnswith a locked template. - Patterns. If it’s a layout the client assembles once per page and then edits freely, it’s a pattern. Patterns are just serialised core blocks: zero runtime cost, zero upgrade risk.
- Block bindings. Since WP 6.5, you can bind a core paragraph, heading, image or button attribute straight to post meta. If the requirement is “show the custom field value here, styled like the rest of the site”, that’s a binding plus a registered meta key. No custom block.
You build a custom block when the thing has its own data shape, its own editing affordances, and rendering logic that touches the database at view time. Pricing card with a query for the current plan? Block. Fancy divider? Not a block.

Scaffold once, then read what it made
Start with the official generator. Don’t hand-roll a webpack config in 2026.
npx @wordpress/create-block@latest scw-pricing-card \
--namespace scw \
--variant dynamic \
--no-plugin # omit the plugin header if you're dropping it into an existing plugin
cd scw-pricing-card
npm run start # wp-scripts watch build, sourcemaps on
The --variant dynamic flag matters. It gives you a render.php and no save() markup, which is the shape you want for about 80% of client work.
The scaffold produces a src/ folder and a build/ folder. Only build/ is registered. If you’ve ever had a block silently fail to appear, check that you’re pointing registerblocktype() at the build directory, not src/.
block.json is the whole API surface
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "scw/pricing-card",
"title": "Pricing Card",
"category": "design",
"icon": "money-alt",
"textdomain": "scw-blocks",
"attributes": {
"tier": { "type": "string", "default": "Standard" },
"price": { "type": "string", "default": "49" },
"highlighted": { "type": "boolean", "default": false }
},
"supports": {
"html": false,
"anchor": true,
"color": { "background": true, "text": true },
"spacing": { "padding": true, "margin": [ "top", "bottom" ] },
"typography": { "fontSize": true, "lineHeight": true },
"interactivity": true
},
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css",
"viewScriptModule": "file:./view.js",
"render": "file:./render.php"
}
Two lines earn their keep. "supports" hands you colour, spacing and typography controls for free, wired into theme.json, which means the block inherits the site’s design tokens instead of inventing new ones. And "html": false removes the “Edit as HTML” option, which is the single most common way editors corrupt a block. That’s the same instinct behind everything in our client-proof admin handover process: remove the loaded gun.
Note "apiVersion": 3. The post editor canvas is an iframe at v3. Styles you enqueue onto the admin page do not reach inside it. Use editorStyle, or addeditorstyle() from the theme, and nothing else.
How to build a WordPress block that survives your own edits
Static blocks serialise their output into post_content wrapped in HTML comments. On load, WordPress re-runs save() with the stored attributes and string-compares the result against what’s in the database. Any difference, even an attribute order change caused by a @wordpress/block-editor update, triggers the invalid content warning.
Dynamic blocks store this:
<!-- wp:scw/pricing-card {"tier":"Pro","price":"149","highlighted":true} /-->
That’s it. No markup to validate. The output is generated at render time by render.php:
<?php
/**
- @var array $attributes
- @var string $content Serialised InnerBlocks output.
- @var WP_Block $block
*/
$extra = $attributes['highlighted'] ? 'scw-pricing-card is-highlighted' : 'scw-pricing-card';
// getblockwrapper_attributes() emits the classes and inline styles generated by "supports".
?>
<div <?php echo getblockwrapper_attributes( array( 'class' => $extra ) ); ?>>
<h3 class="scw-pricing-card__tier"><?php echo esc_html( $attributes['tier'] ); ?></h3>
<p class="scw-pricing-card__price">
<span class="scw-pricing-card__currency">£</span><?php echo esc_html( $attributes['price'] ); ?>
</p>
<?php echo $content; // Already rendered and escaped by the block renderer. ?>
</div>
Change the markup next year, hit save on the PHP file, every existing instance updates. No migration. No recovery prompts.
The trade-off is real and you should know it. Dynamic blocks cost PHP on every uncached request, and their content is invisible to anything reading post_content directly: some search indexers, some export tools, the default WP REST API content.raw field. On a heavily cached site that cost rounds to zero, but on a page-cache-hostile setup (logged-in users, personalised content), measure it. Our breakdown of the caching layers covers where those requests actually land.
When you do ship static, write the deprecation first
registerBlockType( metadata.name, {
edit: Edit,
save: Save,
deprecated: [ {
attributes: { ...metadata.attributes, price: { type: 'number' } },
save( { attributes } ) {
// Exact copy of the OLD save output. Do not tidy it up.
return <div className="pricing">{ attributes.price }</div>;
},
migrate( attributes ) {
return { ...attributes, price: String( attributes.price ) };
},
} ],
} );
The deprecated save must be a byte-faithful copy of the old one. People break this by “cleaning up” the old function while they move it. Copy, paste, don’t touch.
Build the editor UI the client will actually use
The edit component should look like the front end. Not approximately, exactly, because editors judge whether a block is broken by whether the preview matches the page.
import { __ } from '@wordpress/i18n';
import { useBlockProps, useInnerBlocksProps, InspectorControls, RichText } from '@wordpress/block-editor';
import { PanelBody, ToggleControl } from '@wordpress/components';
const TEMPLATE = [
[ 'core/list', {}, [ [ 'core/list-item', { content: __( 'Unlimited projects', 'scw-blocks' ) } ] ] ],
[ 'core/buttons', { lock: { remove: true, move: false } } ],
];
export default function Edit( { attributes, setAttributes } ) {
const { tier, price, highlighted } = attributes;
const blockProps = useBlockProps( {
className: highlighted ? 'scw-pricing-card is-highlighted' : 'scw-pricing-card',
} );
// Spread inner blocks onto the same wrapper so the editor DOM matches render.php.
const innerBlocksProps = useInnerBlocksProps( blockProps, {
template: TEMPLATE,
templateLock: 'insert', // Children can be edited and reordered, not added or deleted.
} );
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Card options', 'scw-blocks' ) }>
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Highlight this tier', 'scw-blocks' ) }
checked={ highlighted }
onChange={ ( value ) => setAttributes( { highlighted: value } ) }
/>
</PanelBody>
</InspectorControls>
<div { ...innerBlocksProps }>
<RichText
tagName="h3"
value={ tier }
allowedFormats={ [] }
onChange={ ( value ) => setAttributes( { tier: value } ) }
placeholder={ __( 'Tier name', 'scw-blocks' ) }
/>
{ innerBlocksProps.children }
</div>
</>
);
}
Three opinions baked into that snippet. allowedFormats={ [] } on the tier stops someone pasting bold red Comic Sans into a heading. templateLock: 'insert' is the sweet spot for client sites: editable content, fixed structure. Put every control in InspectorControls rather than rendering settings inline in the canvas, because inline controls confuse people about what’s content and what’s configuration.
One more thing that audits always catch: RichText with a placeholder is not a label. If your block has form-like controls in the canvas, they need accessible names. We wrote up the recurring offenders in the five accessibility failures auditors always find.
Front-end behaviour without shipping React
The old pattern was to enqueue a viewScript that queried the DOM and bolted on listeners. It worked, but you ended up hand-rolling state for every toggle, tab and filter.
Since WP 6.5, the Interactivity API gives you a declarative directive system with a runtime of a few kilobytes. Declare "interactivity": true in supports, point viewScriptModule at your file, and write directives in the PHP output:
<div
<?php echo getblockwrapper_attributes(); ?>
data-wp-interactive="scw/pricing"
<?php echo wpinteractivitydatawpcontext( array( 'isOpen' => false ) ); ?>
>
<button
data-wp-on--click="actions.toggle"
data-wp-bind--aria-expanded="context.isOpen"
><?php eschtmle( 'What is included', 'scw-blocks' ); ?></button>
<div data-wp-bind--hidden="!context.isOpen"><?php echo $content; ?></div>
</div>
import { store, getContext } from '@wordpress/interactivity';
store( 'scw/pricing', {
actions: {
toggle() {
const context = getContext();
context.isOpen = ! context.isOpen; // Mutating the proxy re-renders bound directives.
},
},
} );
Note the ! prefix in data-wp-bind--hidden. Negation is supported inline, which saves you a derived state entry for every trivial inverse.
The honest caveat: the Interactivity API is worth it for anything with state that changes. For a one-off scroll reveal or a hover effect, plain CSS plus an IntersectionObserver is smaller and less to learn. We’ve argued the performance side of that in micro-interactions without wrecking performance.
Registering twenty blocks without twenty file reads
Each registerblocktype( __DIR__ . '/build/thing' ) call reads and parses a JSON file on every single request. With one block, irrelevant. With a plugin shipping 25, that’s 25 filesystem hits per page load on a cold object cache.
WordPress 6.8 added wpregisterblocktypesfrommetadatacollection(), which reads one pre-compiled PHP array instead. Generate it in the build:
wp-scripts build --blocks-manifest
add_action( 'init', function () {
$build = __DIR__ . '/build';
if ( functionexists( 'wpregisterblocktypesfrommetadata_collection' ) ) {
wpregisterblocktypesfrommetadatacollection( $build, $build . '/blocks-manifest.php' );
return;
}
// Fallback for 6.7 and earlier.
foreach ( (array) glob( $build . '/*/block.json' ) as $file ) {
registerblocktype( dirname( $file ) );
}
} );
Also: run wp-env start for local development rather than testing on the client’s staging box. It gives you a throwaway WordPress with your plugin mounted, and wp-env run cli wp ... for WP-CLI. Resetting a broken block’s content is one command, not a database restore.
What breaks in production
Five things we see repeatedly on handover:
- Editor styles that don’t load. API v3 iframe. Declare
editorStyle, neverwpenqueuestyleonadminenqueuescripts. - Block CSS loading on every page. Declare styles in
block.jsonand WordPress only enqueues them on pages containing the block, provided the theme doesn’t callwpenqueueglobalstylesincorrectly or forceshouldloadseparatecoreblockassetsto false. - ServerSideRender in the editor. It fires a REST request on every attribute change. On a shared host that’s a visible lag of 300ms or more per keystroke. Render in JS, render in PHP, don’t use both for live preview.
- Attribute drift. Adding a new attribute with no
defaultmeans existing content getsundefined. Always set a default. - Uncleaned revisions. Blocks with large attribute payloads inflate
wp_postsfast because every autosave copies them. Worth reading alongside our notes on revisions and autoloaded option bloat.
If the project is mostly layout rather than custom data, weigh whether a theme that already ships the patterns is cheaper than a block library you have to maintain. CanvasWP exists for that reason: most “we need a custom block” briefs are really “we need this layout to be editable by a non-developer”.
Frequently Asked Questions
Do I need to know React to build a custom Gutenberg block?
You need enough React to write a function component with props and JSX, which is maybe two hours of learning. You do not need hooks beyond useState and the WordPress-provided ones, and you never touch routing, context providers or state libraries. If you write a dynamic block, all the output logic stays in PHP.
Static or dynamic block: which should I pick?
Dynamic, unless the block’s output genuinely never depends on anything outside its own attributes and you need it present in post_content for export or search reasons. Dynamic blocks cannot produce validation errors, which removes the single biggest support cost in block editor development. The price is a small amount of PHP per render, which page caching absorbs.
Why does my block say “unexpected or invalid content” after an update?
Your current save() output no longer matches what’s stored in the database. The cause is usually an edit to save() without a matching deprecated entry, but it can also be a core update changing how an attribute or class is serialised. Fix it by adding a deprecated version containing the exact previous save() function, then optionally a migrate to reshape attributes.
Can I put a custom block’s data in post meta instead of block attributes?
Yes, and since WP 6.5 the Block Bindings API is usually the better route for that. Register the meta with showinrest and registerpostmeta, then bind a core block’s attribute to it, no custom block required. Use block attributes when the data belongs to that specific instance of the block, and meta when it belongs to the post.
How do I stop clients breaking the block layout?
Set "html": false in supports to remove the code editor for that block, use templateLock: 'insert' or 'all' on inner blocks, and lock individual children with the lock attribute ({ remove: true, move: false }). Combine that with restricting which supports are exposed, so editors get your spacing scale instead of arbitrary pixel values.
Pick your default and stick to it: dynamic block, apiVersion 3, everything declared in block.json, interactivity through directives rather than a bespoke bundle. That combination means the next person to open the codebase can change the front-end markup by editing one PHP file, without fear.
Before you write the first line, though, spend ten minutes proving the requirement isn’t a block style, a variation, a pattern or a binding. The block you don’t build is the one that never breaks.


