Custom Gutenberg Blocks: A Complete Guide
Build custom Gutenberg blocks with React and the WordPress block API: block.json, registration, attributes, save vs render, dynamic blocks, and the modern @wordpress/scripts toolchain.

A custom block gives editors a reusable interface for structured content. This guide builds a callout with a title, body, and tone, then explains how a dynamic version stores and renders its data. Start with a disposable local WordPress installation and test the examples before adapting them to a production plugin.
What a block actually is
A block has two halves: an editor representation (a React component that renders inside the block editor) and a save representation (HTML that gets serialized into the post content). For static blocks, the save half is declarative HTML. For dynamic blocks, the save function can return no markup, while PHP renders the frontend output. A dynamic block can also save fallback HTML for use when its rendering plugin is unavailable.
Pick the right shape for your block:
- Static blocks: for content that editors save directly, such as quotes, callouts, and custom layouts. Changes to saved markup may require a deprecated version or migration so existing posts remain editable.
- Dynamic blocks: for content driven by queries (latest posts, related products, user-specific content). PHP controls the output, but caching can affect freshness. Attribute changes still need compatibility planning, and server rendering has a cost that should be measured.
Use a static block when saved content is sufficient. Use a dynamic block when the output must be computed on the server. Either choice needs a plan for existing content when the implementation changes.
The modern toolchain: block.json + @wordpress/scripts
Use block.jsonto describe the block's name, attributes, supports, and asset paths. Register it on the server as well as in the editor. The generated plugin supplies the PHP registration code; keep it and its build paths together.
Scaffold a new block with the official tool:
npx @wordpress/create-block@4.99.0 callout --namespace=mxt --textdomain=mxt-callout --title=Callout
cd callout
npm run buildThis command pins the scaffold version rather than assuming future templates have the same layout. In this scaffold, edit the files under src/callout/, keep the generated plugin entry point, and rebuild before activating it in local WordPress:
block.jsonas the source of truth.edit.js+save.jsfor the React halves.index.jsregistering the block.style.scss+editor.scssfor stylesheets.- A pre-configured webpack build via
@wordpress/scripts. npm run startto watch for changes.npm run buildfor a production build.
The anatomy of block.json
Replace the scaffold's block metadata with this static example. Keep the generated imports for style.scss andeditor.scss; their build outputs match the paths below. No frontend JavaScript is needed for this callout:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "mxt/callout",
"version": "1.0.0",
"title": "Callout",
"category": "design",
"icon": "megaphone",
"description": "A bordered callout for emphasis blocks.",
"keywords": ["callout","note","emphasis"],
"textdomain": "mxt-callout",
"attributes": {
"tone": {
"type": "string",
"enum": ["info","warning","danger","success"],
"default": "info"
},
"title": {
"type": "string",
"source": "html",
"selector": "h3",
"default": ""
},
"body": {
"type": "string",
"source": "html",
"selector": "div.callout-body",
"default": ""
}
},
"supports": {
"anchor": true,
"html": false,
"spacing": { "padding": true, "margin": true },
"typography": { "fontSize": true }
},
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css"
}Pieces worth calling out:
apiVersion: 3: this example uses block API version 3, introduced in WordPress 6.3. Check the generated plugin's minimum WordPress version as well as the APIs you use.attributes: the data model. Each attribute has a type and (for HTML-sourced ones) a selector telling WordPress how to extract it from saved HTML.supports: opts you into core editor features (color, spacing, typography, anchor) without writing controls yourself. Available controls also depend on theme settings and the WordPress version.
The Edit component
The Edit component is a React component receiving attributes, setAttributes, and context. Build it with WordPress's component library (@wordpress/components) for visual consistency with core blocks.
import { useBlockProps, RichText, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, SelectControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import './editor.scss';
export default function Edit({ attributes, setAttributes }) {
const { tone, title, body } = attributes;
const blockProps = useBlockProps({
className: `mxt-callout mxt-callout--${tone}`,
});
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Callout', 'mxt-callout' ) }>
<SelectControl
label={ __( 'Tone', 'mxt-callout' ) }
value={ tone }
options={ [
{ value: 'info', label: __( 'Info', 'mxt-callout' ) },
{ value: 'warning', label: __( 'Warning', 'mxt-callout' ) },
{ value: 'danger', label: __( 'Danger', 'mxt-callout' ) },
{ value: 'success', label: __( 'Success', 'mxt-callout' ) },
] }
onChange={ ( v ) => setAttributes({ tone: v }) }
/>
</PanelBody>
</InspectorControls>
<div { ...blockProps }>
<RichText
tagName="h3"
value={ title }
onChange={ ( v ) => setAttributes({ title: v }) }
placeholder={ __( 'Headline...', 'mxt-callout' ) }
/>
<RichText
tagName="div"
className="callout-body"
value={ body }
onChange={ ( v ) => setAttributes({ body: v }) }
placeholder={ __( 'Body...', 'mxt-callout' ) }
/>
</div>
</>
);
}Three patterns to internalize:
useBlockProps()wraps the outer element with the props the editor needs (anchors, alignment, custom classes). Always use it.RichTextis for editable text. It gives you the toolbar (bold, italic, links) for free.InspectorControlsrenders panels in the sidebar. Use it for settings. Use the block toolbar for inline-relevant controls.
The Save component (for static blocks)
For static blocks, Save returns the HTML that gets serialized into the post content:
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save({ attributes }) {
const { tone, title, body } = attributes;
const blockProps = useBlockProps.save({
className: `mxt-callout mxt-callout--${tone}`,
});
return (
<div { ...blockProps }>
<RichText.Content tagName="h3" value={ title } />
<RichText.Content tagName="div" className="callout-body" value={ body } />
</div>
);
}Save uses RichText.Content instead of RichText. It's the read-only static renderer. WordPress regenerates saved markup from the parsed attributes and compares it with the markup stored in the post. If validation fails, inspect the expected and actual saved markup and confirm the attribute sources and types. The editing interface does not need to match the saved HTML.
Dynamic blocks: the PHP render callback
Try this as a separate version on a new test post. Converting an existing static block needs a migration plan for its saved content. For a dynamic block whose save function returns null, replace the entire attributes object with the following and addrender. Do not keep the static version'ssource or selector keys: these values need to be serialized in the block comment for PHP to receive them.
{
"attributes": {
"tone": {
"type": "string",
"enum": ["info", "warning", "danger", "success"],
"default": "info"
},
"title": { "type": "string", "default": "" },
"body": { "type": "string", "default": "" }
},
"render": "file:./render.php"
}Keep the editor component, replace save.js with the following, and remove anchor from supports for this dynamic example. Anchor support is not automatic for dynamic blocks. A null save provides no fallback HTML when the plugin is disabled, so choose it only when that behavior fits the content.
export default function save() {
return null;
}And implement render.php:
<?php
$tone = $attributes['tone'] ?? 'info';
$tone = in_array( $tone, [ 'info', 'warning', 'danger', 'success' ], true )
? $tone : 'info';
$title = is_string( $attributes['title'] ?? null ) ? $attributes['title'] : '';
$body = is_string( $attributes['body'] ?? null ) ? $attributes['body'] : '';
$wrapper_attributes = get_block_wrapper_attributes( [
'class' => "mxt-callout mxt-callout--{$tone}",
] );
?>
<div <?php echo $wrapper_attributes; ?>>
<h3><?php echo wp_kses_post( $title ); ?></h3>
<div class="callout-body">
<?php echo wp_kses_post( $body ); ?>
</div>
</div>Two important details:
get_block_wrapper_attributes()generates the escaped wrapper attributes for supported features, including classes and styles. Echo its result directly rather than escaping the whole attribute string again. Check each support's requirements for a dynamic renderer.- Always escape:
wp_kses_post()for HTML content,esc_attr()for attributes,esc_url()for URLs. Block attributes come from storage and requests. Treat them as untrusted, validate expected types and allowed values, and filter or escape at the final output context. See WordPress security for the full rationale.
Block patterns and variations
A block is a primitive. Patterns are pre-configured arrangements of blocks (useful for recurring marketing layouts). Variations are pre-configured states of a single block (useful when one block can render four different visual modes).
A variation can offer a preset warning or success tone without defining a separate block type. A pattern can combine the callout with surrounding headings and paragraphs. Pick the one that matches what an editor needs to insert.
Choosing block supports
The supports object opts you into editor features. Enable only the controls the block needs, then check them with the target theme and WordPress version:
- color and typography: nested options such as
color.gradientsandtypography.fontSizeenable the corresponding controls when supported by the theme. - spacing: padding/margin/blockGap controls.
- anchor: anchor IDs for in-page linking.
- align: wide/full alignment.
- html: false: disable the "Edit as HTML" option that lets users break your block.
Shared controls help the block fit the editor. They still need frontend checks, keyboard testing, and a stylesheet sized to the actual design. Remove unused scaffold styles. This callout does not require a frontend script, tracker, or external service; verify which assets load on pages with and without it before shipping a plugin.
Internationalization
Always wrap user-facing strings in __()with your plugin's text domain:
import { __ } from '@wordpress/i18n';
// ...
<Button>{ __( 'Click me', 'mxt-callout' ) }</Button>With WP-CLI and its i18n command installed, extract a translation template from the plugin directory. An npm script namedmake-pot works only if you define it. Delivering editor translations also requires the appropriate JSON catalogs and script translation registration; extraction alone does not load translations.
wp i18n make-pot . languages/mxt-callout.potTesting blocks
Test the built plugin in a disposable WordPress installation, not on a customer site. Include these checks:
- Visual regression on the editor: Playwright navigating to a page with your block, screenshot, diff.
- Save and reload: save nonempty title and body values in every tone, reload, and confirm the text survives. Static blocks should remain valid. Test existing saved content before releasing a change to the save function.
- Frontend render: check static and dynamic output, supported styles, keyboard access, and heading structure. Include malformed attributes and hostile HTML in isolated security tests, and verify the intended plugin-disabled behavior.
Reference documentation
- Create Block scaffold and options
- Edit, save, and block validation
- Attribute sources and serialization
- Dynamic rendering
- Block supports and their limitations
- WP-CLI translation extraction
Common mistakes
- Changing saved markup without compatibility handling. Existing static blocks can fail validation when reopened.
- Forgetting
useBlockProps(). Your block loses anchors, alignment, custom classes silently. - Hardcoding strings.Translators can't help you.
- Rolling your own controls instead of using core components. They look different from every other block, age poorly, and lose accessibility wins core ships.
- Static block when dynamic was the right call. Six months in, you have 50,000 published posts with the block and a schema change requires deprecations.
Keep the block's data model, saved content, and frontend output under test as the implementation changes. For a plugin scaffold that pairs well with custom blocks (the registration code, the activation/deactivation hooks, the i18n wiring), see WordPress Plugin Boilerplate Setup. For projects where the requirements warrant senior block engineering, our plugin development service handles the full lifecycle.
Need help putting this into practice?
MaxtDesign builds the AI web stacks the articles describe, from agentic workflows to performance-first WordPress + WooCommerce. Talk to us about your project.