Building Custom Gutenberg Blocks from Scratch in 2026

The block editor has matured considerably since its contentious launch. In 2026, most serious WordPress development involves writing custom blocks rather than assembling layouts from page builder elements. The appeal is straightforward: developers control the exact HTML output, content editors get a predictable, structured interface, and the resulting pages load faster without carrying a page builder's JavaScript overhead.

This guide covers the complete process of building custom blocks using @wordpress/create-block and the modern block API — from scaffolding the initial plugin through dynamic server-side rendering and multi-block plugin architecture. The examples use a testimonial block as the running case study, which is concrete enough to illustrate real decisions without being trivial.

Setting Up the Development Environment

You need Node.js 18 or later and npm installed, plus a local WordPress environment. Local by Flywheel is the easiest option for getting a WordPress site running on your machine; wp-env (the official WordPress local environment) is the better choice if you prefer a Docker-based setup that matches staging and production more closely.

Scaffold a new block plugin with a single command:

npx @wordpress/create-block@latest my-custom-block

This generates a complete plugin directory containing a webpack configuration, edit.js, save.js, block.json, index.js, and the main PHP plugin file. Navigate into the directory and run:

npm start

This starts webpack in watch mode, recompiling the block JavaScript on every file save. The scaffolded configuration handles all the build complexity — module bundling, JSX transpilation, asset handling — without requiring you to write or maintain your own webpack config. Only write a custom webpack configuration if you have a specific, documented need; the default is production-ready.

Install the plugin in your local WordPress, activate it, and you'll see the scaffolded block appear in the block inserter. Everything from here is modifying the generated files to build your actual block.

Understanding block.json

The block.json file is the manifest that defines and registers your block. Understanding its fields is prerequisite to everything else, because it controls how WordPress discovers and presents the block to editors.

The name field follows the namespace/block-name convention — for example, myagency/testimonial. Use your company or plugin name as the namespace to avoid collisions. The title and description are what editors see in the block inserter. The category places the block in one of WordPress's built-in inserter categories: text, media, design, widgets, theme, or embed.

The attributes object is where you define the data your block stores — more on this in the next section. The supports object enables or disables WordPress's built-in block features: align for block-level alignment, color for the color panel in the inspector, typography for the typography panel. Each support you enable adds a corresponding control to the block sidebar automatically, without writing additional code.

The editorScript, style, and editorStyle fields point to the compiled asset files. @wordpress/create-block sets these up correctly in the generated block.json, but you need to understand them if you're registering blocks manually.

A practical advantage of block.json: it enables server-side block type registration, which means blocks can be queried and rendered server-side — a prerequisite for dynamic blocks. It also makes blocks portable; a block registered via block.json can be installed on any WordPress site without modification.

Attributes — How Blocks Store Data

Attributes are the inputs your block saves alongside the post content. Each attribute has a type (string, boolean, number, array, object) and a source that tells WordPress how to serialize and deserialize it.

For a testimonial block, you might define:

"attributes": {
  "quote": {
    "type": "string",
    "source": "html",
    "selector": "blockquote"
  },
  "author": {
    "type": "string",
    "source": "text",
    "selector": "cite"
  },
  "role": {
    "type": "string",
    "default": ""
  },
  "showAvatar": {
    "type": "boolean",
    "default": true
  },
  "rating": {
    "type": "number",
    "default": 5
  }
}

The source: "html" on quote means WordPress reads the rich text HTML from the blockquote element in the saved output. The source: "text" on author reads the plain text content of the cite element. Attributes without a source field (like role, showAvatar, and rating) are stored as JSON in the block comment delimiter — invisible in the rendered page but parsed by the editor when loading the block.

The distinction matters for deprecations: attributes stored in HTML are tied to the structure of your save function. If you restructure the HTML and change selectors, existing blocks can't parse their own stored data. Plan your attribute sources before publishing blocks to production.

Writing the Edit Component

The edit function renders what appears in the block editor. It receives attributes (the current attribute values) and setAttributes (a function to update them) as props.

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

