Written by 5:39 pm For Developers, Gutenberg Blocks Views: 0

Custom Gutenberg Blocks: The Essential React Guide 2026

Custom Gutenberg block development with React and @wordpress/create-block step by step tutorial

Building a custom Gutenberg block from scratch sounds intimidating at first. You’re suddenly dealing with React, webpack, PHP registration, and a JavaScript build pipeline all at once. But with the @wordpress/create-block scaffold tool and a clear path to follow, you can have a working block running inside your WordPress site within an hour. This guide walks you through every step: scaffolding, editing JSX, registering the block in PHP, wiring up attributes and InspectorControls, building, and deploying as a real plugin folder.

Why Build a Custom Block Instead of Using a Plugin?

Third-party block plugins cover a lot of ground. But they always come with trade-offs: extra JavaScript on every page, settings you’ll never use, and a dependency on someone else’s release schedule. When you write your own block, you control everything. The output is exactly what your project needs, the CSS only loads where required, and you can update the block any time without waiting for a plugin author.

Custom blocks are also the cleanest way to enforce editorial consistency. Need a CTA box that always uses your brand colors and a specific button label pattern? Build a block with those constraints baked in. Your editors can’t accidentally break the design because the block doesn’t expose the settings that would let them.

Learning block development also unlocks a big chunk of the modern WordPress ecosystem. Themes built on Full Site Editing, patterns, template parts, and block-based widgets all use the same foundation. Once you understand how blocks work, that whole layer of WordPress opens up.

What You Need Before You Start

Before running any commands, make sure your local environment has these ready:

  • Node.js 20+ and npm: check with node -v and npm -v. The @wordpress/create-block tool requires a recent Node version.
  • A local WordPress site: Local by Flywheel, wp-env, DevKinsta, or any LAMP stack works fine. You need a running site where you can activate plugins.
  • Basic familiarity with React: You don’t need to be a React expert, but understanding JSX syntax and props will help. If you’ve built a simple React component before, you’re ready.
  • A code editor: VS Code is popular and has good PHP and JavaScript support out of the box.

You don’t need to set up webpack, Babel, or any build tooling manually. The scaffold handles all of that for you.

Step 1: Scaffold the Block Plugin With @wordpress/create-block

Open your terminal, navigate to your WordPress plugins directory, and run:

cd /path/to/wordpress/wp-content/plugins
npx @wordpress/create-block my-first-block --template @wordpress/create-block-tutorial-template

The tool will ask you a few questions. You can accept the defaults or fill in your own values for the block title, description, and namespace. When it finishes, you’ll have a folder called my-first-block with this structure:

my-first-block/
├── build/
├── node_modules/
├── src/
│   ├── block.json
│   ├── edit.js
│   ├── index.js
│   ├── save.js
│   ├── style.scss
│   └── editor.scss
├── my-first-block.php
└── package.json

Every file you need is already wired together. The PHP file handles plugin registration. src/index.js registers the block on the JavaScript side. edit.js renders what the editor sees. save.js defines the static HTML that gets saved to the database.

Install dependencies and start the build watcher:

cd my-first-block
npm install
npm start

Leave this terminal running. Every time you save a file in src/, webpack rebuilds the assets in build/.

Step 2: Understand the File Map Before Touching Anything

Before you change a line of code, spend five minutes reading each file. The scaffold generates working code, not boilerplate placeholders.

block.json is the manifest file. It declares the block’s name, title, category, attributes, and where to find the editor and frontend scripts. This is the single source of truth for your block’s metadata:

{
  "apiVersion": 3,
  "name": "create-block/my-first-block",
  "version": "0.1.0",
  "title": "My First Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "supports": {
    "html": false
  },
  "textdomain": "my-first-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css"
}

index.js calls registerBlockType and imports the edit and save functions. You rarely need to modify this file directly.

edit.js is where you’ll spend most of your time. It defines the React component that renders inside the block editor. It receives a props object that includes attributes and setAttributes.

save.js defines the static output. When a post is saved, WordPress calls this function and stores the resulting HTML. This is different from React rendering in the editor: once saved, it’s plain HTML in the database.

my-first-block.php calls register_block_type and enqueues the block assets. With modern block.json-based registration, the PHP file is quite short.

Step 3: Edit the PHP Registration File

Open my-first-block.php. The scaffold already generates a working registration function, but let’s look at what it does and make sure you understand each part:

<?php
/**
 * Plugin Name:       My First Block
 * Description:       A custom Gutenberg block built with @wordpress/create-block.
 * Version:           0.1.0
 * Author:            Your Name
 * License:           GPL-2.0-or-later
 * Text Domain:       my-first-block
 *
 * @package           CreateBlock
 */

function create_block_my_first_block_block_init() {
    register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'create_block_my_first_block_block_init' );

The key line is register_block_type( __DIR__ . '/build' ). Passing the build directory path tells WordPress to read the block.json file from there and automatically enqueue the correct scripts and styles for both the editor and the frontend. You don’t need to call wp_enqueue_script manually because block.json declares the asset handles.

Activate the plugin in your WordPress admin under Plugins. Open any post or page in the block editor, click the “+” button, and search for “My First Block.” If the block appears, the PHP registration is working correctly.

Step 4: Write Your Edit Component in JSX

The edit component is what editors see and interact with inside the block editor. Open src/edit.js. The scaffold generates a simple version. Let’s replace it with something more useful: a block that lets editors set a custom heading and paragraph.

import { useBlockProps } from '@wordpress/block-editor';

export default function Edit( { attributes, setAttributes } ) {
    const blockProps = useBlockProps();

    return (
        <div { ...blockProps }>
            <input
                type="text"
                value={ attributes.heading || '' }
                placeholder="Enter heading..."
                onChange={ ( e ) => setAttributes( { heading: e.target.value } ) }
                style={ { display: 'block', width: '100%', marginBottom: '8px', fontSize: '24px', fontWeight: 'bold' } }
            />
            <textarea
                value={ attributes.body || '' }
                placeholder="Enter body text..."
                onChange={ ( e ) => setAttributes( { body: e.target.value } ) }
                style={ { display: 'block', width: '100%', minHeight: '80px' } }
            />
        </div>
    );
}

The useBlockProps hook is required. It attaches the necessary class names, data attributes, and event handlers that WordPress needs to recognize and manage the block in the editor. Always spread blockProps onto the root element.

The setAttributes function works like a partial state update. You only pass the keys you want to change, and WordPress merges them into the existing attributes object.

Step 5: Define Attributes in block.json

Attributes are the data layer of your block. They define what gets saved and restored. Open src/block.json and add an attributes section:

{
  "apiVersion": 3,
  "name": "create-block/my-first-block",
  "version": "0.1.0",
  "title": "My First Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "supports": {
    "html": false
  },
  "textdomain": "my-first-block",
  "attributes": {
    "heading": {
      "type": "string",
      "default": ""
    },
    "body": {
      "type": "string",
      "default": ""
    },
    "textColor": {
      "type": "string",
      "default": "#333333"
    }
  },
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css"
}

Each attribute has a type and optionally a default value. WordPress validates attributes against this schema when blocks are parsed. If a saved post has an attribute that doesn’t match the schema, WordPress falls back to the default. The types available are string, number, boolean, array, and object.

Step 6: Add InspectorControls for the Settings Sidebar

InspectorControls is the component that renders inside the block settings panel on the right side of the editor. This is where you put configuration options that don’t belong inline with the content.

Update src/edit.js to add a color picker in the sidebar:

import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, ColorPicker } from '@wordpress/components';

