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- WordPress
How to Prepare Your Plugin or Theme for WordPress 7.0
A step-by-step compatibility process for shipping a WordPress 7.0-ready release — set up a test site, audit your blocks, fix the breaking changes, and ship with confidence.
22 min
- WordPress
WordPress 7.0 for Block Developers: Breaking Changes
WordPress 7.0 enforces the iframed editor, broadens contentOnly mode, and drops PHP 7.3. Here are the breaking changes block and plugin developers must fix before users upgrade.
25 min
- WordPress
WordPress 7.0 AI: Client, Abilities & Connectors
The headline feature of WordPress 7.0 is native AI in Core. Here is what the WP AI Client, the Abilities API, and the Connectors system actually mean for plugin developers.
25 min