TechEarl

Register a Custom Gutenberg Block with block.json

The modern way to register a custom Gutenberg block: a block.json metadata file, register_block_type( __DIR__ . '/build/callout' ) on the init hook, and a @wordpress/scripts build step. One source of truth for PHP and JS.

Ishan Karunaratne⏱️ 10 min readUpdated
Share thisCopied
How to register a custom Gutenberg block with block.json: the metadata-first approach using register_block_type() on the init hook, the file: asset convention, and a @wordpress/scripts build step that ships edit.js and save.js.

To register a custom Gutenberg block the modern way, you describe it once in a block.json file and hand that folder to register_block_type() on the init hook. WordPress reads the metadata, wires up the editor and front-end assets, and registers the type for both PHP and JavaScript from that single file. Here is the smallest complete example, a static "callout" block in the te namespace.

The block.json (this lives next to your built JS, at build/callout/block.json):

json
{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "te/callout",
  "version": "1.0.0",
  "title": "Callout",
  "category": "text",
  "icon": "megaphone",
  "description": "A simple highlighted callout box.",
  "textdomain": "te-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css"
}

And the PHP that registers it:

php
<?php
/**
 * Plugin Name: TE Block
 * Plugin URI:  https://techearl.com/wordpress-custom-gutenberg-block-block-json
 * Description: A custom Gutenberg block registered from block.json metadata.
 * Version:     1.0.0
 * Author:      Ishan Karunaratne
 * Author URI:  https://techearl.com
 * License:     GPL-2.0-or-later
 * Text Domain: te-block
 */

add_action( 'init', 'te_register_blocks' );

function te_register_blocks() {
    register_block_type( __DIR__ . '/build/callout' );
}

That is the whole registration. register_block_type() gets a directory path, finds the block.json inside it, and does the rest. No wp_enqueue_script, no hand-written asset handles, no duplicate block definition in JavaScript.

Why a directory path works

Passing a folder to register_block_type() is the part that surprises people coming from older tutorials. Since WordPress 5.8 (July 2021), the function's first parameter accepts the path to the directory that holds a block.json. WordPress opens that file, reads the name, resolves every file:-prefixed asset relative to the JSON, enqueues them in the right context, and registers the type. The block.json is the canonical block registration mechanism in WordPress as of 5.8, and everything else hangs off it.

