
How to Build a Custom Gutenberg Block with block.json and React?
Sooner or later every WordPress project needs something the core blocks cannot do. A pricing card with a highlighted plan, a callout box in your brand colors, a "last updated" line that always shows the right date. You can approximate these with groups, columns, and custom CSS classes, but editors break them, the markup drifts between pages, and nobody remembers which class does what. A custom block solves this properly: one reusable component with its own controls, its own styles, and markup you define once.
Building blocks used to mean hand-wiring webpack, Babel, and a pile of PHP registration code. Today the official tooling does almost all of it. You describe the block in a block.json file, write the editor interface in React, and WordPress handles asset registration, loading, and server-side awareness of your block.
This article walks through building a custom block from start to finish: scaffolding a plugin with @wordpress/create-block, understanding every file it generates, defining attributes in block.json, writing the edit and save functions with React, adding sidebar controls, styling the block, switching to a dynamic block rendered with PHP, and avoiding the validation errors that trip up most first blocks.
What You Need Before You Start
Block development uses JavaScript tooling, so you need a few things installed locally:
- Node.js LTS (version 20 or later) and npm
- A local WordPress site running a recent version. The examples here target WordPress 7.1. If you do not have one, setting up wp-env gives you a Docker-based site in one command.
- Basic React knowledge: components, props, and JSX. You do not need to know Redux or the internals of the block editor.
If you are new to the editor itself, read what the Gutenberg editor is first. Everything in the editor, from paragraphs to the navigation menu, is a block built with the same APIs you will use here.
Scaffolding the Block Plugin
Blocks are distributed in plugins (or themes, though plugins are the better home because blocks are content and should survive a theme switch). Change into your site's plugins folder and run the scaffolding tool:
# Terminal
cd wp-content/plugins
npx @wordpress/create-block@latest callout-box
cd callout-box
npm start
create-block asks no questions in this form. It creates a plugin named callout-box, installs @wordpress/scripts, and runs an initial build. npm start then watches your source files and rebuilds on every save.
Activate the plugin in Dashboard > Plugins, create a new post, and search the block inserter for "Callout Box". The placeholder block appears and already works.
What create-block Generates
# Plugin structure
callout-box/
├── build/ # Compiled output, generated by npm start or npm run build
├── src/
│ └── callout-box/
│ ├── block.json # Block metadata
│ ├── index.js # Registers the block in the editor
│ ├── edit.js # Editor interface (React)
│ ├── save.js # Front-end markup saved to post content
│ ├── editor.scss # Styles only for the editor
│ ├── style.scss # Styles for the editor and the front end
│ └── view.js # Optional front-end JavaScript
├── callout-box.php # Main plugin file
└── package.json
| File | Runs where | Purpose |
|---|---|---|
block.json | PHP and JS | Single source of truth: name, attributes, supports, assets |
index.js | Editor | Calls registerBlockType with the edit and save functions |
edit.js | Editor | What the author sees and interacts with |
save.js | Editor, then stored | Static HTML written into post_content |
style.scss | Editor and front end | Shared visual styles |
editor.scss | Editor only | Editor-specific adjustments |
callout-box.php | Server | Registers the block type from the build folder |
The Plugin File
The main plugin file is short because block.json does the heavy lifting:
<?php
// wp-content/plugins/callout-box/callout-box.php
/**
* Plugin Name: Callout Box
* Description: A styled callout box with a title, message, and variant.
* Version: 0.1.0
* Requires at least: 6.8
* Requires PHP: 7.4
* Author: Your Name
* License: GPL-2.0-or-later
* Text Domain: callout-box
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function callout_box_block_init() {
wp_register_block_types_from_metadata_collection(
__DIR__ . '/build',
__DIR__ . '/build/blocks-manifest.php'
);
}
add_action( 'init', 'callout_box_block_init' );
wp_register_block_types_from_metadata_collection() reads a PHP manifest that the build generates from every block.json in the plugin, then registers each block and its assets in one pass. It was added in WordPress 6.8 and is faster than parsing JSON files on every request. On older sites you will see the equivalent single-block call, register_block_type( __DIR__ . '/build/callout-box' ), which still works.
Understanding block.json
block.json is read by both PHP and JavaScript, so the server knows about your block, its attributes, and its assets without running any JavaScript. Replace the generated src/callout-box/block.json with this:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "wsm/callout-box",
"version": "0.1.0",
"title": "Callout Box",
"category": "text",
"icon": "megaphone",
"description": "Highlight important information with a title, message, and color variant.",
"keywords": ["notice", "alert", "tip"],
"textdomain": "callout-box",
"attributes": {
"title": {
"type": "string",
"source": "html",
"selector": ".wp-block-wsm-callout-box__title"
},
"message": {
"type": "string",
"source": "html",
"selector": ".wp-block-wsm-callout-box__message"
},
"variant": {
"type": "string",
"enum": ["info", "success", "warning"],
"default": "info"
}
},
"supports": {
"html": false,
"align": ["wide", "full"],
"spacing": {
"margin": true,
"padding": true
},
"typography": {
"fontSize": true
}
},
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css"
}
The important fields:
nameisnamespace/block-name. Use your own namespace, nevercore. It is stored in every post that uses the block, so choose it carefully and do not change it later.apiVersion: 3is the current block API. It enables the iframed editor canvas, which isolates block styles from admin styles.attributesdefine the block's data.source: "html"with aselectortells WordPress to read the value back out of the saved markup, so the text is not duplicated in the block comment. Attributes without a source, likevariant, are stored as JSON in the block comment delimiter.supportsopt the block into core features. Margin, padding, font size, and alignment controls appear automatically, and WordPress adds the right classes and inline styles for you.editorScript,editorStyle,stylepoint at build output. Thefile:prefix makes paths relative to theblock.jsonfile.
Because the namespace changed from the generated default, the CSS class WordPress generates is now wp-block-wsm-callout-box. The selectors above and the styles below use that class.
Registering the Block in JavaScript
index.js connects the metadata to your React components:
// src/callout-box/index.js
import { registerBlockType } from "@wordpress/blocks";
import "./style.scss";
import "./editor.scss";
import Edit from "./edit";
import save from "./save";
import metadata from "./block.json";
registerBlockType(metadata.name, {
edit: Edit,
save,
});
Everything descriptive, such as the title, icon, and attributes, comes from block.json. The JavaScript only supplies behavior.
Writing the Edit Component
The edit function renders the block inside the editor. It receives the block's attributes and a setAttributes function to update them:
// src/callout-box/edit.js
import { __ } from "@wordpress/i18n";
import {
useBlockProps,
RichText,
InspectorControls,
} from "@wordpress/block-editor";
import { PanelBody, SelectControl } from "@wordpress/components";
import "./editor.scss";
const VARIANTS = [
{ label: __("Info", "callout-box"), value: "info" },
{ label: __("Success", "callout-box"), value: "success" },
{ label: __("Warning", "callout-box"), value: "warning" },
];
export default function Edit({ attributes, setAttributes }) {
const { title, message, variant } = attributes;
const blockProps = useBlockProps({
className: `is-variant-${variant}`,
});
return (
<>
<InspectorControls>
<PanelBody title={__("Settings", "callout-box")}>
<SelectControl
label={__("Variant", "callout-box")}
value={variant}
options={VARIANTS}
onChange={(value) => setAttributes({ variant: value })}
__nextHasNoMarginBottom
__next40pxDefaultSize
/>
</PanelBody>
</InspectorControls>
<div {...blockProps}>
<RichText
tagName="p"
className="wp-block-wsm-callout-box__title"
value={title}
onChange={(value) => setAttributes({ title: value })}
placeholder={__("Callout title", "callout-box")}
allowedFormats={[]}
/>
<RichText
tagName="p"
className="wp-block-wsm-callout-box__message"
value={message}
onChange={(value) => setAttributes({ message: value })}
placeholder={__("Write the message…", "callout-box")}
allowedFormats={["core/bold", "core/italic", "core/link"]}
/>
</div>
</>
);
}
What each piece does:
useBlockProps()returns the props the editor needs on the block wrapper: the generated class name, data attributes, selection handling, and any styles fromsupports. Always spread it onto the outermost element. Pass extra classes through it rather than writing your ownclassNameon the wrapper.RichTextis the editable text field core blocks use.allowedFormatslimits the toolbar, so the title cannot be bold or linked, while the message can.InspectorControlsrenders its children in the block settings sidebar. Anything that is configuration rather than content belongs there.SelectControlcomes from@wordpress/components, the same component library the editor is built with. The two__nextprops opt into the current default sizing and spacing, and avoid deprecation notices in the console.
Writing the Save Function
The save function returns the static markup stored in the post. It must be a pure function of the attributes: same attributes, same markup, every time.
// src/callout-box/save.js
import { useBlockProps, RichText } from "@wordpress/block-editor";
export default function save({ attributes }) {
const { title, message, variant } = attributes;
const blockProps = useBlockProps.save({
className: `is-variant-${variant}`,
});
return (
<div {...blockProps}>
{title && (
<RichText.Content
tagName="p"
className="wp-block-wsm-callout-box__title"
value={title}
/>
)}
<RichText.Content
tagName="p"
className="wp-block-wsm-callout-box__message"
value={message}
/>
</div>
);
}
Note useBlockProps.save(), not useBlockProps(). The save version produces only the attributes that belong in saved markup, without editor-only props.
When an author saves the post, WordPress stores something like this in post_content:
<!-- Post content stored in the database -->
<!-- wp:wsm/callout-box {"variant":"warning"} -->
<div class="wp-block-wsm-callout-box is-variant-warning">
<p class="wp-block-wsm-callout-box__title">Back up first</p>
<p class="wp-block-wsm-callout-box__message">
Take a full backup before updating.
</p>
</div>
<!-- /wp:wsm/callout-box -->
The comment delimiter holds the block name and the non-sourced attribute variant. The title and message are read back from the HTML using the selectors in block.json.
Styling the Block
style.scss loads in both the editor and on the front end, so the block looks the same in both places:
// src/callout-box/style.scss
.wp-block-wsm-callout-box {
--callout-accent: #2563eb;
--callout-bg: #eff6ff;
border-left: 4px solid var(--callout-accent);
background: var(--callout-bg);
border-radius: 6px;
padding: 1rem 1.25rem;
&.is-variant-success {
--callout-accent: #16a34a;
--callout-bg: #f0fdf4;
}
&.is-variant-warning {
--callout-accent: #d97706;
--callout-bg: #fffbeb;
}
&__title {
font-weight: 700;
margin: 0 0 0.25rem;
}
&__message {
margin: 0;
}
}
Keep editor.scss for editor-only tweaks, such as a minimum height for an empty placeholder. WordPress only enqueues style-index.css on pages that actually contain the block, so you do not pay for its CSS elsewhere.
Hard-coded colors are fine for a first block. For a production block, consider adding "color": { "background": true, "text": true } to supports so editors pick from the theme's palette instead.
Making It a Dynamic Block with render.php
A static block stores its HTML once. That is ideal for content, but wrong for anything that must change after the post is saved, such as a list of recent posts, a price fetched from a store, or today's date. For these, use a dynamic block: the editor saves only the attributes, and PHP renders the markup on every page view.
Scaffold one with the dynamic variant:
# Terminal
npx @wordpress/create-block@latest last-updated --variant=dynamic
The difference is in src/last-updated/block.json, which gains a render field, and in the absence of a save.js:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "wsm/last-updated",
"title": "Last Updated",
"category": "text",
"icon": "update",
"textdomain": "last-updated",
"usesContext": ["postId"],
"attributes": {
"prefix": {
"type": "string",
"default": "Last updated on"
}
},
"supports": {
"html": false,
"typography": { "fontSize": true }
},
"editorScript": "file:./index.js",
"style": "file:./style-index.css",
"render": "file:./render.php"
}
render.php receives three variables: $attributes, $content (inner blocks, if any), and $block, the WP_Block instance, which carries context such as the current post ID:
<?php
// src/last-updated/render.php
$post_id = isset( $block->context['postId'] ) ? (int) $block->context['postId'] : get_the_ID();
if ( ! $post_id ) {
return;
}
$prefix = isset( $attributes['prefix'] ) ? $attributes['prefix'] : '';
$modified = get_the_modified_date( '', $post_id );
$iso = get_the_modified_date( 'c', $post_id );
?>
<p <?php echo get_block_wrapper_attributes(); ?>>
<?php echo esc_html( $prefix ); ?>
<time datetime="<?php echo esc_attr( $iso ); ?>"><?php echo esc_html( $modified ); ?></time>
</p>
get_block_wrapper_attributes() is the PHP counterpart of useBlockProps: it outputs the class name and any styles from supports. Every value from attributes or the database is escaped on output, because attributes are user-supplied data.
In the editor, render a preview with ServerSideRender, or a lightweight React mock-up if you want a faster editor:
// src/last-updated/edit.js
import { __ } from "@wordpress/i18n";
import { useBlockProps, InspectorControls } from "@wordpress/block-editor";
import { PanelBody, TextControl } from "@wordpress/components";
import ServerSideRender from "@wordpress/server-side-render";
import metadata from "./block.json";
export default function Edit({ attributes, setAttributes }) {
return (
<>
<InspectorControls>
<PanelBody title={__("Settings", "last-updated")}>
<TextControl
label={__("Prefix", "last-updated")}
value={attributes.prefix}
onChange={(prefix) => setAttributes({ prefix })}
__nextHasNoMarginBottom
__next40pxDefaultSize
/>
</PanelBody>
</InspectorControls>
<div {...useBlockProps()}>
<ServerSideRender block={metadata.name} attributes={attributes} />
</div>
</>
);
}
Install the dependency with npm install @wordpress/server-side-render. The build automatically copies render.php into build/ because the dynamic template's build script includes the --webpack-copy-php flag.
Static or Dynamic?
| Choose a static block when | Choose a dynamic block when |
|---|---|
| The output depends only on what the author typed | The output depends on data that changes after saving |
| You want content to survive if the plugin is deactivated | You want to change markup for every post without re-saving them |
| You want zero PHP cost per page view | You need PHP functions, queries, or user context |
A useful detail: if a static block's plugin is deactivated, its saved HTML still displays. A dynamic block's output disappears, because nothing is left to render it.
Building for Production
npm start produces unminified development builds. Before deploying:
# Terminal
npm run build
npm run plugin-zip
build minifies the assets and regenerates blocks-manifest.php. plugin-zip packages the plugin with only the files WordPress needs, excluding src and node_modules. Upload the ZIP through Dashboard > Plugins > Add New Plugin > Upload Plugin, or deploy the plugin folder without node_modules.
Commit src, package.json, and package-lock.json to version control. Whether to commit build depends on your deployment: commit it if the server cannot run Node, otherwise build in CI.
Common Problems and Fixes
- "This block contains unexpected or invalid content." The markup in the post no longer matches what
saveproduces. This happens whenever you changesaveafter posts already use the block. Add adeprecatedentry with the oldsavefunction and attributes so WordPress can migrate existing content, instead of editingsavein place. - The block does not appear in the inserter. The plugin is not active, the build folder is missing, or
block.jsonhas invalid JSON. Check the browser console and runnpm run buildagain. - Styles work in the editor but not on the front end. You put them in
editor.scss. Move shared styles tostyle.scss. - Changes do not show up.
npm startis not running, or the browser cached the old build. Restart the watcher and hard-reload the editor. - Attributes reset to empty after reload. The
selectorinblock.jsondoes not match the class insave. They must match exactly, including the namespace in the class name. - render.php output is not escaped. Treat every attribute as untrusted. Use
esc_html,esc_attr, andesc_urlon output, andwp_kses_postfor intentional HTML.
Custom Gutenberg Block FAQ
Basic React is enough. You need to understand components, props, and JSX. WordPress provides the hooks and components, such as useBlockProps, RichText, and InspectorControls, so you rarely write complex React logic for a typical block.
A plugin, in most cases. Blocks create content, and content should not disappear when the site changes themes. Keep presentation that is truly theme-specific in the theme, and put blocks in a small plugin that the theme can rely on.
API version 3 is the current version and is required for blocks to work correctly in the iframed editor canvas, which isolates editor styles. New blocks should always use version 3. Existing version 2 blocks usually only need the number changed, then a check that their styles still load in the editor.
Add a deprecation. Copy the old save function and attribute definitions into the deprecated array in your block registration. WordPress tries each deprecation when the saved markup does not match the current save output, and migrates the block to the new format when the post is next saved.
Yes. Put each block in its own folder under src, each with its own block.json. The build generates a single manifest, and wp_register_block_types_from_metadata_collection registers all of them at once.
It is possible with plain JavaScript and the wp global, but you lose JSX, imports, and automatic dependency handling. The create-block tooling is the supported path and saves far more time than it costs.
Conclusion
A custom Gutenberg block is the cleanest way to give editors a reusable, consistent component. The modern workflow is mostly declarative: block.json describes the block, its attributes, and its assets; edit.js gives authors a React interface with RichText fields and sidebar controls; and save.js or render.php decides what visitors see.
Start with npx @wordpress/create-block, choose a namespace you will keep forever, define attributes carefully, and decide early whether the block is static or dynamic. Then protect existing content with deprecations whenever you change saved markup, escape everything in PHP, and ship a production build. From there, the same pattern scales from a simple callout to a full library of branded blocks, and the Interactivity API adds front-end behavior when you need it.
Here are some useful references for going deeper on custom blocks:
- Block Editor Handbook: Tutorial: Build your first block — the official step-by-step walkthrough with a dynamic block.
- Block Editor Handbook: Metadata in block.json — every field block.json supports.
- Block Editor Handbook: Create Block package — scaffolding options, variants, and templates.
- Block Editor Handbook: Deprecation — how to change saved markup without breaking existing posts.
- Make WordPress Core: More efficient block type registration in 6.8 — the blocks manifest and collection registration.


