Skip to content

Add a Custom Block

This guide explains how to add a new server-rendered Gutenberg block to the tinnitus-blocks mu-plugin, which auto-discovers and registers blocks from subdirectories.

How auto-registration works

The tinnitus-blocks.php loader scans blocks/*/block.json on every init action and calls register_block_type() for each directory it finds:

function tinnitus_register_blocks(): void
{
    $block_jsons = glob(__DIR__ . '/blocks/*/block.json');

    if (false === $block_jsons) {
        return;
    }

    foreach ($block_jsons as $block_json) {
        register_block_type(dirname($block_json));
    }
}

This means you do not need to touch the loader. Just create a new directory with a block.json file and the block is registered automatically.

Step-by-step

1. Create the block directory

Create a new directory under web/app/mu-plugins/tinnitus-blocks/blocks/ with a hyphenated name matching your block:

web/app/mu-plugins/tinnitus-blocks/blocks/my-block/
    block.json
    render.php
    edit.js

2. Define block.json

The block.json file declares the block metadata. Use API version 3, the tinnitus/ namespace, and the tinnitus-content category (registered by the loader):

{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "tinnitus/my-block",
    "title": "My Block",
    "category": "tinnitus-content",
    "description": "Brief description of what this block renders.",
    "textdomain": "tinnitus",
    "editorScript": "file:./edit.js",
    "render": "file:./render.php",
    "supports": {
        "align": ["wide", "full"],
        "spacing": {
            "margin": true,
            "padding": true
        },
        "html": false
    }
}

Key fields:

  • name -- must use the tinnitus/ prefix (e.g., tinnitus/my-block)
  • category -- use tinnitus-content so the block appears in the project's custom category in the inserter
  • textdomain -- always tinnitus
  • render -- points to the PHP file for server-side rendering
  • editorScript -- points to the JS file for the block editor placeholder

If the block should not appear in the block inserter (e.g., it is only used in templates), add "inserter": false to supports.

For blocks that accept attributes, declare them in the attributes key:

{
    "attributes": {
        "count": {
            "type": "integer",
            "default": 6
        }
    }
}

3. Create render.php

The render template receives three variables from WordPress: $attributes, $content, and $block. Follow the same file-level conventions as all project PHP:

<?php

/**
 * My Block render template.
 *
 * @var array<string, mixed> $attributes Block attributes.
 * @var string               $content    Block inner content.
 * @var WP_Block             $block      Block instance.
 */

declare(strict_types=1);

defined('ABSPATH') || exit;

$wrapper_attributes = get_block_wrapper_attributes([
    'class' => 'tinnitus-my-block',
]);

printf('<div %s>', $wrapper_attributes);
echo esc_html__('My block content here.', 'tinnitus');
echo '</div>';

Escape all output

Follow the project's late-escaping rule. Use esc_html(), esc_attr(), esc_url() immediately before output. The one exception is get_block_wrapper_attributes(), which returns pre-escaped attributes.

For reference, the existing breadcrumbs block demonstrates guarding against a missing dependency:

if (! function_exists('rank_math_the_breadcrumbs')) {
    return;
}

$wrapper_attributes = get_block_wrapper_attributes([
    'class' => 'tinnitus-breadcrumbs tinnitus-footer-breadcrumbs',
]);
$aria_label         = esc_attr__('Breadcrumb', 'tinnitus');

printf('<nav %s aria-label="%s">', $wrapper_attributes, $aria_label);
rank_math_the_breadcrumbs();
echo '</nav>';

4. Create edit.js

The editor script provides a placeholder in the Site Editor. All custom JS follows the project's ES5 IIFE pattern -- no const/let, no arrow functions, no template literals:

( function () {
    var el = wp.element.createElement;
    var useBlockProps = wp.blockEditor.useBlockProps;

    wp.blocks.registerBlockType( 'tinnitus/my-block', {
        edit: function () {
            var blockProps = useBlockProps( {
                className: 'tinnitus-block-placeholder',
                style: {
                    padding: '12px 16px',
                    background: '#f0f0f0',
                    border: '1px dashed #ccc',
                    borderRadius: '4px',
                    color: '#757575',
                    fontSize: '13px',
                    fontStyle: 'italic',
                },
            } );
            return el( 'div', blockProps,
                el( 'span', null, 'My Block \u2014 rendered on frontend' )
            );
        },
    } );
} )();

The block name in registerBlockType() must exactly match the name in block.json.

5. Use the block in a template

Add the block to an FSE template (templates/*.html) or a pattern (patterns/*.php):

<!-- wp:tinnitus/my-block /-->

If the block has attributes:

<!-- wp:tinnitus/my-block {"count":3} /-->

6. Verify

  1. Start the local environment: docker compose up -d
  2. Visit http://localhost:8080 and verify the block renders on the frontend
  3. Visit the Site Editor and confirm the placeholder appears
  4. Run the quality checks:

    composer lint && composer lint:phpcs && composer analyse
    npm run lint:js
    

Block category

All project blocks appear under the "Tinnitus Content" category in the block inserter. This category is registered by the loader:

function tinnitus_block_categories(array $categories): array
{
    array_unshift($categories, [
        'slug'  => 'tinnitus-content',
        'title' => __('Tinnitus Content', 'tinnitus'),
    ]);

    return $categories;
}

If your block should appear in a different core category (e.g., text, media), change the category field in block.json. But for project-specific content blocks, prefer tinnitus-content.