The file: convention is the glue. An asset field like "editorScript": "file:./index.js" tells WordPress "the script is the index.js sitting next to this file." WordPress also reads the matching index.asset.php that the build step emits (the dependency and version manifest) so the script declares its @wordpress/* dependencies automatically. You never type out a handle or a version string.

A few fields earn their keep:

  • apiVersion is the Block API contract, not your plugin version. Use 3 (introduced in WordPress 6.3, it opts the block's editor markup into the iframe-isolated canvas). 2 is still valid and is the right floor if you must support editors before 6.3. 1 is legacy; do not start there.
  • name must be namespace/block-name. The namespace is yours: I use te/ here so the block is unambiguously mine and will not collide with core/ or another plugin's blocks.
  • category is one of the editor's inserter groups (text, media, design, widgets, theme, embed), or a custom one you register separately.
  • editorScript / editorStyle load only inside the editor. style loads in both the editor and the front end. script (front-end JS) loads in both as well; a static block usually needs none.

The build step with @wordpress/scripts

The block.json points at build/callout/index.js, but you do not write that file by hand. You write modern JSX in src/ and let @wordpress/scripts (the wp-scripts toolchain) compile it. It wraps webpack and Babel with the WordPress defaults already configured, so there is no config file to babysit.

A minimal package.json:

json
{
  "name": "te-block",
  "version": "1.0.0",
  "scripts": {
    "build": "wp-scripts build",
    "start": "wp-scripts start"
  },
  "devDependencies": {
    "@wordpress/scripts": "^27.0.0"
  }
}

Then:

bash
npm install
npm run build

wp-scripts build reads src/index.js as the entry point, compiles the JSX and SCSS, and writes the production bundle plus an index.asset.php manifest into build/. It also copies your src/block.json into build/ so the file: paths resolve against the compiled assets, which is exactly why the PHP points at build/callout and not src. During development, npm run start does the same thing in watch mode with source maps, rebuilding on every save.

If you are scaffolding from scratch, npx @wordpress/create-block te-callout generates this entire layout (a working block.json, src files, package.json, and the PHP plugin header) in one command. It is the fastest way to get a correct skeleton, and the structure it produces is the structure above.

edit.js and save.js: the two halves of a static block

A block has two faces. edit renders what the author sees and manipulates in the editor; save returns the static markup that gets serialized into the post's post_content and shipped to visitors. For a static block, save returns real HTML that lives in the database, so no PHP runs at render time on the front end.

The entry point ties them together:

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

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

edit.js, a single editable paragraph using the editor's rich-text control:

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

export default function Edit( { attributes, setAttributes } ) {
	const blockProps = useBlockProps( { className: 'te-callout' } );

	return (
		<RichText
			{ ...blockProps }
			tagName="p"
			value={ attributes.content }
			onChange={ ( content ) => setAttributes( { content } ) }
			placeholder="Write your callout..."
		/>
	);
}

save.js, the markup written to the database:

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

export default function save( { attributes } ) {
	const blockProps = useBlockProps.save( { className: 'te-callout' } );

	return <RichText.Content { ...blockProps } tagName="p" value={ attributes.content } />;
}

You also declare the content attribute so the value persists. Add it to block.json:

json
{
  "attributes": {
    "content": {
      "type": "string",
      "source": "html",
      "selector": "p"
    }
  }
}

The source and selector tell WordPress to parse the saved value back out of the <p> when it re-loads the block in the editor, which is how the editor reconstructs state from the static HTML on the next edit. I keep RichText.Content in save and RichText in edit deliberately: mismatched markup between the two is the single most common cause of a block validation error.

For a block that should render through PHP at request time instead of freezing HTML into the post (a latest-posts list, anything that depends on live data), save returns null and a server-side render_callback produces the markup. That is a dynamic block, and I cover the full pattern, including the render field in block.json, in the server-rendered Gutenberg block walkthrough.

Why block.json beats registering in JavaScript alone

The old approach called registerBlockType() in JavaScript with the full block definition inline, and enqueued the editor script from PHP by hand. It works, but the block exists only in the browser. PHP has no idea the block is there. That cripples a few things that matter:

  • Server awareness. With block.json, both PHP and JS register the same type from the same metadata. The REST API exposes it, server-side block patterns can reference it, and WP_Block_Type_Registry knows about it. The JS-only block is invisible to all of that.
  • Asset handling. The file: fields plus the generated index.asset.php mean WordPress enqueues your scripts and styles in the correct context with correct dependencies and cache-busting versions. Doing that by hand means maintaining wp_enqueue_script calls that drift out of sync with the build.
  • Internationalization. textdomain in block.json lets WordPress set up script translations for your block automatically. The JS-only path makes you wire wp_set_script_translations() yourself.
  • One source of truth. Block name, category, icon, attributes, and supports live in one file that both runtimes read. The JS-only version splits that across a JS object and ad hoc PHP, and they fall out of step the first time someone edits one and not the other.

The practical summary: registering in JS alone still renders a block, but it is a client-only object that the rest of WordPress cannot see. The metadata file makes the block a first-class citizen on both sides of the request.

Verify the block is registered

After npm run build and activating the plugin, open any post in the editor and click the inserter (the +). Search for "Callout." The block should appear under the Text group with the megaphone icon, and inserting it should give you the editable paragraph. Type into it, save the post, and view the front end: you should see your text wrapped in the <p class="te-callout"> that save returned, styled by style-index.css.

If it does not show up, the usual suspects are: the plugin is not active, you pointed register_block_type() at src instead of build (so the compiled block.json and assets are not where PHP is looking), or the build failed and build/callout/block.json does not exist. A quick check from WP-CLI confirms registration server-side:

bash
wp eval 'var_dump( WP_Block_Type_Registry::get_instance()->is_registered( "te/callout" ) );'

A bool(true) there means PHP sees the block, which isolates any remaining problem to the editor JavaScript (check the browser console for a build or validation error).

See also

Sources

Authoritative references this article was fact-checked against.

TagsWordPressGutenbergBlock EditorJavaScriptblock.jsonPHP

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts