MW
Tutorial

Building Your First Custom Block

Replace the scaffolded placeholder with a real custom Callout block — editable title, body, and a type selector — using registerBlockType + edit/save.

intermediate 45 min May 13, 2026
Working block dev environment from Part 2 Basic React / JSX familiarity

BUILDING YOUR FIRST CUSTOM BLOCK

In Part 2 we scaffolded a plugin. In Part 3 we write a real custom block. Our target: a Callout block with a configurable type (info / warning / tip / error), a title, and a body.

Step 1 — Update block.json

block.json is the manifest. WordPress reads it to register the block. Replace the scaffolded version with ours:

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "my-first-block/callout",
  "title": "Callout",
  "category": "text",
  "icon": "megaphone",
  "description": "A styled callout box for notes, tips, and warnings.",
  "textdomain": "my-first-block",
  "attributes": {
    "title": {
      "type": "string",
      "default": "Heads up"
    },
    "body": {
      "type": "string",
      "default": "Body text goes here."
    },
    "calloutType": {
      "type": "string",
      "default": "info",
      "enum": ["info", "warning", "tip", "error"]
    }
  },
  "supports": {
    "html": false,
    "align": ["wide", "full"]
  },
  "editorScript": "file:./index.js",
  "style": "file:./style-index.css",
  "editorStyle": "file:./index.css"
}

The important fields:

  • name — globally unique, namespaced as plugin/block-name. Required.
  • apiVersion — current is 3 (added WP 6.3). Use the latest.
  • attributes — the data model. Each attribute has a type and optional default.
  • supports — opt-in or opt-out of editor features. html: false hides the “Edit as HTML” option (cleaner for custom blocks).

Step 2 — Register the block in src/index.js

Replace the scaffolded src/index.js with:

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

registerBlockType(metadata.name, {
  edit: Edit,
  save: Save,
});

That’s it. The CLI tool wires the rest. block.json is imported so WordPress and the bundler stay in sync on the block name.

Step 3 — Write the Edit component

This is the React component WordPress mounts in the editor. Replace src/edit.js:

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

export default function Edit({ attributes, setAttributes }) {
  const { title, body, calloutType } = attributes;

  const blockProps = useBlockProps({
    className: `my-callout my-callout-${calloutType}`,
  });

  return (
    <div {...blockProps}>
      <RichText
        tagName="h4"
        className="my-callout-title"
        value={title}
        onChange={(value) => setAttributes({ title: value })}
        placeholder={__('Callout title', 'my-first-block')}
      />
      <RichText
        tagName="p"
        className="my-callout-body"
        value={body}
        onChange={(value) => setAttributes({ body: value })}
        placeholder={__('Callout body text', 'my-first-block')}
      />
    </div>
  );
}

Three patterns to understand:

  1. useBlockProps() — returns the props WordPress needs on the wrapper element (className, data-attributes, focus handling). Always spread it on your outermost element.
  2. RichText — a contenteditable component that supports inline formatting (bold, italic, links). Use it for any text the user should be able to edit with formatting.
  3. setAttributes — the way to update block state. Equivalent to React’s setState, but tied into WordPress’s undo/redo and serialization.

Step 4 — Write the Save component

This is what gets serialized to post_content. Replace src/save.js:

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

export default function Save({ attributes }) {
  const { title, body, calloutType } = attributes;

  const blockProps = useBlockProps.save({
    className: `my-callout my-callout-${calloutType}`,
  });

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

Note the differences from Edit:

  • useBlockProps.save() instead of useBlockProps() (save-time variant)
  • RichText.Content instead of RichText.Content renders the saved HTML without editing affordances

Step 5 — Add styles

Update src/style.scss (front-end + editor) with the visual styles:

.my-callout {
  border-left: 4px solid;
  padding: 1rem 1.25rem;
  border-radius: 0.5rem;
  margin: 1.5rem 0;

  &.my-callout-info    { border-color: #2271b1; background: #e7f5ff; }
  &.my-callout-tip     { border-color: #16a34a; background: #ecfdf5; }
  &.my-callout-warning { border-color: #d97706; background: #fff7ed; }
  &.my-callout-error   { border-color: #d63638; background: #fee2e2; }

  .my-callout-title { font-weight: 600; margin: 0 0 0.5rem 0; }
  .my-callout-body  { margin: 0; }
}

Save. Webpack rebuilds. Refresh the editor.

Step 6 — Test it

Testing my callout

Testing my callout

Heads up

Body text goes here.

In wp-admin, add a new post, search the inserter for “Callout”, insert it. You should see the editable title and body. Type into both. Save the post and view it on the front-end — the same styled box renders.

Common errors

ErrorCauseFix
”Block validation failed”Edit and Save output different HTMLMake sure they produce identical markup
Block doesn’t appear in inserterStale browser cacheHard refresh wp-admin
Styles don’t applystyle.scss not compiledConfirm npm run start is running
RichText is not definedMissing importimport { RichText } from '@wordpress/block-editor'

What’s next

You have a working custom block. Part 4 dives into the attribute system in depth — sources, types, serialization quirks, and how to evolve attributes without breaking saved content.

You've completed this tutorial!

Get the next one in your inbox. Practical tips, no fluff.

Subscribe

Get weekly notes in your inbox

Practical tips, tutorials and resources. No spam.