export default function Edit({ attributes, setAttributes }) {
  const { quote, author, role, showAvatar, rating } = attributes;
  const blockProps = useBlockProps();

  return (
    <>
      <InspectorControls>
        <PanelBody title="Testimonial Settings">
          <TextControl
            label="Author Name"
            value={author}
            onChange={(value) => setAttributes({ author: value })}
          />
          <TextControl
            label="Author Role"
            value={role}
            onChange={(value) => setAttributes({ role: value })}
          />
          <ToggleControl
            label="Show Avatar"
            checked={showAvatar}
            onChange={(value) => setAttributes({ showAvatar: value })}
          />
          <RangeControl
            label="Rating"
            value={rating}
            onChange={(value) => setAttributes({ rating: value })}
            min={1}
            max={5}
          />
        </PanelBody>
      </InspectorControls>
      <div {...blockProps}>
        <RichText
          tagName="blockquote"
          value={quote}
          onChange={(value) => setAttributes({ quote: value })}
          placeholder="Enter testimonial quote..."
        />
        <cite>{author} — {role}</cite>
      </div>
    </>
  );
}

The useBlockProps hook is mandatory — it attaches the necessary editor classes and data attributes to the block's wrapper element. Skip it and the block won't behave correctly in the editor (selection, focus, drag-and-drop all break).

Keep editor-only controls — things editors configure once and don't change per-session — inside InspectorControls. It renders them in the sidebar panel. Put frequently toggled options in BlockControls, which renders them in the toolbar that appears above the block when it's selected.

Writing the Save Component

The save function generates the HTML that gets stored in the post content and served to site visitors. Unlike edit, it receives only attributes — no setAttributes, no side effects, no API calls. It must be purely deterministic: given identical attributes, it must always return identical HTML.

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

export default function save({ attributes }) {
  const { quote, author, role, rating } = attributes;
  const blockProps = useBlockProps.save();
  const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating);

  return (
    <div {...blockProps}>
      <div className="testimonial-rating" aria-label={`${rating} out of 5 stars`}>
        {stars}
      </div>
      <RichText.Content tagName="blockquote" value={quote} />
      <cite>{author} — {role}</cite>
    </div>
  );
}

Use RichText.Content (not RichText) in the save function. RichText.Content renders the stored rich text HTML as-is without the editor controls.

The critical constraint: if you change the save function after blocks are already published, WordPress will show "Block contains unexpected or invalid content" for every existing instance, because the stored HTML no longer matches what the current save function expects to produce. Handle this by adding a deprecated() array that records older versions of the save function — WordPress tries each deprecated version in order to find one that validates the existing block content, then migrates the attributes to the current format.

InspectorControls Deep Dive

The sidebar panel is where editors configure block options that don't need to be visible inline. Beyond the basics shown above, two components are particularly useful in production blocks.

MediaUpload lets editors select images from the media library and returns the attachment ID and URL. For an avatar image on the testimonial block:

import { MediaUpload, MediaUploadCheck } from '@wordpress/block-editor';
import { Button } from '@wordpress/components';

// Inside InspectorControls > PanelBody:
<MediaUploadCheck>
  <MediaUpload
    onSelect={(media) => setAttributes({
      avatarId: media.id,
      avatarUrl: media.url,
      avatarAlt: media.alt
    })}
    allowedTypes={['image']}
    value={avatarId}
    render={({ open }) => (
      <Button onClick={open} variant="secondary">
        {avatarId ? 'Replace Avatar' : 'Select Avatar'}
      </Button>
    )}
  />
</MediaUploadCheck>

Store the attachment ID (not just the URL) as an attribute. The ID lets you regenerate the URL server-side at different image sizes — if you store only the URL, you're locked into whatever size the editor uploaded.

SelectControl provides a dropdown for layout or style variants. For a testimonial with card, quote, and minimal layouts:

<SelectControl
  label="Layout"
  value={layout}
  options={[
    { label: 'Card', value: 'card' },
    { label: 'Quote', value: 'quote' },
    { label: 'Minimal', value: 'minimal' },
  ]}
  onChange={(value) => setAttributes({ layout: value })}
/>

The selected value gets added as a class to the block wrapper in both edit and save, letting CSS handle the visual variation without JavaScript branching logic.

Dynamic Blocks — Server-Side Rendering

Static blocks store their output as HTML in the post content. Dynamic blocks store only their attributes and render their output in PHP on each page load. Dynamic blocks are the right choice when the block displays content that changes independently of the post — recent posts, user-specific content, live data, or content pulled from an external API.

Register a dynamic block in PHP with a render_callback:

