14+ years building on WordPress / Replies in under 5 hours
Page Builders & Theming 6 min read · Updated July 2026

How to Build a Custom Gutenberg Block from Scratch

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
How to Build a Custom Gutenberg Block from Scratch: A Step-by-Step Guide
TL;DR

Custom Gutenberg blocks are built as WordPress plugins using the `@wordpress/scripts` build toolchain. The modern stack (WordPress 5.8+) uses three key files: `block.json` for block metadata (name, category, attributes, script/style references), a PHP plugin file that calls `register_block_type(__DIR__ . '/build')` to read `block.json` automatically, and `src/index.js` with your `edit` and `save` React components. Two things that break blocks in current WordPress if you skip them: (1) `useBlockProps()` in `edit` and `useBlockProps.save()` in `save` — required since WordPress 5.6 for block validation; missing them causes a validation error in the editor. (2) `apiVersion: 3` and a valid `category` in `block.json` — the `"common"` category was removed in WordPress 6.0 and will silently break block placement. Valid categories in 2026: `text`, `media`, `design`, `widgets`, `theme`, `embed`. During development use `npm start` (watch mode). For production use `npm run build`. If you change `save()` after posts exist, handle it via deprecations or switch to a dynamic block with a PHP `render_callback`.

Custom Gutenberg blocks let you extend WordPress’s block editor with editor UI components tailored to your site’s exact needs — instead of reaching for a page builder plugin that adds weight you don’t need. They’re the right tool when you need a repeatable content pattern (a testimonial, a pricing card, a call-to-action) that the default block library doesn’t cover.

This guide uses the current WordPress block development stack: block.json for block metadata (the standard since WordPress 5.8), useBlockProps() for block wrapper attributes (required since WordPress 5.6), and @wordpress/scripts for the build toolchain. Code that skips these — like examples using category: 'common' (removed in WordPress 6.0) or no useBlockProps() — will produce validation errors in modern WordPress.

Prerequisites

  • Node.js 18+ and npm installed
  • A local WordPress development environment (Local, Lando, or wp-env)
  • Basic JavaScript (ES6+) and JSX/React familiarity
  • PHP knowledge for the plugin registration file

If you’d rather scaffold the full block boilerplate automatically rather than build from scratch, @wordpress/create-block does it in one command: npx @wordpress/create-block@latest my-block. This guide walks through the manual approach so you understand what each file does.

Step 1: Create the plugin folder and install dependencies

Gutenberg blocks live inside WordPress plugins (or themes). Create a plugin folder inside wp-content/plugins/:

mkdir wp-content/plugins/my-custom-block
cd wp-content/plugins/my-custom-block
npm init -y

Install @wordpress/scripts — the official WordPress build toolchain that wraps webpack, Babel, and the WordPress package aliases:

npm install --save-dev @wordpress/scripts

Add build commands to package.json:

{
  "scripts": {
    "build": "wp-scripts build",
    "start": "wp-scripts start"
  }
}

npm start runs in watch mode (rebuilds on save, useful during development). npm run build produces the production output in build/. The plugin setup for WordPress is similar to the general plugin scaffolding covered in the WordPress plugin with Node.js guide.

Step 2: Create block.json

block.json is the block’s metadata file — the current standard since WordPress 5.8. It replaces passing options directly to registerBlockType(). Create src/block.json:

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "my-plugin/call-to-action",
  "version": "1.0.0",
  "title": "Call to Action",
  "category": "text",
  "icon": "megaphone",
  "description": "A custom call-to-action block with editable heading and body text.",
  "attributes": {
    "heading": {
      "type": "string",
      "source": "html",
      "selector": "h2"
    },
    "body": {
      "type": "string",
      "source": "html",
      "selector": "p"
    }
  },
  "editorScript": "file:./index.js",
  "style": "file:./style-index.css",
  "editorStyle": "file:./index.css"
}

