How to Build Your First Gutenberg Block (2026 — @wordpress/scripts Setup)

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.

What you need before starting

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.

Scaffold the plugin

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 myfirstblock
Bash

This 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)
Bash

Activate the plugin in WordPress, then start the dev server:

cd my-first-block
npm install
npm start
Bash

From here, changes to files in src/ compile automatically. You’ll see the block appear in the editor under “Widgets” by default.

Understanding block.json

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"
}
JSON

A 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.

The edit function

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>
		
	);
}
JavaScript

useBlockProps() 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.

The save function

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="" }="">
		
); }JavaScript

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.

Registering the block in PHP

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' );
} );
PHP

That’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.

Building for production

# Development (watch mode, unminified)
npm start

# Production (minified, optimised)
npm run build
Bash

Always 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.

Adding a dynamic block (server-side rendering)

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;
}
JavaScript
add_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 ); }
PHP

Always 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.

Block validation errors — what they are and why they happen

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.

Useful @wordpress packages to know

PackageWhat it gives you
@wordpress/block-editoruseBlockProps, RichText, InspectorControls, BlockControls, colour/alignment tools
@wordpress/componentsUI components: PanelBody, TextControl, ToggleControl, SelectControl, Button
@wordpress/dataAccess to the editor data store — current post, editor settings, selected block
@wordpress/i18n__( 'string', 'textdomain' ) for translations
@wordpress/api-fetchFetch data from the WordPress REST API with nonce auth built in
@wordpress/hooksJavaScript version of WordPress actions and filtersaddFilter, 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.

Frequently asked questions

Do I need to know React to build Gutenberg blocks?

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.

What is the difference between apiVersion 2 and 3?

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.

Can I build a Gutenberg block without @wordpress/scripts?

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.

How do I add a block to a specific category or create my own category?

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,
        ],
    ] );
} );
PHP

How do I check if a block is rendering correctly on the front end?

Add 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.

About Me

Gemini_Generated_Image_6ed8rn6ed8rn6ed8

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

Learn More