export default function Edit( { attributes, setAttributes } ) {
    const blockProps = useBlockProps();
    const { heading, body, textColor } = attributes;

    return (
        <>
            <InspectorControls>
                <PanelBody title="Text Color" initialOpen={ true }>
                    <ColorPicker
                        color={ textColor }
                        onChange={ ( value ) => setAttributes( { textColor: value } ) }
                        enableAlpha={ false }
                    />
                </PanelBody>
            </InspectorControls>

            <div { ...blockProps } style={ { color: textColor } }>
                <input
                    type="text"
                    value={ heading || '' }
                    placeholder="Enter heading..."
                    onChange={ ( e ) => setAttributes( { heading: e.target.value } ) }
                    style={ { display: 'block', width: '100%', marginBottom: '8px', fontSize: '24px', fontWeight: 'bold', color: textColor } }
                />
                <textarea
                    value={ body || '' }
                    placeholder="Enter body text..."
                    onChange={ ( e ) => setAttributes( { body: e.target.value } ) }
                    style={ { display: 'block', width: '100%', minHeight: '80px', color: textColor } }
                />
            </div>
        </>
    );
}

The fragment wrapper (<>...</>) is necessary because React components must have a single root element, but InspectorControls renders inside a different DOM container (the sidebar), not inside the block’s root div. The fragment lets you return both without wrapping them in an extra div that would appear in the editor content area.

PanelBody creates the collapsible panel section in the sidebar. ColorPicker is a full-featured color selection component from @wordpress/components. Both are pre-styled to match the WordPress editor UI.

Step 7: Write the Save Function

The save function defines the static HTML that WordPress stores in the database when a post is published or saved. Open src/save.js:

import { useBlockProps } from '@wordpress/block-editor';

export default function save( { attributes } ) {
    const blockProps = useBlockProps.save();
    const { heading, body, textColor } = attributes;

    return (
        <div { ...blockProps } style={ { color: textColor } }>
            { heading && <h2 style={ { color: textColor } }>{ heading }</h2> }
            { body && <p>{ body }</p> }
        </div>
    );
}

A few important notes about the save function:

  • Use useBlockProps.save() (not the plain hook) in the save function. This is a static version that doesn’t attach editor event listeners.
  • The save output must be deterministic. Given the same attributes, it must always produce the same HTML. If it doesn’t, WordPress will show a “Block Validation Error” when the post loads.
  • If you change the save function after a block is already in use, you’ll need to handle block deprecations or update existing posts. Plan your save structure carefully before publishing to production.

Step 8: Build the Assets for Production

While you’re developing, npm start runs webpack in watch mode with source maps and without minification. For production deployment, run:

npm run build

This creates minified, optimized assets in the build/ folder. The build output includes:

  • build/index.js: the editor JavaScript bundle
  • build/index.asset.php: a PHP file with the script dependencies array and a version hash for cache busting
  • build/style-index.css: the frontend CSS
  • build/index.css: the editor-only CSS

The index.asset.php file is what makes register_block_type so clean. It automatically reads the dependencies (like wp-blocks, wp-element, wp-block-editor) from that file rather than requiring you to list them manually in PHP.

You can also run npm run lint:js and npm run lint:css to check for code style issues before building.

Step 9: Deploy as a Real Plugin Folder

For deployment, you want a clean folder that contains only the files WordPress needs at runtime. You do not need to ship node_modules, src/, or build configuration files to a live server.

The minimum files for a working block plugin are:

my-first-block/
├── build/
│   ├── block.json
│   ├── index.js
│   ├── index.asset.php
│   ├── index.css
│   └── style-index.css
└── my-first-block.php

Copy this reduced set to your production server’s wp-content/plugins/ directory. You can zip it and upload via the WordPress admin under Plugins > Add New > Upload Plugin, or transfer it via SFTP.

For version-controlled projects, add node_modules/ to your .gitignore but commit the build/ folder. This way, deploying via git pull gives you everything the server needs without requiring npm on the server.

If you’re building for distribution on WordPress.org or selling commercially, follow the developer guide for building and packaging WordPress plugins to ensure your plugin meets repository standards.

Step 10: Add Block Styles and Scope CSS Correctly

The scaffold generates two CSS files: style.scss for both the editor and frontend, and editor.scss for editor-only styles.