Key points:

  • apiVersion: 3 — current as of WordPress 6.3. Always specify this.
  • category: "text" — valid in 2026. The "common" category was removed in WordPress 6.0 and will silently fail (the block appears under a generic “uncategorized” group). Valid categories: text, media, design, widgets, theme, embed.
  • Attributes in block.json — defining attributes here (rather than in registerBlockType) is the modern approach. WordPress reads them at registration time.

Step 3: Register the block in PHP

Create the main plugin file my-custom-block.php:

<?php
/**
 * Plugin Name: My Custom Block
 * Description: A custom call-to-action Gutenberg block.
 * Version: 1.0.0
 * Requires at least: 6.3
 */

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

register_block_type( __DIR__ . '/build' ) reads block.json from the build/ directory and registers the block, scripts, and styles automatically. You don’t pass any options — block.json has everything WordPress needs.

Step 4: Write the block’s JavaScript (edit and save)

Create src/index.js:

import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps, RichText } from '@wordpress/block-editor';
import metadata from './block.json';

registerBlockType( metadata.name, {
    edit: function Edit( { attributes, setAttributes } ) {
        const blockProps = useBlockProps( {
            className: 'my-cta-block',
        } );

        return (
            <div { ...blockProps }>
                <RichText
                    tagName="h2"
                    value={ attributes.heading }
                    onChange={ ( heading ) => setAttributes( { heading } ) }
                    placeholder="Enter heading..."
                />
                <RichText
                    tagName="p"
                    value={ attributes.body }
                    onChange={ ( body ) => setAttributes( { body } ) }
                    placeholder="Enter body text..."
                />
            </div>
        );
    },

    save: function Save( { attributes } ) {
        const blockProps = useBlockProps.save( {
            className: 'my-cta-block',
        } );

        return (
            <div { ...blockProps }>
                <RichText.Content tagName="h2" value={ attributes.heading } />
                <RichText.Content tagName="p" value={ attributes.body } />
            </div>
        );
    },
} );

useBlockProps() in the edit function and useBlockProps.save() in the save function are both required. They inject the block’s wrapper attributes (class, data-*, and any attributes WordPress adds for block identification). Blocks without useBlockProps produce a block validation error in the editor — WordPress compares the saved markup against what save() would generate now, and the mismatch triggers the error.

Notice import metadata from './block.json': passing metadata.name to registerBlockType instead of a hardcoded string means the block name stays in sync with block.json automatically. Using React in WordPress blocks is the same JSX/hook model as any other React app — the WordPress theme with React guide covers the broader React-in-WordPress environment in detail.

Step 5: Add InspectorControls for block settings

InspectorControls lets you add settings to the block’s sidebar panel in the editor — toggle switches, colour pickers, text inputs, and other controls that affect the block’s appearance or behaviour without touching the content canvas.

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

// Inside your edit function:
edit: function Edit( { attributes, setAttributes } ) {
    const blockProps = useBlockProps();
    const { showButton } = attributes;

    return (
        <>
            <InspectorControls>
                <PanelBody title="Block Settings">
                    <ToggleControl
                        label="Show button"
                        checked={ showButton }
                        onChange={ ( val ) => setAttributes( { showButton: val } ) }
                    />
                </PanelBody>
            </InspectorControls>
            <div { ...blockProps }>
                { /* block canvas content here */ }
            </div>
        </>
    );
}

Add the corresponding showButton attribute to block.json: "showButton": { "type": "boolean", "default": true }.

Step 6: Build and test the block

Start the development build watcher:

npm start

This compiles src/index.jsbuild/index.js and watches for changes. Activate your plugin in the WordPress admin (Plugins → Activate), then open a post or page in the block editor and search for your block name. If the block doesn’t appear, check the browser console for JavaScript errors and the PHP error log for registration failures.

When you’re ready to ship:

npm run build

This produces minified production output in build/. Only the build/ directory (and the plugin PHP file) need to be on the server — src/, node_modules/, and package.json stay off production.

Block validation errors: what they mean and how to fix them

