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 asplugin/block-name. Required.apiVersion— current is3(added WP 6.3). Use the latest.attributes— the data model. Each attribute has atypeand optionaldefault.supports— opt-in or opt-out of editor features.html: falsehides 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:
useBlockProps()— returns the props WordPress needs on the wrapper element (className, data-attributes, focus handling). Always spread it on your outermost element.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.setAttributes— the way to update block state. Equivalent to React’ssetState, 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 ofuseBlockProps()(save-time variant)RichText.Contentinstead ofRichText—.Contentrenders 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
| Error | Cause | Fix |
|---|---|---|
| ”Block validation failed” | Edit and Save output different HTML | Make sure they produce identical markup |
| Block doesn’t appear in inserter | Stale browser cache | Hard refresh wp-admin |
| Styles don’t apply | style.scss not compiled | Confirm npm run start is running |
RichText is not defined | Missing import | import { 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.
Get the next one in your inbox. Practical tips, no fluff.
More tutorials
View all-
Block Patterns
Register reusable block compositions — landing-page hero, feature grid, FAQ section — that users insert as a complete unit.
25 min
-
Block Attributes and Serialization
Master the attribute system — types, sources, defaults, and how block data round-trips between the editor, the database, and the front-end.
30 min
-
Building a Block Plugin
Package your block as a proper WordPress plugin — file structure, asset enqueueing, server-side render callbacks, plugin metadata.
35 min