| name | add-block |
| description | Add a new Gutenberg block to an existing plugin or theme. |
Add a Gutenberg Block
Register a new block in an existing plugin or theme following WordPress block development conventions.
Step 0 — Understand the purpose
Ask the user: "What will this block do?" — a brief description of what it displays or enables in the editor.
Based on the description, suggest 2–3 name options (kebab-case), for example:
Based on your description, here are some suggestions:
| # | Block name |
|---|
| 1 | featured-card |
| 2 | promo-card |
| 3 | highlight-card |
Which would you like to use, or do you have your own preference?
- The user may pick one of the suggestions or provide their own name.
- Confirm the final choice before proceeding.
Once the name is confirmed, apply the find-plugin skill to determine whether:
- WordPress Core already provides this block or equivalent functionality (check the block inserter's built-in blocks and block patterns).
- A well-maintained plugin on WordPress.org already ships this block.
Only proceed to the steps below if Core and existing plugins do not cover the need, or if the user has explicitly agreed to build a custom block after reviewing the recommendation.
Step 1 — Gather remaining information
| Input | Required | Notes |
|---|
| Target package | Yes | Which theme or plugin should own this block? Scan packages/ and list options. |
| Block vendor prefix | Yes | The namespace prefix used in block.json name, e.g. acme → acme/hero-banner. Check sibling block.json files in the plugin to confirm. |
| Block category | No | One of text, media, design, widgets, theme, embed. Default: design. |
| Is it dynamic (server-rendered)? | No | If yes, save() returns null and a PHP render callback is provided. Defaults to yes for server-rendered projects. |
| Attributes | No | Ask what data the block needs to store (text, image URL, boolean toggle, etc.). |
| Needs frontend JS? | No | Does the block need a view.js for frontend interactivity? Defaults to no. |
Step 2 — Locate the blocks directory
Look for an existing block registration pattern in the target package. Typical location:
src/blocks/<block-name>/ (or src/<block-name>/ depending on plugin structure)
block.json
block.php (dynamic: registration + render via namespaced functions)
edit.js
index.js
save.js (omit if dynamic)
style.scss (front-end styles)
editor.scss (editor-only styles)
view.js (only if frontend interactivity is needed)
Read the plugin's main PHP file to confirm the PHP namespace and how blocks are registered/loaded.
Step 3 — Create block files
block.json
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "<vendor>/<block-name>",
"version": "1.0.0",
"title": "<Block Title>",
"category": "<category>",
"description": "",
"supports": {
"html": false,
"align": ["wide", "full"]
},
"attributes": {},
"textdomain": "<plugin-slug>",
"editorScript": "file:index.js",
"editorStyle": "file:index.css",
"style": "file:style-index.css"
}
- For dynamic blocks, omit
"render" — the render callback is registered in block.php.
- If the block needs frontend interactivity, add
"viewScript": "file:view.js" (omit otherwise).
- Add attributes as needed for the block's content.
block.php (dynamic blocks only)
<?php
declare( strict_types=1 );
namespace <PluginNamespace>\Blocks\<BlockNamePascal>;
function register_block(): void {
register_block_type_from_metadata(
__DIR__,
[
'render_callback' => __NAMESPACE__ . '\\render_block',
]
);
}
add_action( 'init', __NAMESPACE__ . '\\register_block' );
function render_block( array $attributes, string $content ): string {
$wrapper_attributes = get_block_wrapper_attributes();
ob_start();
?>
<div <?php echo $wrapper_attributes;
<?php
</div>
<?php
return ob_get_clean() ?: '';
}
- Match
namespace to the plugin's existing namespace (e.g. ProjectBase\BlockLibrary\Blocks\HeroBanner).
- Always use
get_block_wrapper_attributes() for the wrapper element.
- Always use
ob_start()/ob_get_clean() for template rendering.
- Escape all output:
esc_html(), esc_attr(), esc_url(), wp_kses_post().
index.js
import { registerBlockType } from '@wordpress/blocks';
import metadata from './block.json';
import Edit from './edit';
import save from './save';
registerBlockType( metadata.name, {
edit: Edit,
save,
} );
edit.js
import { useBlockProps, RichText, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
export default function Edit( { attributes, setAttributes } ) {
const { title } = attributes;
const blockProps = useBlockProps();
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Settings', '<text-domain>' ) }>
{ /* Add controls here */ }
</PanelBody>
</InspectorControls>
<div { ...blockProps }>
<RichText
tagName="h2"
value={ title }
onChange={ ( value ) => setAttributes( { title: value } ) }
placeholder={ __( 'Add a title…', '<text-domain>' ) }
/>
</div>
</>
);
}
save.js (static blocks only)
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save( { attributes } ) {
const { title } = attributes;
return (
<div { ...useBlockProps.save() }>
<RichText.Content tagName="h2" value={ title } />
</div>
);
}
view.js (only if frontend interactivity is needed)
document.querySelectorAll( '.wp-block-<vendor>-<block-name>' ).forEach( ( block ) => {
} );
Step 4 — Verify block auto-discovery
Open the plugin's main PHP file and confirm it loads blocks via glob:
foreach ( glob( __DIR__ . '/build/*/block.php' ) as $block ) {
require_once $block;
}
If the glob pattern is missing, add it. If the plugin uses a different registration pattern (e.g. a BlockRegistrar class), follow that pattern instead.
Step 5 — Build and lint
cd packages/plugins/<plugin-slug>
npm run build
npm run lint:js
npm run lint:css
composer lint
Confirm the block appears in build/<block-name>/ after the build.
Conventions
- Import grouping: Keep JS
import statements grouped: WordPress core → internal. The @wordpress/dependency-group ESLint rule is enforced
- Block wrapper: Always use
get_block_wrapper_attributes() in PHP render — this enables block spacing, align, and custom className support
- No jQuery: Use
@wordpress/* packages or native DOM APIs
Output
When done, the following files exist under src/<block-name>/:
block.json — block metadata
block.php — PHP server-side render + registration (dynamic blocks)
index.js — block registration entry point
edit.js — block editor component
save.js — static save output (static blocks only)
view.js — (optional) frontend script
After npm run build, the block appears in build/<block-name>/ and is loadable in the WordPress editor. Lint passes with no errors.