2026年11月12日

2026年11月12日

WordPress Custom Block Plugin: create-block, block.json, render.php

This article is a localized edition of the Japanese original. Read the Japanese original

You can add blocks to the block editor (Gutenberg) with a plugin. The standard approach today is to describe the block in block.json, build the editor view in JavaScript (React), and produce the front-end HTML in a PHP file, render.php. A block whose output is generated in PHP is called a "dynamic block".

In this article we build a "CTA block" with a heading and a button. The steps and code were checked in November 2026 with WordPress 7.1, Node.js 22 and the latest @wordpress/create-block, all the way through building and displaying the block.

What you need

  • Node.js and npm (to build the block's JavaScript; this article was checked with Node.js 22)
  • A local WordPress environment (to install and test the plugin you build)

Note that WP-CLI's wp scaffold block command is deprecated (wp help scaffold block itself says to use the official @wordpress/create-block). This article uses create-block.

1. Scaffold the block with create-block

Run the following command in the wp-content/plugins folder.

cd wp-content/plugins
npx @wordpress/create-block@latest my-cta --variant dynamic --namespace myplugin --title "CTA Block"
Option Meaning
my-cta The plugin folder name (also the second half of the block name)
--variant dynamic Use the template for a dynamic block rendered in PHP (render.php)
--namespace myplugin The first half of the block name. The block name becomes myplugin/my-cta
--title The block's display name

It installs the required packages and runs the first build automatically, producing the following files (main ones only).

my-cta/
├── my-cta.php            # Main plugin file (registers the block)
├── package.json          # Build commands
├── src/my-cta/
│   ├── block.json        # Block metadata
│   ├── index.js          # Block registration (JS)
│   ├── edit.js           # Editor view and controls
│   ├── render.php        # Front-end HTML
│   ├── style.scss        # CSS for both the front end and the editor
│   ├── editor.scss       # Editor-only CSS
│   └── view.js           # JS that runs on the front end (delete it if unused)
└── build/                # Build output (this is what WordPress reads)

You edit the files in src. build is recreated on every build.

2. Register the block in the main plugin file

The generated my-cta.php (some headers and comments omitted) registers all blocks in the build folder like this:

<?php
/**
 * Plugin Name:       CTA Block
 * Requires at least: 6.8
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

function myplugin_my_cta_block_init() {
	wp_register_block_types_from_metadata_collection( __DIR__ . '/build', __DIR__ . '/build/blocks-manifest.php' );
}
add_action( 'init', 'myplugin_my_cta_block_init' );

wp_register_block_types_from_metadata_collection() was added in WordPress 6.8. It uses the blocks-manifest.php file generated at build time to register multiple blocks efficiently. With only one block, the older register_block_type( __DIR__ . '/build/my-cta' ); also works.

3. Define attributes and features in block.json

Rewrite src/my-cta/block.json as follows.

{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "myplugin/my-cta",
	"version": "0.1.0",
	"title": "CTA Block",
	"category": "widgets",
	"icon": "megaphone",
	"description": "A call-to-action block with a heading and a button",
	"attributes": {
		"heading": {
			"type": "string",
			"default": "Feel free to get in touch"
		},
		"buttonText": {
			"type": "string",
			"default": "Contact us"
		},
		"buttonUrl": {
			"type": "string",
			"default": ""
		}
	},
	"supports": {
		"html": false,
		"align": [ "wide", "full" ],
		"color": {
			"background": true,
			"text": true
		},
		"spacing": {
			"padding": true
		}
	},
	"textdomain": "my-cta",
	"editorScript": "file:./index.js",
	"editorStyle": "file:./index.css",
	"style": "file:./style-index.css",
	"render": "file:./render.php"
}

What the main fields mean:

  • attributes: the values the block holds. Here there are three: the heading, the button text and the link URL. They are saved in the block comment in the post content (<!-- wp:myplugin/my-cta {...} /-->). Attributes left at their default are not saved, and the default is used when the block is displayed.
  • supports: adds core WordPress features to the block. Turning on color adds background and text color settings to the editor, and get_block_wrapper_attributes() (below) adds the classes and styles for the chosen colors automatically. You do not need to create color attributes yourself.
  • render: the PHP file that produces the front-end HTML (added in WordPress 6.1). With it, you no longer need JavaScript to build the HTML to save.
  • file:./ entries such as editorScript: these point to files in build. Use the file names after the build (index.js, style-index.css and so on).

If you do not use view.js, delete src/my-cta/view.js and remove the viewScript line from block.json.

4. Produce the front-end HTML in render.php

Write src/my-cta/render.php as follows.

<?php
/**
 * Prints the front-end HTML.
 * $attributes (block attributes), $content (inner blocks) and $block (WP_Block) are available.
 */

$heading     = $attributes['heading'] ?? '';
$button_text = $attributes['buttonText'] ?? '';
$button_url  = esc_url( $attributes['buttonUrl'] ?? '' ); // Unsafe URLs become empty
?>
<div <?php echo get_block_wrapper_attributes(); ?>>
	<?php if ( '' !== $heading ) : ?>
		<p class="wp-block-myplugin-my-cta__heading"><?php echo wp_kses_post( $heading ); ?></p>
	<?php endif; ?>
	<?php if ( '' !== $button_url && '' !== $button_text ) : ?>
		<a class="wp-block-myplugin-my-cta__button" href="<?php echo $button_url; ?>">
			<?php echo esc_html( $button_text ); ?>
		</a>
	<?php endif; ?>
</div>
  • get_block_wrapper_attributes() outputs the block class (wp-block-myplugin-my-cta) together with the color, spacing and alignment classes and styles added by supports. Put it on the outer element.
  • The heading can have bold text or links added in the editor, so wp_kses_post() keeps only allowed tags; the button text is escaped with esc_html().
  • The URL goes through esc_url() first. URLs such as ones starting with javascript: become empty, and in that case the button is not shown.

For example, a block with a black background and white text produced the following HTML (whitespace tidied).

<div class="wp-block-myplugin-my-cta has-text-color has-white-color has-background has-black-background-color">
	<p class="wp-block-myplugin-my-cta__heading">Feel free to get in touch</p>
	<a class="wp-block-myplugin-my-cta__button" href="https://example.com/contact/">Contact us</a>
</div>

5. Build the editor view and controls in edit.js

Write src/my-cta/edit.js as follows. The heading is edited directly in the block, and the button text and URL are entered in the Settings sidebar on the right.

import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls, RichText } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
import './editor.scss';