register_block_type( __DIR__ . '/build', [
  'render_callback' => 'myagency_render_latest_posts_block',
] );

function myagency_render_latest_posts_block( $attributes, $content ) {
  $category_id = isset( $attributes['categoryId'] ) ? (int) $attributes['categoryId'] : 0;

  $args = [
    'post_type'      => 'post',
    'posts_per_page' => 3,
    'cat'            => $category_id ?: null,
    'post_status'    => 'publish',
  ];

  $posts = get_posts( $args );
  if ( empty( $posts ) ) return '<p>No posts found.</p>';

  $html = '<ul class="wp-block-myagency-latest-posts">';
  foreach ( $posts as $post ) {
    $html .= sprintf(
      '<li><a href="%s">%s</a></li>',
      esc_url( get_permalink( $post ) ),
      esc_html( get_the_title( $post ) )
    );
  }
  $html .= '</ul>';
  return $html;
}

The edit component for a dynamic block shows a preview placeholder and the inspector controls — in this case a SelectControl listing available categories. The save function returns null, because all rendering happens server-side. Dynamic blocks are not affected by save function deprecation issues, which is a meaningful maintenance advantage for blocks that need to evolve over time.

Registering Multiple Blocks from One Plugin

A single client project might need a testimonial block, a call-to-action block, a pricing table block, and a team member card block. Shipping these as separate plugins creates management overhead. The professional pattern is one plugin, multiple blocks.

Structure the src directory with one subdirectory per block:

src/
  blocks/
    testimonial/
      block.json
      edit.js
      save.js
      index.js
      style.scss
    cta/
      block.json
      edit.js
      save.js
      index.js
      style.scss

In the main plugin PHP file, auto-register all blocks by scanning the build directory:

function myagency_register_blocks() {
  $block_dirs = glob( __DIR__ . '/build/blocks/*', GLOB_ONLYDIR );
  foreach ( $block_dirs as $dir ) {
    register_block_type( $dir );
  }
}
add_action( 'init', 'myagency_register_blocks' );

Update package.json to list each block as a separate webpack entry point. @wordpress/scripts supports this pattern natively — check the official documentation for the --webpack-src-dir flag and multi-entry configuration. This setup scales from three blocks to fifty without touching the registration code.

Frequently Asked Questions

Should I use ACF blocks or native Gutenberg blocks?

ACF blocks are built with register_block_type using a render_callback tied to ACF field groups, which means you skip edit.js entirely and write a PHP template. If your development team is fluent in ACF, this is significantly faster. Native blocks are the better choice when you need the block to render accurately in the editor for a true WYSIWYG experience — which matters most for design-heavy elements like hero sections or testimonials where editors need to see exactly how the published page will look. For most Kerala WordPress agencies building interior pages for clients with mixed technical backgrounds, ACF blocks are the pragmatic day-to-day choice. Reserve native block development for your core design system elements that need a polished editor experience.

What happens to my custom blocks if I update WordPress?

WordPress has maintained backward compatibility for block APIs across every major release since the block editor launched. The block API version you built with continues to function after WordPress updates. What occasionally breaks is usage of internal WordPress functions that get deprecated — this happens with advance notice, not suddenly. Subscribe to the Make WordPress Core blog and read the Gutenberg changelog with each major release to catch deprecation notices before they affect production. For any client site, run a staging environment test after a WordPress update before deploying to production. Testing block rendering takes about 30 minutes and prevents the "Block contains unexpected or invalid content" notice from appearing in front of real visitors.

Can I use TypeScript and React in custom Gutenberg blocks?

Yes. @wordpress/scripts — the build tool used by @wordpress/create-block — supports TypeScript out of the box with a tsconfig.json file. WordPress's own packages ship type definitions. The practical trade-off: the type definitions for @wordpress/blocks and @wordpress/editor are not always perfectly synchronized with the actual API, so you'll occasionally encounter type errors that require a workaround or a type assertion. For a team already using TypeScript across a larger codebase, adding it to block development is worth the consistency. For a standalone client project where you need to move quickly, plain JavaScript works identically and removes the compile-step friction.

Rajesh R Nair is an IT Consultant based in Trivandrum, Kerala, specialising in WordPress development, custom block development, and web architecture for Kerala businesses. He builds tailored WordPress solutions that give clients structured, maintainable content experiences without the overhead of page builders.