Block CSS should be scoped to your block’s root class. WordPress automatically generates a unique class for each block based on its namespace and name. For a block named create-block/my-first-block, the generated class is wp-block-create-block-my-first-block. Use this as the root selector in your SCSS:

// style.scss
.wp-block-create-block-my-first-block {
    padding: 1.5rem;
    border: 2px solid #e0e0e0;
    border-radius: 4px;
    background: #fafafa;

    h2 {
        margin-top: 0;
        margin-bottom: 0.5rem;
    }

    p {
        margin: 0;
        line-height: 1.6;
    }
}

Scoping CSS to your block class prevents styles from leaking into other blocks or the surrounding page. This is especially important if your block is loaded on pages alongside other blocks from different sources.

For responsive styling, add a @media block inside your selector:

.wp-block-create-block-my-first-block {
    padding: 1.5rem;

    @media (max-width: 640px) {
        padding: 1rem;
        font-size: 15px;
    }
}

Step 11: Test the Block End-to-End

After building, reload your WordPress editor. Here’s a checklist to confirm everything is working:

  • The block appears in the block inserter under its category.
  • Typing in the heading and body fields updates the block preview in real time.
  • Opening the Inspector Controls panel (the gear icon in the toolbar) shows the color picker.
  • Changing the color updates the text color immediately in the editor.
  • Saving the post and viewing it on the frontend shows the correct HTML output with the correct styles applied.
  • The frontend output contains the correct class (wp-block-create-block-my-first-block) and the inline color style from the attribute.

If the block shows a “Block Validation Error” on reload, compare your edit component’s output with your save function’s output. They must produce structurally identical HTML. A common cause is inline styles in the edit component that don’t match the save function exactly.

To debug validation errors, open the browser console while the editor is loading. WordPress logs the expected HTML (from the save function) and the parsed HTML (from the stored database content) so you can see exactly where they differ.

Common Mistakes to Avoid

Based on where developers typically get stuck on their first block:

Forgetting to rebuild after changing block.json. The build watcher watches src/ files, but you need to restart npm start after changes to block.json attributes or metadata for the changes to take effect in the editor.

Mismatched save output. Any change to the save function that alters the HTML structure of an already-saved block will cause a validation error. If your block is already in use, you need to add a deprecation entry in your index.js to handle the old save format.

Using browser-only APIs in save.js. The save function runs server-side in some contexts (block rendering for the REST API, for example). Avoid using window, document, or other browser globals in save. Keep it as a pure render of attributes to HTML.

Not using useBlockProps. Omitting this hook means WordPress can’t properly select, move, or delete the block in the editor. Always spread blockProps onto the root element of both your edit and save functions.

Putting UI-only code in save.js. Components like InspectorControls, BlockControls, and RichText (in edit mode) belong only in edit.js. The save function should only contain the static output HTML.

Understanding Block Deprecations

Block deprecations are one of the more confusing aspects of block development when you first run into them. Here’s the scenario: you build a block, publish several posts using it, then decide to change the HTML structure in your save function. When those posts load, WordPress compares the stored HTML with what your new save function would output. If they don’t match, it shows a “Block Validation Error.”

Deprecations are the solution. A deprecation is a record of a previous version of your block’s save function. WordPress walks through your deprecations in order, newest first, until it finds one that matches the stored HTML. If it does, it migrates the block to the current format transparently.

Add deprecations to your src/index.js like this:

import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
import save from './save';
import metadata from './block.json';

registerBlockType( metadata, {
    edit: Edit,
    save,
    deprecated: [
        {
            // Previous version of save (before you added textColor)
            attributes: {
                heading: { type: 'string', default: '' },
                body: { type: 'string', default: '' },
            },
            save( { attributes } ) {
                const { heading, body } = attributes;
                return (
                    <div className="wp-block-create-block-my-first-block">
                        { heading && <h2>{ heading }</h2> }
                        { body && <p>{ body }</p> }
                    </div>
                );
            },
            migrate( attributes ) {
                return {
                    ...attributes,
                    textColor: '#333333',
                };
            },
        },
    ],
} );

