Block development has a reputation for being harder than it needs to be. The official docs are thorough but long, and it’s easy to get lost before writing a single line of JavaScript. I’ve watched developers spend an entire afternoon trying to configure Webpack before giving up. This guide skips that problem entirely.
The short version: you don’t need to configure Webpack yourself anymore. @wordpress/scripts handles the build tooling, and block.json handles block registration. Most tutorials you’ll find predate this setup, which is why they feel more complicated than they should be. The current approach is genuinely simple once you see it.
Node.js (v18 or above) and npm installed locally. A WordPress development environment — Local by Flywheel or WordPress Playground both work fine. That’s genuinely it. No Docker, no Vagrant, no custom Webpack config sitting in your project waiting to break on the next Node upgrade.
The fastest way to start is with @wordpress/create-block, which scaffolds everything — plugin file, block.json, JavaScript, PHP, CSS, and build config — in one command:
cd wp-content/plugins
npx @wordpress/create-block my-first-block --namespace myfirstblockBashThis creates a my-first-block/ folder with the following structure:
my-first-block/
├── my-first-block.php # Plugin header + register_block_type call
├── package.json # @wordpress/scripts dependency
├── src/
│ ├── block.json # Block metadata (name, attributes, supports)
│ ├── edit.js # Editor UI (what the author sees)
│ ├── save.js # Front-end output (what visitors see)
│ ├── index.js # Registers the block in JS
│ └── style.scss # Front-end styles
└── build/ # Compiled output (generated, don't edit)BashActivate the plugin in WordPress, then start the dev server:
cd my-first-block
npm install
npm startBashFrom here, changes to files in src/ compile automatically. You’ll see the block appear in the editor under “Widgets” by default.
block.json is the single most important file to understand. It defines everything about the block — its name, what attributes it stores, what settings appear in the sidebar, and which JavaScript and CSS files to load. WordPress reads this file directly; you don’t register anything manually in PHP anymore.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "myfirstblock/notice",
"version": "1.0.0",
"title": "Notice",
"category": "text",
"icon": "info",
"description": "A simple notice block with a message and colour option.",
"attributes": {
"message": {
"type": "string",
"source": "html",
"selector": "p"
},
"backgroundColor": {
"type": "string",
"default": "#f0f4ff"
}
},
"supports": {
"html": false,
"align": true,
"color": {
"background": true,
"text": true
},
"typography": {
"fontSize": true
}
},
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css"
}JSONA few things worth knowing here. The name field must be unique — namespace/block-name format, all lowercase. The attributes object defines what data the block stores and how it maps to the HTML output (the source field). The supports object controls which panel options appear in the editor sidebar — setting "html": false prevents users from switching to HTML view, which is good practice for blocks with structured output.
edit.js controls what the block looks like inside the editor. It can be interactive — you can add controls, let users type directly into the block, pick colours. This is all React, but you don’t need to import React itself; WordPress makes it available globally.
import { useBlockProps, RichText, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, ColorPicker } from '@wordpress/components';
export default function Edit( { attributes, setAttributes } ) {
const { message, backgroundColor } = attributes;
const blockProps = useBlockProps( {
style: { backgroundColor },
} );
return (
<>
{ /* Sidebar panel */ }
<inspectorcontrols>
<panelbody title="Background colour" initialopen="{" true="" }="">
color="{" backgroundcolor="" }="" onchange="{" (="" value="" )=""> setAttributes( { backgroundColor: value } ) }
/>
colorpicker>panelbody>
inspectorcontrols>
{ /* Block canvas */ }
<div {="" ...blockprops="" }="">
<richtext tagname="p" value="{" message="" }="" onchange="{" (="" )=""> setAttributes( { message: value } ) }
placeholder="Write your notice here..."
/>
richtext>div>
);
}JavaScriptuseBlockProps() is required — it attaches the data attributes and class names WordPress needs to identify the block in the editor. Always spread it onto your outermost element. RichText gives you an editable text field with basic formatting. InspectorControls renders into the right sidebar when the block is selected.
save.js produces the static HTML that gets stored in the database. It runs once when the post is saved, not on every page load. The output needs to be deterministic — given the same attributes, it must always produce the same HTML, otherwise WordPress will flag a “block validation error” when the post is edited later.
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function Save( { attributes } ) {
const { message, backgroundColor } = attributes;
const blockProps = useBlockProps.save( {
style: { backgroundColor },
} );
return (
<div {="" ...blockprops="" }="">
<richtext.content tagname="p" value="{" message="" }="">
Notice the difference: in save.js you use useBlockProps.save() (not just useBlockProps()), and RichText.Content instead of RichText. These are the static versions. No event handlers, no state — just HTML output.
The main plugin file is short. register_block_type reads block.json and handles everything — you just point it at the build directory:
php
/**
* Plugin Name: My First Block
* Plugin URI: https://kamalhosen.com
* Description: A simple custom Gutenberg block.
* Version: 1.0.0
* Author: Kamal Hosen
* License: GPL-2.0-or-later
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'init', function() {
register_block_type( __DIR__ . '/build' );
} );PHPThat’s the entire PHP side. register_block_type finds the block.json inside the build directory, registers the block, and enqueues the compiled scripts and styles automatically. No manual wp_enqueue_script() calls needed — the WordPress options and settings you defined in block.json handle everything.
# Development (watch mode, unminified)
npm start
# Production (minified, optimised)
npm run buildBashAlways run npm run build before distributing or deploying your plugin. The build/ directory is what WordPress actually loads — commit it to your repository. The node_modules/ folder goes in .gitignore.
Static blocks store their HTML in the database. Dynamic blocks render on each page request using a PHP callback — useful when the output depends on live data, like a recent posts list or a product feed.
To make a block dynamic, set save.js to return null and add a render_callback in PHP:
// save.js — return null for dynamic blocks
export default function Save() {
return null;
}JavaScriptadd_action( 'init', function() {
register_block_type( __DIR__ . '/build', [
'render_callback' => 'myfirstblock_render_notice',
] );
} );
function myfirstblock_render_notice( array $attributes, string $content ): string {
$message = wp_kses_post( $attributes['message'] ?? '' );
$bg_color = sanitize_hex_color( $attributes['backgroundColor'] ?? '#f0f4ff' );
return sprintf(
'%s
',
esc_attr( $bg_color ),
$message
);
}PHPAlways sanitize attributes in the PHP render callback — treat them the same way you’d treat any user input. See the WordPress plugin security guide for which sanitization function to use for each data type.
This is the thing that catches everyone at least once. If you change save.js after a block has already been used in posts, WordPress throws a validation error when those posts are opened. The stored HTML no longer matches what save() produces, and WordPress refuses to render the block without intervention.
There are three ways to handle this. The cleanest is to use a dynamic block (PHP render) from the start — then save always returns null and there’s nothing to validate. The second option is block deprecations, where you define the old save function alongside the new one and WordPress migrates the content automatically. The third option is to just bump the block name — useful during development when you haven’t shipped to anyone yet.
For any block that stores user content, dynamic rendering is generally the right default. Static blocks are best for purely presentational elements where the output is unlikely to ever change.
| Package | What it gives you |
|---|---|
@wordpress/block-editor | useBlockProps, RichText, InspectorControls, BlockControls, colour/alignment tools |
@wordpress/components | UI components: PanelBody, TextControl, ToggleControl, SelectControl, Button |
@wordpress/data | Access to the editor data store — current post, editor settings, selected block |
@wordpress/i18n | __( 'string', 'textdomain' ) for translations |
@wordpress/api-fetch | Fetch data from the WordPress REST API with nonce auth built in |
@wordpress/hooks | JavaScript version of WordPress actions and filters — addFilter, addAction |
All of these are pre-bundled with WordPress and available as globals — you import them but don’t bundle them, which is why block JavaScript files are small despite using a full component library.
You need enough JSX to read and write the edit function — props, state via setAttributes, conditional rendering. You don’t need to know React deeply. The block editor components handle most of the complexity, so most blocks are just a few components wired together rather than custom React logic.
apiVersion 3 (introduced in WordPress 6.3) moves the block wrapper element handling into the editor iframe rather than the main document, which isolates block styles better. It’s the current default from @wordpress/create-block. Use it unless you’re supporting older WordPress versions below 6.3.
Yes — you can write vanilla JavaScript with no build step and register it with wp_enqueue_script. This works for simple blocks but gets painful quickly because you lose JSX, modern JS syntax, and the import system. For anything beyond a trivial block, @wordpress/scripts is worth the setup time, which is about two minutes with create-block.
Set the category field in block.json to one of the built-in values: text, media, design, widgets, theme, or embed. To create a custom category, use the block_categories_all filter in PHP:
add_filter( 'block_categories_all', function( array $categories ): array {
return array_merge( $categories, [
[
'slug' => 'my-plugin',
'title' => 'My Plugin',
'icon' => null,
],
] );
} );PHPAdd the block to a post, publish it, then view the post while logged out. The front end always loads the compiled style-index.css and the PHP render output — not the editor version. If something looks wrong on the front end but correct in the editor, check that save.js and your PHP render callback are producing the same class names and structure.

I’m Kamal, a WordPress developer focused on plugins, APIs, and scalable products.
Learn More