export default function Edit( { attributes, setAttributes } ) {
	const { heading, buttonText, buttonUrl } = attributes;

	return (
		<>
			<InspectorControls>
				<PanelBody title={ __( 'Button settings', 'my-cta' ) }>
					<TextControl
						__next40pxDefaultSize
						__nextHasNoMarginBottom
						label={ __( 'Button text', 'my-cta' ) }
						value={ buttonText }
						onChange={ ( value ) => setAttributes( { buttonText: value } ) }
					/>
					<TextControl
						__next40pxDefaultSize
						__nextHasNoMarginBottom
						type="url"
						label={ __( 'Button link URL', 'my-cta' ) }
						value={ buttonUrl }
						onChange={ ( value ) => setAttributes( { buttonUrl: value } ) }
					/>
				</PanelBody>
			</InspectorControls>

			<div { ...useBlockProps() }>
				<RichText
					tagName="p"
					className="wp-block-myplugin-my-cta__heading"
					value={ heading }
					onChange={ ( value ) => setAttributes( { heading: value } ) }
					placeholder={ __( 'Enter a heading', 'my-cta' ) }
				/>
				<span className="wp-block-myplugin-my-cta__button">
					{ buttonText }
				</span>
			</div>
		</>
	);
}
  • useBlockProps() provides the attributes for the outer element in the editor. It is the counterpart of PHP's get_block_wrapper_attributes(), so color and alignment settings show up in the editor too.
  • Whatever is inside InspectorControls appears in the Settings sidebar on the right.
  • At build time, @wordpress/components is mapped to WordPress core's wp.components, so the build worked without installing it separately.

src/my-cta/index.js can stay as generated. Because this is a dynamic block, there is no save, and only the block comment (the attributes) is saved.

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

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

6. Write the CSS

src/my-cta/style.scss is loaded on both the front end and in the editor.

.wp-block-myplugin-my-cta {
	padding: 2rem;
	text-align: center;

	&__heading {
		margin: 0 0 1rem;
		font-size: 1.25rem;
		font-weight: bold;
	}

	&__button {
		display: inline-block;
		padding: 0.75em 1.5em;
		border-radius: 4px;
		background: #1d2327;
		color: #fff;
		text-decoration: none;
	}
}

Put editor-only styling (such as an outline) in editor.scss.

7. Build and activate

cd my-cta
npm run build   # Build src into build
npm start       # During development: rebuild automatically every time you save a file

After building, activate "CTA Block" on the Plugins screen. In the post editor, add a block and search for "CTA Block" to find it.

You can create a ZIP file for distribution with npm run plugin-zip. On the production site you only need my-cta.php and the build folder; node_modules and src are not needed.

Check with WP-CLI

# Is the block registered?
wp eval 'var_dump( WP_Block_Type_Registry::get_instance()->is_registered( "myplugin/my-cta" ) );'

# Find posts and pages that use this block
wp post list --post_type=post,page --s='wp:myplugin/my-cta' --fields=ID,post_title

When the block does not show up

  • The block is not in the inserter: check that you ran npm run build and that build/my-cta/block.json exists. Editing src without building does not change anything in WordPress.
  • Nothing appears on the front end: check that render in block.json is file:./render.php and that build/my-cta/render.php exists. Turn on WP_DEBUG_LOG and check the log for PHP errors.
  • Color settings are not applied: check that the outer element has get_block_wrapper_attributes() (PHP) and useBlockProps() (JS).
  • Page caching: dynamic block output is generated in PHP on every view, but with a caching plugin you may still see old HTML after fixing the block.

Related articles

Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)

お気軽にご相談ください

お見積りへ お問い合わせへ
▲