
How to Create a Custom Gutenberg Block in WordPress?
A custom Gutenberg block is JavaScript registered against the block editor's own APIs, @wordpress/blocks and @wordpress/block-editor, giving editors a reusable, visually editable piece of content that goes beyond what core blocks and shortcodes can do. Where a shortcode is a simple bracketed tag processed at render time, a block has a live, editable preview directly inside the editor, its own settings panel, and (for a static block) markup saved directly into post content as HTML comments.
This guide walks through scaffolding a block with the official tooling, understanding block.json, writing the edit and save functions, adding attributes and an Inspector Controls panel, and the difference between static and dynamic (server-rendered) blocks.
Scaffolding a Block with @wordpress/create-block
The officially supported way to start a new block is the create-block package, which generates a complete, working block plugin with build tooling already configured:
npx @wordpress/create-block@latest tidewave-callout
This creates a tidewave-callout folder containing a block.json, an src/index.js entry point, src/edit.js, src/save.js, and a package.json with @wordpress/scripts already set up for building. Running npm start inside that folder watches and rebuilds the block during development; npm run build produces the production build WordPress actually loads.
block.json: The Block's Metadata
Every modern block is defined by a block.json file, WordPress reads this to register the block's name, attributes, and where its scripts and styles should load:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "tidewave/callout",
"title": "Callout",
"category": "text",
"icon": "megaphone",
"description": "A styled callout box for highlighting important text.",
"attributes": {
"message": {
"type": "string",
"source": "html",
"selector": "p"
},
"tone": {
"type": "string",
"default": "info"
}
},
"supports": {
"align": ["wide", "full"]
},
"textdomain": "tidewave",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css"
}
Key fields worth understanding:
namemust be namespaced asplugin-slug/block-name,tidewave/callouthere, to avoid colliding with core or other plugins' blocks.attributesdefines the data a block instance stores,message(the text content) andtone(which style variant to apply) in this example. Thesource: "html"andselector: "p"combination tells WordPress to parse the attribute's value straight out of a<p>tag in the saved markup, rather than storing it as a separate comment attribute.editorScriptloads only in the block editor;styleloads on both the editor and the front end, whileeditorStyleloads only in the editor, useful for editor-only affordances that shouldn't appear on the live site.
Registering the Block in JavaScript
src/index.js is the entry point that actually registers the block with the editor, using registerBlockType from @wordpress/blocks:
// src/index.js
import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
import save from './save';
import metadata from './block.json';
import './style.scss';
registerBlockType( metadata.name, {
edit: Edit,
save,
} );
Passing metadata (imported directly from block.json) means the attributes, supports, and other configuration defined there are used automatically, only edit and save, the two functions that actually render the block, need to be supplied here.
The edit Function: What Editors See and Interact With
edit.js is a React component rendered inside the block editor. It uses useBlockProps (from @wordpress/block-editor) to attach the standard editor wrapper attributes, and RichText for inline-editable text:
// src/edit.js
import { useBlockProps, RichText, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, SelectControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
export default function Edit( { attributes, setAttributes } ) {
const { message, tone } = attributes;
const blockProps = useBlockProps( {
className: `tidewave-callout tidewave-callout--${ tone }`,
} );
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Callout Settings', 'tidewave' ) }>
<SelectControl
label={ __( 'Tone', 'tidewave' ) }
value={ tone }
options={ [
{ label: 'Info', value: 'info' },
{ label: 'Warning', value: 'warning' },
{ label: 'Success', value: 'success' },
] }
onChange={ ( newTone ) => setAttributes( { tone: newTone } ) }
/>
</PanelBody>
</InspectorControls>
<div { ...blockProps }>
<RichText
tagName="p"
value={ message }
onChange={ ( newMessage ) => setAttributes( { message: newMessage } ) }
placeholder={ __( 'Enter your callout text…', 'tidewave' ) }
/>
</div>
</>
);
}
A few things worth calling out:
setAttributesis how a block updates its own stored data; calling it re-renders theeditcomponent and (eventually) changes whatsaveoutputs when the post is saved.InspectorControlsrenders its children into the settings sidebar on the right of the editor, rather than inline in the block itself, which is where block-level configuration (as opposed to content editing) normally belongs.RichTextgives themessageattribute inline, WYSIWYG-style editing directly inside the block's preview, exactly like the paragraph block's own text editing.
The save Function: What Gets Stored in Post Content
save.js returns the actual HTML markup that WordPress serializes into the post's post_content, wrapped in HTML comments recording the block's name and attributes:
// src/save.js
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save( { attributes } ) {
const { message, tone } = attributes;
const blockProps = useBlockProps.save( {
className: `tidewave-callout tidewave-callout--${ tone }`,
} );
return (
<div { ...blockProps }>
<RichText.Content tagName="p" value={ message } />
</div>
);
}
The resulting post content looks like this once saved:
<!-- wp:tidewave/callout {"tone":"warning"} -->
<div class="wp-block-tidewave-callout tidewave-callout tidewave-callout--warning">
<p>Back up your database before running this migration.</p>
</div>
<!-- /wp:tidewave/callout -->
This is a static block, its front-end markup is generated once at save time and stored directly in the database, requiring no PHP to render. If the save function's output and the block's registered attributes ever get out of sync (for example, after editing the block's code), WordPress will flag existing instances of the block with a "block validation" error in the editor, prompting a resolution.
Dynamic Blocks: Rendering with PHP Instead
Some blocks need to render content that changes independent of when the post was saved, a "latest posts" list, live data from an API, output that depends on the current user. For these, register a render_callback in PHP instead of relying on save:
// tidewave-callout.php
add_action( 'init', function () {
register_block_type( __DIR__ . '/build', [
'render_callback' => 'tidewave_render_callout_block',
] );
} );
function tidewave_render_callout_block( $attributes ) {
$tone = isset( $attributes['tone'] ) ? $attributes['tone'] : 'info';
$message = isset( $attributes['message'] ) ? $attributes['message'] : '';
return sprintf(
'<div class="tidewave-callout tidewave-callout--%s"><p>%s</p></div>',
esc_attr( $tone ),
wp_kses_post( $message )
);
}
With a render_callback registered, the block's save function in JavaScript typically returns null, since PHP, not the stored markup, is now responsible for the front-end output. register_block_type() pointed at a folder (rather than a specific block.json path) automatically reads that folder's block.json for the rest of the block's configuration.
Block Supports: Reusing Core Editing Behavior
The supports key in block.json opts a custom block into standard editor behaviors that core blocks already have, rather than reimplementing them yourself. Common ones worth knowing:
{
"supports": {
"align": ["wide", "full"],
"color": {
"background": true,
"text": true
},
"spacing": {
"padding": true,
"margin": true
},
"html": false
}
}
With color.background and color.text enabled, WordPress automatically adds background and text color controls to the block toolbar and Inspector Controls, pulling from the theme's theme.json palette (see customizing a block theme with theme.json), with zero custom UI code needed on your end. html: false disables the "Edit as HTML" option in the block's toolbar menu, worth setting on a block whose markup should never be hand-edited outside the intended edit interface, since manual HTML edits are a common source of the validation errors covered below.
Packaging the Block as a Plugin
A custom block is ultimately just a WordPress plugin. The scaffolded folder from @wordpress/create-block already includes a PHP entry file with a standard plugin header:
<?php
/**
* Plugin Name: Tidewave Callout
* Description: A styled callout block for highlighting important text.
* Version: 1.0.0
* Requires at least: 6.4
* Requires PHP: 7.4
* Author: Tidewave
* Text Domain: tidewave
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'init', function () {
register_block_type( __DIR__ . '/build' );
} );
Zipping this folder (with the built build/ directory included, generated by npm run build) and uploading it under Plugins → Add New Plugin → Upload Plugin installs it like any other plugin, activating and deactivating it independently of whatever theme is currently active, block or classic.
Frequently Asked Questions (FAQ) About Custom Gutenberg Blocks
Yes, in practice. The block editor is built with React, and the edit function is a React component using the editor's own component library (@wordpress/block-editor, @wordpress/components). Basic React knowledge (components, props, hooks like useState) is the main prerequisite beyond WordPress-specific APIs.
A static block's front-end HTML is generated by its save function and stored directly in the post content in the database. A dynamic block's save function returns null (or minimal markup), and a PHP render_callback generates the front-end output fresh on every page load, needed whenever a block's output depends on data that changes after the post was saved.
WordPress compares the markup a block's current save function would generate against what's actually stored in the post content; if you change save's output (or an attribute's source) after posts already contain that block, the stored markup no longer matches, triggering a validation error. Provide a deprecations array in your block's registration to handle migrating older saved markup gracefully.
Technically yes, using wp.blocks.registerBlockType directly with plain JavaScript and no JSX, but @wordpress/create-block and @wordpress/scripts (which handle the webpack build, JSX, and modern JavaScript features) are the officially recommended and far more maintainable path for anything beyond a trivial example.
A shortcode is processed at render time from a bracketed text tag and has no visual editing experience in the editor beyond the raw tag itself (or a generic Shortcode block). A custom Gutenberg block has a live, WYSIWYG editing experience, structured attributes, and Inspector Controls settings, at the cost of needing JavaScript and build tooling rather than just PHP.
Yes, block support for post content is independent of whether the active theme is a classic theme or a block theme, block themes only additionally extend block editing into page templates. A custom block registered as shown here works in the post content area of any modern WordPress theme.
Yes, using the InnerBlocks component from @wordpress/block-editor inside your edit and save functions, which lets a block act as a container that accepts other blocks inside it, following the same pattern core container blocks like Group and Columns use.
Conclusion
Building a custom Gutenberg block means working directly with the block editor's own JavaScript APIs, block.json for metadata and attributes, an edit component for the in-editor experience, and either a save function (for a static block) or a PHP render_callback (for a dynamic one) to produce the front-end output. @wordpress/create-block handles the tooling setup, so the real work is deciding what attributes a block needs and whether its output can be generated once at save time or needs to be computed fresh on every page load.
Custom blocks are the right tool once core blocks and theme.json styling (see customizing a block theme with theme.json) stop covering what an editor needs, particularly for anything with meaningful custom interactivity or data that a shortcode's simpler model can't express well.
A few additional resources if you want to go deeper:
- WordPress Block Editor Handbook — the complete developer reference for block development, including tutorials beyond this guide's scope.
- WordPress Block Editor Handbook: block.json — the full block.json schema reference.
- @wordpress/create-block on npm — the official scaffolding tool used at the start of this guide.