The migrate function runs when WordPress finds a match in your deprecated list. It transforms the old attributes into the new format, and WordPress re-saves the block with the updated structure. The post saves cleanly the next time the editor is opened and the user saves.

You don’t need deprecations if you haven’t published any posts with the block yet. But the moment the block goes live on a production site, treat the save function as a contract you can’t break without a migration path.

Adding RichText for Formatted Content

The plain input and textarea elements in the edit component work for simple text, but they don’t let editors apply bold, italic, links, or other formatting. The RichText component solves this. It renders a content-editable element with WordPress’s built-in formatting toolbar.

Here’s the updated edit component using RichText:

import { useBlockProps, InspectorControls, RichText } from '@wordpress/block-editor';
import { PanelBody, ColorPicker } from '@wordpress/components';

export default function Edit( { attributes, setAttributes } ) {
    const blockProps = useBlockProps();
    const { heading, body, textColor } = attributes;

    return (
        <>
            <InspectorControls>
                <PanelBody title="Text Color" initialOpen={ true }>
                    <ColorPicker
                        color={ textColor }
                        onChange={ ( value ) => setAttributes( { textColor: value } ) }
                        enableAlpha={ false }
                    />
                </PanelBody>
            </InspectorControls>

            <div { ...blockProps }>
                <RichText
                    tagName="h2"
                    value={ heading }
                    onChange={ ( value ) => setAttributes( { heading: value } ) }
                    placeholder="Enter heading..."
                    style={ { color: textColor } }
                />
                <RichText
                    tagName="p"
                    value={ body }
                    onChange={ ( value ) => setAttributes( { body: value } ) }
                    placeholder="Enter body text..."
                    style={ { color: textColor } }
                />
            </div>
        </>
    );
}

And the corresponding save function for RichText output:

import { useBlockProps, RichText } from '@wordpress/block-editor';

export default function save( { attributes } ) {
    const blockProps = useBlockProps.save();
    const { heading, body, textColor } = attributes;

    return (
        <div { ...blockProps }>
            { heading && (
                <RichText.Content
                    tagName="h2"
                    value={ heading }
                    style={ { color: textColor } }
                />
            ) }
            { body && (
                <RichText.Content
                    tagName="p"
                    value={ body }
                    style={ { color: textColor } }
                />
            ) }
        </div>
    );
}

Notice the save function uses RichText.Content rather than RichText. The Content variant renders the saved HTML value as static content rather than an interactive editor. This is the correct pattern for saving RichText values.

The value attribute for RichText should be typed as string in block.json. WordPress stores the formatted HTML string (with <strong>, <em>, and link tags intact) as the attribute value.

Where to Go From Here

Once your first block is working, there are several natural next steps depending on where you want to take things.

If you want richer text editing inside the block, replace your plain textarea with the RichText component from @wordpress/block-editor. It gives you a content-editable area with formatting controls built in.

If you want to let editors pick images or media, use the MediaUpload component. It opens the WordPress media library and returns a media object with the URL and ID.

If you need dynamic content (data from a custom post type, a remote API, or anything that should update without re-saving the post), switch to a dynamic block using the WordPress REST API pattern. Dynamic blocks render via a PHP callback registered in register_block_type, rather than using a save function.

For blocks that need to exist as part of a plugin with settings pages, shortcodes, or custom post types, the architecture scales up naturally from what you’ve built here. The block is just one component in a larger plugin structure.

If you want to understand the broader block development ecosystem, exploring the @wordpress/create-block templates for different block types (interactive blocks using the Interactivity API, meta blocks, dynamic blocks) is a solid next move. Each template generates a different src/ structure optimized for that block pattern.

The foundation you’ve built here, a registered block type with attributes, an edit component with InspectorControls, a save function, and a production build, covers 90% of what most custom blocks need.

Visited 1 times, 1 visit(s) today

Last modified: May 8, 2026

Close