If you edit the save() function after a post has been saved with the old version, the editor will show a block validation error — the stored HTML no longer matches what save() produces now. Three ways to handle this:

  • Deprecations — the correct approach for blocks in production. Define the old save() as a deprecated version so WordPress can still parse existing posts using the old markup. See the Block Deprecation docs.
  • Dynamic blocks — move rendering to PHP via a render_callback in block.json, so the save() function returns null and there’s no stored HTML to validate against.
  • During development only — delete the blocks from existing posts and re-insert them after changing save().

Custom blocks pair naturally with Full Site Editing — once your block is working, it can be used in block templates and template parts across a block theme. The WordPress Full Site Editing guide covers the block theme layer. For reusable editor patterns built from multiple blocks (rather than custom block types), see block patterns vs InnerBlocks — they solve a different but related problem. The theme context for custom blocks is covered in the high-performance block themes guide, and for the full theme-from-scratch approach, see how to build a custom WordPress theme.

Frequently asked questions

block.json is the current standard for block metadata since WordPress 5.8. It defines the block's name, title, category, icon, attributes, and script/style file references in a single JSON file. When you call `register_block_type(__DIR__ . '/build')` in PHP, WordPress reads block.json automatically — no options object needed. The older pattern of passing all metadata directly to `registerBlockType('my-block/name', { title, category, icon, attributes, edit, save })` still works but is now the legacy approach. Using block.json enables features like block.json schema validation, automatic asset enqueueing, and compatibility with newer block APIs that require it.

Block validation errors happen when the HTML stored in the database no longer matches what the block's `save()` function would produce now. Two common causes: (1) You changed the `save()` function after posts were already saved with the old version. Fix this with block deprecations — define the old `save()` as a deprecated version so WordPress can still parse existing posts. (2) You forgot `useBlockProps.save()` in your `save()` function — this causes a class/attribute mismatch between what WordPress expects and what the function outputs. Both `useBlockProps()` in `edit` and `useBlockProps.save()` in `save` are required since WordPress 5.6.

The valid block categories in WordPress 2026 are: `text`, `media`, `design`, `widgets`, `theme`, and `embed`. The `common` category was removed in WordPress 6.0 (May 2022). Using `category: 'common'` in your block.json or registerBlockType options won't throw an error, but the block will be placed in an unintended category or may not appear under the expected group in the block inserter. Use `text` for content blocks like headings, paragraphs, and rich text; `design` for layout and decorative blocks; `media` for image, video, and audio blocks.

`useBlockProps()` is a React hook from `@wordpress/block-editor` that returns the block wrapper's required HTML attributes — including the `class` names WordPress needs for block identification, focus management, and drag-and-drop in the editor. It's been required since WordPress 5.6. In the `edit` function, call `const blockProps = useBlockProps()` and spread the result onto your wrapper element: `

`. In the `save` function, use `useBlockProps.save()` instead. Blocks that omit `useBlockProps` produce a validation error because the saved HTML won't include the expected block wrapper classes, causing a mismatch when WordPress re-parses the block.

`npm start` runs `wp-scripts start` in watch mode: it compiles your block's JavaScript once, then watches the `src/` directory for file changes and recompiles automatically whenever you save. This is the development workflow — keep it running in your terminal while you edit `src/index.js`. `npm run build` runs `wp-scripts build` once and produces minified, production-optimised output in the `build/` directory. Use this before deploying. Only the `build/` directory and the plugin's PHP file need to go to the server — `src/`, `node_modules/`, and `package.json` stay off production.

Use `InspectorControls` from `@wordpress/block-editor` and components from `@wordpress/components` (like `PanelBody`, `ToggleControl`, `TextControl`, `SelectControl`). Import them in your `index.js`: `import { InspectorControls, useBlockProps } from '@wordpress/block-editor'`. In your `edit` function, return a React fragment containing both the `InspectorControls` component (which renders in the sidebar) and your block content (which renders on the canvas). Wrap the sidebar controls in a `` for the standard collapsible section. Add corresponding attributes to `block.json` so the values are stored with the block.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →