MaxtDesign

Custom Gutenberg Blocks: A Complete Guide

Build custom Gutenberg blocks with React and the WordPress block API — block.json, registration, attributes, save vs render, dynamic blocks, and the modern @wordpress/scripts toolchain.

15 min readGutenberg,block editor,block.json,React,WordPress,plugin development
M
MaxtDesign
Engineering

Gutenberg has been WordPress's editor for seven years now, and the block API has stabilized into something genuinely pleasant to build for. The on-ramp has improved dramatically — @wordpress/create-blockscaffolds a working block in 30 seconds. The hard parts moved up the stack: state design, server-side rendering decisions, and the editor-vs-frontend parity problem. This is the working developer's guide to building custom blocks that don't embarrass you in production.

What a block actually is

A block has two halves: an editor representation (a React component that renders inside the block editor) and a save representation (HTML that gets serialized into the post content). For static blocks, the save half is declarative HTML. For dynamic blocks, the save half is empty and a PHP callback renders the final HTML at request time.

Pick the right shape for your block:

  • Static blocks— for content that doesn't change after publish. Quote blocks, callouts, custom layouts. Faster to render (no PHP), but a schema change requires deprecations or manual migration.
  • Dynamic blocks — for content driven by queries (latest posts, related products, user-specific content). Always rendered fresh, no deprecation worries. Trade-off: server cost on every render.

When in doubt, dynamic. Static blocks lock you into HTML schemas that get painful to evolve.

The modern toolchain — block.json + @wordpress/scripts

As of WordPress 5.8 (and definitively in 2026), block registration is driven by block.json. This is the metadata file that tells WordPress everything it needs to know — name, attributes, supports, render callback, asset paths. Use it. Avoid the older purely-JavaScript registration unless you're working on a 2018-vintage codebase.

Scaffold a new block with the official tool:

npx @wordpress/create-block@latest mxt-callout

That gives you a working block plugin with:

  • block.json as the source of truth
  • edit.js + save.js for the React halves
  • index.js registering the block
  • style.scss + editor.scss for stylesheets
  • A pre-configured webpack build via @wordpress/scripts
  • npm run start for dev with HMR; npm run build for prod

The anatomy of block.json

A real block.json looks like this:

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "mxt/callout",
  "version": "1.0.0",
  "title": "Callout",
  "category": "design",
  "icon": "megaphone",
  "description": "A bordered callout for emphasis blocks.",
  "keywords": ["callout","note","emphasis"],
  "textdomain": "mxt-callout",
  "attributes": {
    "tone": {
      "type": "string",
      "enum": ["info","warning","danger","success"],
      "default": "info"
    },
    "title": {
      "type": "string",
      "source": "html",
      "selector": "h3"
    },
    "body": {
      "type": "string",
      "source": "html",
      "selector": "div.callout-body"
    }
  },
  "supports": {
    "anchor": true,
    "html": false,
    "spacing": { "padding": true, "margin": true },
    "typography": { "fontSize": true }
  },
  "editorScript": "file:./index.js",
  "editorStyle":  "file:./editor.css",
  "style":        "file:./style.css"
}

Pieces worth calling out:

  • apiVersion: 3 — the current block API. Use 3 for any new block; older versions still work but lose features.
  • attributes — the data model. Each attribute has a type and (for HTML-sourced ones) a selector telling WordPress how to extract it from saved HTML.
  • supports— opts you into core editor features (color, spacing, typography, anchor) without writing controls yourself. Use these. They're free, consistent, and future-proof.

The Edit component

The Edit component is a React component receiving attributes, setAttributes, and context. Build it with WordPress's component library (@wordpress/components) for visual consistency with core blocks.

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 { tone, title, body } = attributes;
    const blockProps = useBlockProps({
        className: `mxt-callout mxt-callout--${tone}`,
    });

    return (
        <>
            <InspectorControls>
                <PanelBody title={ __( 'Callout', 'mxt-callout' ) }>
                    <SelectControl
                        label={ __( 'Tone', 'mxt-callout' ) }
                        value={ tone }
                        options={ [
                            { value: 'info',    label: 'Info' },
                            { value: 'warning', label: 'Warning' },
                            { value: 'danger',  label: 'Danger' },
                            { value: 'success', label: 'Success' },
                        ] }
                        onChange={ ( v ) => setAttributes({ tone: v }) }
                    />
                </PanelBody>
            </InspectorControls>
            <div { ...blockProps }>
                <RichText
                    tagName="h3"
                    value={ title }
                    onChange={ ( v ) => setAttributes({ title: v }) }
                    placeholder={ __( 'Headline...', 'mxt-callout' ) }
                />
                <RichText
                    tagName="div"
                    className="callout-body"
                    value={ body }
                    onChange={ ( v ) => setAttributes({ body: v }) }
                    placeholder={ __( 'Body...', 'mxt-callout' ) }
                />
            </div>
        </>
    );
}

Three patterns to internalize:

  • useBlockProps() wraps the outer element with the props the editor needs (anchors, alignment, custom classes). Always use it.
  • RichText is for editable text — it gives you the toolbar (bold, italic, links) for free.
  • InspectorControls renders panels in the sidebar. Use it for settings; use the block toolbar for inline-relevant controls.

The Save component (for static blocks)

For static blocks, Save returns the HTML that gets serialized into the post content:

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

export default function save({ attributes }) {
    const { tone, title, body } = attributes;
    const blockProps = useBlockProps.save({
        className: `mxt-callout mxt-callout--${tone}`,
    });

    return (
        <div { ...blockProps }>
            <RichText.Content tagName="h3" value={ title } />
            <RichText.Content tagName="div" className="callout-body" value={ body } />
        </div>
    );
}

Save uses RichText.Content instead of RichText— it's the read-only static renderer. Mismatching Edit and Save HTML is the single most common source of "block validation" errors. If WordPress complains about a corrupted block on a saved post, look at the HTML diff between Edit-rendered and Save-rendered output.

Dynamic blocks — the PHP render callback

For dynamic content, set render to a PHP file in block.json:

{
  "render": "file:./render.php"
}

And implement render.php:

<?php
$tone  = $attributes['tone']  ?? 'info';
$title = $attributes['title'] ?? '';
$body  = $attributes['body']  ?? '';

$wrapper_attributes = get_block_wrapper_attributes( [
    'class' => "mxt-callout mxt-callout--{$tone}",
] );
?>

<div <?php echo $wrapper_attributes; ?>>
    <h3><?php echo wp_kses_post( $title ); ?></h3>
    <div class="callout-body">
        <?php echo wp_kses_post( $body ); ?>
    </div>
</div>

Two important details:

  • get_block_wrapper_attributes() generates the outer-element attributes including any user customizations from the editor. Skip it and you lose alignment, anchor IDs, and custom classes.
  • Always escape: wp_kses_post() for HTML content, esc_attr() for attributes, esc_url()for URLs. Block attributes come from the editor — they're trusted to be valid HTML, but you still escape on output. See WordPress security for the full rationale.

Block patterns and variations

A block is a primitive. Patterns are pre-configured arrangements of blocks — useful for recurring marketing layouts. Variations are pre-configured states of a single block — useful when one block can render four different visual modes.

Variations are simpler to ship and easier to maintain. Patterns are powerful for whole-page layouts (think landing-page sections). Use both, but for a typical custom-block plugin, variations cover 80% of the "different mode" needs.

Block.json supports — the gift you didn't know you got

The supports object opts you into editor features for free. The most useful ones:

  • color, gradients, fontSize, typography — users get inspector panels matching core blocks
  • spacing — padding/margin/blockGap controls
  • anchor — anchor IDs for in-page linking
  • align — wide/full alignment
  • html: false— disable the "Edit as HTML" option that lets users break your block

When you adopt these, you get consistent UI across your custom block and core blocks for free. Users learn one interface.

Internationalization

Always wrap user-facing strings in __()with your plugin's text domain:

import { __ } from '@wordpress/i18n';
// ...
<Button>{ __( 'Click me', 'mxt-callout' ) }</Button>

The @wordpress/scripts toolchain extracts translatable strings into a .pot file via npm run make-pot. Translators take it from there.

Testing blocks

Block testing is annoying because the block editor itself is a React tree mounted inside an iframe. Three layers that catch most regressions:

  • Visual regression on the editor — Playwright navigating to a page with your block, screenshot, diff
  • Save/Edit parity— for static blocks, save a post with the block, reload, confirm WordPress doesn't flag a validation error
  • Frontend render — for dynamic blocks, hit a published post via curl and grep for expected attributes

Common mistakes

  • Edit and Save HTML don't match. Block validation error every time the user re-edits.
  • Forgetting useBlockProps(). Your block loses anchors, alignment, custom classes silently.
  • Hardcoding strings.Translators can't help you.
  • Rolling your own controls instead of using core components. They look different from every other block, age poorly, and lose accessibility wins core ships.
  • Static block when dynamic was the right call. Six months in, you have 50,000 published posts with the block and a schema change requires deprecations.

Custom blocks are a rewarding place to invest in WordPress development — the API is mature, the toolchain is good, the editor rewards careful component work. The patterns above are what we use on client builds. For a plugin scaffold that pairs well with custom blocks (the registration code, the activation/deactivation hooks, the i18n wiring), see WordPress Plugin Boilerplate Setup. For projects where the requirements warrant senior block engineering, our plugin development service handles the full lifecycle.

Need help putting this into practice?

MaxtDesign builds the AI-powered web stacks the articles describe — from agentic workflows to performance-first WordPress + WooCommerce. Talk to us about your project.