Type something to search...
How to Build a Custom Gutenberg Block with block.json and React?

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
FileRuns wherePurpose
block.jsonPHP and JSSingle source of truth: name, attributes, supports, assets
index.jsEditorCalls registerBlockType with the edit and save functions
edit.jsEditorWhat the author sees and interacts with
save.jsEditor, then storedStatic HTML written into post_content
style.scssEditor and front endShared visual styles
editor.scssEditor onlyEditor-specific adjustments
callout-box.phpServerRegisters 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:

  • name is namespace/block-name. Use your own namespace, never core. It is stored in every post that uses the block, so choose it carefully and do not change it later.
  • apiVersion: 3 is the current block API. It enables the iframed editor canvas, which isolates block styles from admin styles.
  • attributes define the block's data. source: "html" with a selector tells 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, like variant, are stored as JSON in the block comment delimiter.
  • supports opt 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, style point at build output. The file: prefix makes paths relative to the block.json file.

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 from supports. Always spread it onto the outermost element. Pass extra classes through it rather than writing your own className on the wrapper.
  • RichText is the editable text field core blocks use. allowedFormats limits the toolbar, so the title cannot be bold or linked, while the message can.
  • InspectorControls renders its children in the block settings sidebar. Anything that is configuration rather than content belongs there.
  • SelectControl comes from @wordpress/components, the same component library the editor is built with. The two __next props 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 whenChoose a dynamic block when
The output depends only on what the author typedThe output depends on data that changes after saving
You want content to survive if the plugin is deactivatedYou want to change markup for every post without re-saving them
You want zero PHP cost per page viewYou 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 save produces. This happens whenever you change save after posts already use the block. Add a deprecated entry with the old save function and attributes so WordPress can migrate existing content, instead of editing save in place.
  • The block does not appear in the inserter. The plugin is not active, the build folder is missing, or block.json has invalid JSON. Check the browser console and run npm run build again.
  • Styles work in the editor but not on the front end. You put them in editor.scss. Move shared styles to style.scss.
  • Changes do not show up. npm start is not running, or the browser cached the old build. Restart the watcher and hard-reload the editor.
  • Attributes reset to empty after reload. The selector in block.json does not match the class in save. 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, and esc_url on output, and wp_kses_post for 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:

  1. Block Editor Handbook: Tutorial: Build your first block — the official step-by-step walkthrough with a dynamic block.
  2. Block Editor Handbook: Metadata in block.json — every field block.json supports.
  3. Block Editor Handbook: Create Block package — scaffolding options, variants, and templates.
  4. Block Editor Handbook: Deprecation — how to change saved markup without breaking existing posts.
  5. Make WordPress Core: More efficient block type registration in 6.8 — the blocks manifest and collection registration.
Tags :
Share :

Related Posts

WordPress optimization with specific recommended approach

WordPress optimization with specific recommended approach

Whether you run a high traffic WordPress installation or a small blog on a low cost shared host, you should optimize WordPress and your server to run

Continue Reading
Creating and Customizing WordPress Child Themes

Creating and Customizing WordPress Child Themes

Creating a child theme in WordPress is a best practice for making modifications to a theme. By using a child theme, you can update the parent theme w

Continue Reading
Understanding the Distinction Categories vs. Tags in WordPress

Understanding the Distinction Categories vs. Tags in WordPress

WordPress, a powerful content management system, offers a plethora of features to organize content effectively. Among these features, categories and

Continue Reading