Home › Blog › Article

Building a Custom WordPress Block: block.json and Styles

Building a Custom WordPress Block: block.json and Styles

Create a block plugin folder, define metadata in block.json, register it with register_block_type(), enqueue separate editor and front-end styles, then build with @wordpress/scripts. It is done right when the block appears in the inserter, saves without validation errors, renders identically in the editor and front end, and survives a cacheless reload. This tutorial targets WordPress 6.9 / Gutenberg 20.x in 2026 and follows 准备 → 步骤 → 验证 → 回滚 so every command is repeatable on staging.

准备: Environment, file paths, and assumptions

Work on a staging copy of a WordPress 6.9 site with PHP 8.3, Node.js 22 LTS, npm 10, and WP-CLI 2.11. Use a child-free plugin, not a theme, so content stays portable if the theme changes. The plugin path used throughout is wp-content/plugins/rk-callout/. If your team already ships bespoke blocks through Custom WordPress Development, keep the same namespace and build tooling to avoid duplicate asset pipelines.

Enable debug logging before touching code. Add define( 'WP_DEBUG', true );, define( 'WP_DEBUG_LOG', true );, and define( 'SCRIPT_DEBUG', true ); to wp-config.php, then confirm wp-content/debug.log is writable. Our WordPress Error Log Setup: Config, Privacy & Reviews guide covers log retention and redaction if this is a client production clone.

mkdir -p wp-content/plugins/rk-callout/src
cd wp-content/plugins/rk-callout
npm init -y
npm install --save-dev @wordpress/scripts@30.0.0 @wordpress/blocks@14.0.0
npx wp-scripts --version
wp plugin list --status=inactive | grep rk-callout || true

Add the plugin header and a minimal loader now. The loader will later call register_block_type; keeping it separate from src/index.js makes PHP-side rollback a one-line comment.

<?php
/**
 * Plugin Name: RK Callout
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.2
 * Text Domain: rk-callout
 */

defined( 'ABSPATH' ) || exit;

add_action( 'init', 'rk_callout_register' );
function rk_callout_register() {
    register_block_type( __DIR__ . '/build' );
}

Common pitfall: running npm install inside the theme, or using Node 18 with @wordpress/scripts 30.x. Both produce cryptic EBADENGINE or missing block.json copy errors. Pin versions in package-lock.json and commit the lockfile. Rollback at this stage is simply rm -rf wp-content/plugins/rk-callout plus removing the three debug constants if they were added only for this exercise.

Step Process: Build, register, style, verify, roll back

  1. Scaffold block.json and src/index.js with explicit metadata and attributes.
  2. Wire register_block_type() in PHP against the generated build/ directory.
  3. Split editor-only CSS from shared front-end CSS using editorStyle and style handles.
  4. Register a block pattern that composes the new block with a core heading.
  5. Run the build, activate, and verify in the editor, REST metadata, and front-end markup.
  6. Roll back by deactivating, restoring the previous build directory, and clearing caches.

block.json fields

block.json is the canonical manifest since WordPress 5.8 and is mandatory for API version 3 blocks in WordPress 6.9. It drives server-side registration, asset enqueueing, the inserter, and WP_Block_Type_Registry. Do not duplicate these values in PHP; duplicate title or attributes is the most common cause of editor/front-end drift. See the block metadata reference for the full schema.

Create the manifest

Save this as wp-content/plugins/rk-callout/src/block.json. Note apiVersion: 3, explicit textdomain, and three asset fields. viewScript is intentionally omitted because this callout is static; adding it without a front-end JS file creates a 404 on every page.

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "rk/callout",
  "version": "1.0.0",
  "title": "RK Callout",
  "category": "design",
  "icon": "megaphone",
  "description": "A styled callout with a tone attribute.",
  "keywords": [ "notice", "callout", "alert" ],
  "textdomain": "rk-callout",
  "attributes": {
    "tone": { "type": "string", "default": "info" },
    "label": { "type": "string", "default": "Note", "source": "html", "selector": ".rk-callout__label" }
  },
  "supports": {
    "align": [ "wide", "full" ],
    "color": { "background": true, "text": true },
    "spacing": { "padding": true, "margin": true },
    "html": false
  },
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": [ "file:./style-index.css", "rk-callout-front" ],
  "example": {
    "attributes": { "tone": "info", "label": "Note" }
  }
}

Field-by-field decisions

FieldWhy it mattersVerification
nameNamespace/name becomes the block comment <!-- wp:rk/callout --> and the REST identifier.wp eval 'echo (string) WP_Block_Type_Registry::get_instance()->is_registered( "rk/callout" );' returns 1.
apiVersionVersion 3 enables the 2026 iframe-iframe-less editor alignment and stricter attribute hydration.Block editor console shows no apiVersion deprecation notice.
attributessource + selector must match saved markup exactly or you get block validation errors.Edit, save, reload: no This block contains unexpected content banner.
supports.html: falsePrevents manual HTML edits from breaking the label selector.Editor three-dot menu has no Edit as HTML item.
editorScript / editorStyle / styleResolved relative to build/ after wp-scripts copies the manifest.ls build/block.json build/index.js build/index.css build/style-index.css.

Pitfall: referencing file:./style.css while your source file is named style-index.css. wp-scripts build will happily emit the CSS but WordPress will enqueue a missing handle and the front end will look unstyled. Another pitfall is adding a second handle (rk-callout-front) without registering it; either register it with wp_register_style() or remove it. Roll back a bad manifest by restoring src/block.json from Git, rerunning npm run build, and hard-reloading the editor with SCRIPT_DEBUG on.

Get a Quote

Email + requirement + budget range — we reply with a quote within 24 hours.

register_block_type

register_block_type() reads the compiled build/block.json, registers the block type, and auto-registers every file: asset handle. In 2026 the directory-argument form is preferred over the legacy register_block_type( 'rk/callout', array( ... ) ) because the PHP array can silently override JSON and drift after a deploy. Keep the callback on init at priority 10; registering on plugins_loaded fails because translations and the block registry are not ready.

Complete the PHP loader

Replace the earlier loader with the version below. It registers the extra front-end handle referenced by style, guards against a missing build directory, and logs a clear message instead of fatally erroring on a half-deployed release.

<?php
/**
 * Plugin Name: RK Callout
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.2
 * Text Domain: rk-callout
 */

defined( 'ABSPATH' ) || exit;

add_action( 'init', 'rk_callout_register' );
function rk_callout_register() {
    $build = __DIR__ . '/build';

    if ( ! file_exists( $build . '/block.json' ) ) {
        error_log( 'RK Callout: build/block.json missing; run npm run build.' );
        return;
    }

    wp_register_style(
        'rk-callout-front',
        plugins_url( 'assets/front-extra.css', __FILE__ ),
        array(),
        '1.0.0'
    );

    $type = register_block_type( $build );

    if ( is_wp_error( $type ) ) {
        error_log( 'RK Callout: ' . $type->get_error_message() );
    }
}

add_action( 'init', 'rk_callout_load_textdomain' );
function rk_callout_load_textdomain() {
    load_plugin_textdomain( 'rk-callout', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
}

Write the edit and save functions

src/index.js below uses registerBlockType from @wordpress/blocks, InspectorControls for the tone, and useBlockProps so align and color supports are applied automatically. The save markup must match the label selector in block.json character for character.

import { registerBlockType } from '@wordpress/blocks';
import { InspectorControls, useBlockProps, RichText } from '@wordpress/block-editor';
import { SelectControl } from '@wordpress/components';
import metadata from './block.json';

registerBlockType( metadata.name, {
    ...metadata,
    edit: ( { attributes, setAttributes } ) => {
        const props = useBlockProps( {
            className: `rk-callout rk-callout--${ attributes.tone }`,
        } );
        return (
            <>
                <InspectorControls>
                    <SelectControl
                        label="Tone"
                        value={ attributes.tone }
                        options={ [
                            { label: 'Info', value: 'info' },
                            { label: 'Warning', value: 'warning' },
                            { label: 'Success', value: 'success' },
                        ] }
                        onChange={ ( tone ) => setAttributes( { tone } ) }
                    />
                </InspectorControls>
                <div { ...props }>
                    <RichText.Content
                        tagName="span"
                        className="rk-callout__label"
                        value={ attributes.label }
                        onChange={ ( label ) => setAttributes( { label } ) }
                    />
                </div>
            </>
        );
    },
    save: ( { attributes } ) => {
        const props = useBlockProps.save( {
            className: `rk-callout rk-callout--${ attributes.tone }`,
        } );
        return (
            <div { ...props }>
                <span className="rk-callout__label">{ attributes.label }</span>
            </div>
        );
    },
} );

Add scripts to package.json and build:

npm pkg set scripts.build='wp-scripts build' scripts.start='wp-scripts start'
npm pkg set scripts.check='wp-scripts lint-js src/index.js'
mkdir -p assets
printf '.rk-callout { border-left: 4px solid currentColor; }n' > assets/front-extra.css
npm run build
wp plugin activate rk-callout

Verify with wp eval 'print_r( WP_Block_Type_Registry::get_instance()->get_registered( "rk/callout" )->get_attributes() );' and by requesting /wp-json/wp/v2/block-types/rk%2Fcallout. Pitfalls: forgetting wp plugin activate and blaming PHP; registering on admin_init so the REST endpoint 404s; or shipping build/ in .gitignore while the deploy has no build step. Rollback is wp plugin deactivate rk-callout, then comment out the register_block_type( $build ) line if you need the plugin active for other patterns. Existing post content stays as an unrecognized wp:rk/callout comment and renders as plain paragraph-safe markup until the plugin returns.

editor vs front-end styles

Editor styles and front-end styles solve different problems. editorStyle is loaded inside the block editor iframe and may target .editor-styles-wrapper, selection outlines, and placeholder states. style is enqueued both in the editor and on the public site, so it must contain only the visual contract of the saved markup. Keeping chrome-only rules out of style prevents layout shifts when a client switches from the editor preview to the published page, a detail our Common WordPress SEO Issues Illustrated: Vendor Guide 2026 article also flags for CLS-sensitive templates.

Shared front-end stylesheet

Create src/style-index.css. These rules are the single source of truth for tone colors and spacing.

.rk-callout {
  --rk-accent: #2271b1;
  display: block;
  padding: 1rem 1.25rem;
  border-left: 4px solid var(--rk-accent);
  border-radius: 2px;
  background: color-mix(in srgb, var(--rk-accent) 8%, #fff);
  font-size: 1rem;
  line-height: 1.5;
}
.rk-callout--warning { --rk-accent: #d63638; }
.rk-callout--success { --rk-accent: #00a32a; }
.rk-callout__label {
  display: inline-block;
  font-weight: 600;
  letter-spacing: .02em;
  color: var(--rk-accent);
}

Editor-only stylesheet

Create src/index.css and import it from src/index.js with import './index.css'; so wp-scripts emits build/index.css for editorStyle. Never put these rules in style-index.css.

.editor-styles-wrapper .rk-callout {
  outline: 1px dashed rgba(34, 113, 177, .45);
  outline-offset: 3px;
}
.editor-styles-wrapper .rk-callout__label:empty::before {
  content: 'Add a label…';
  opacity: .55;
}
.block-editor-block-list__block[data-type='rk/callout'] {
  margin-block: 1.25rem;
}

Verify the split

Run npm run build && wp cache flush, open a post, and inspect the Network tab: index.css appears only under wp-admin/post.php, while style-index.css and front-extra.css appear on both the editor iframe and the published URL. Use wp eval 'echo wp_style_is( "rk-callout-front", "enqueued" ) ? "yes" : "no";' inside a template_redirect hook if you need server-side proof. Pitfall: using color-mix() without a fallback on a site still serving to legacy embedded browsers; add a plain background: #f5f8fb; line above it. Pitfall two: caching plugins serving last year’s style-index.css because the file name has no hash; bump version in block.json to 1.0.1 on every CSS deploy. Roll back by restoring the previous build/ tarball (tar -xzf build-1.0.0.tgz), flushing the object cache, and purging the CDN by URL, not by full-site purge.

block patterns

Block patterns are registered content presets, not block types. They let editors insert a heading plus your callout in one click without touching code, and they are the fastest way to prove custom WordPress block development delivers editor value rather than just a new inserter icon. Register on init after the block is registered, using register_block_pattern() from wp-includes/class-wp-block-patterns-registry.php; the official function reference documents the content, viewportWidth, and blockTypes keys.

Register the pattern in PHP

Append this to the loader. blockTypes makes the pattern appear in the callout block’s transform/quick-inserter suggestions in WordPress 6.9, and postTypes limits it to pages and posts so it does not clutter the site editor’s template patterns.

add_action( 'init', 'rk_callout_pattern', 11 );
function rk_callout_pattern() {
    register_block_pattern_category(
        'rk-components',
        array( 'label' => __( 'RK Components', 'rk-callout' ) )
    );

    register_block_pattern( 'rk/callout-with-heading', array(
        'title'       => __( 'Callout with heading', 'rk-callout' ),
        'description' => __( 'A section heading followed by an info callout.', 'rk-callout' ),
        'categories'  => array( 'rk-components' ),
        'postTypes'   => array( 'post', 'page' ),
        'blockTypes'  => array( 'rk/callout', 'core/heading' ),
        'viewportWidth' => 700,
        'content'     => '<!-- wp:heading {"level":3} -->'
            . '<h3 class="wp-block-heading">Before you deploy</h3>'
            . '<!-- /wp:heading -->'
            . '<!-- wp:rk/callout {"tone":"info","label":"Note"} /-->',
    ) );
}

Verify and common failures

Open a new page, press /, type Callout with heading, and insert it. Then run wp eval 'print_r( WP_Block_Patterns_Registry::get_instance()->get_registered( "rk/callout-with-heading" )[ "blockTypes" ] );'. Save and inspect wp post content via wp post get <ID> --field=content: you should see exactly two block comments and no raw <h3> outside a wp:heading wrapper. Pitfalls: priority 10 or lower can race the block registration on multisite; malformed serialized JSON inside the comment (curly quotes copied from a design doc) makes the pattern insert as a classic block; and forgetting __() means the pattern title is excluded from the POT file. Roll back with unregister_block_pattern( 'rk/callout-with-heading' ) on init priority 12, or remove the two function calls and flush the pattern cache with wp cache flush. Patterns already inserted into posts remain ordinary blocks, so no content migration is needed.

验证: End-to-end acceptance test

验证: End-to-end acceptance test

Run this sequence on staging and record the output in the deploy ticket. It covers metadata, registration, assets, save/load parity, and pattern discovery in under two minutes.

npm run check
npm run build
wp plugin activate rk-callout
wp cache flush
wp eval 'echo WP_Block_Type_Registry::get_instance()->is_registered( "rk/callout" ) ? "BLOCK OK" : "BLOCK FAIL";'
curl -s https://staging.example.com/wp-json/wp/v2/block-types/rk%2Fcallout | grep -o '"name":"rk/callout"'
curl -sI https://staging.example.com/wp-content/plugins/rk-callout/build/style-index.css | head -1
tail -n 20 wp-content/debug.log

Then perform the human checks: insert the block, change tone to warning, edit the label, save, hard-reload the editor, and compare the published page screenshot with the editor preview. A passing result has zero console errors, zero debug.log lines prefixed RK Callout:, identical border color in both contexts, and the pattern available from the slash inserter. If the editor shows the block but the front end shows raw <!-- wp:rk/callout --> text, the plugin was deactivated between save and render, not a CSS problem.

回滚: Safe reversal order

Roll back in the reverse order of deployment so cached assets never reference a missing manifest. First wp plugin deactivate rk-callout. Second, restore the prior build/ directory from the release artifact or git checkout HEAD~1 -- build/ if build output is committed. Third, remove the pattern registration calls or add the unregister_block_pattern() shim at priority 12. Fourth, wp cache flush and purge only /wp-content/plugins/rk-callout/* at the CDN. Fifth, re-run the acceptance curl commands and confirm the REST endpoint returns 404 and debug.log stays quiet.

Do not delete the plugin folder while posts still contain wp:rk/callout comments unless you have already migrated them with wp search-replace 'wp:rk/callout' 'wp:paragraph' --dry-run first. Keep the plugin deactivated but present for one release cycle; WordPress renders unknown block comments as harmless, invisible markers, and editors get a clean Classic/Unrecognized recovery prompt instead of a broken page. If a bad attributes.selector already caused validation errors in saved posts, restore the previous src/index.js save() output, rebuild, reactivate, and let the editor’s block recovery dialog repair each post before attempting any migration.

Frequently asked questions

Do I still need register_block_type if block.json exists?

Yes. block.json is data only; WordPress never scans plugin folders automatically. The init callback calling register_block_type( __DIR__ . '/build' ) is what parses the manifest, registers asset handles, and adds the type to the REST API. Without it the inserter shows nothing and debug.log stays empty.

Why does my front end lose styles but the editor looks correct?

Almost always a style path mismatch or a stale cached CSS file. Confirm build/style-index.css exists, bump version in block.json, run wp cache flush, and verify every extra handle in the style array is registered with wp_register_style() before init priority 10 finishes.

Can a block pattern replace a custom block entirely?

No. A pattern composes existing blocks and stores serialized markup; it has no attributes, no inspector controls, and no save() logic. Use the custom block for the tone attribute and validation, then use the pattern to make that block discoverable alongside a heading. They are complementary layers.

Action checklist

  • Pin WordPress 6.9, PHP 8.3, Node 22, and @wordpress/scripts@30.0.0; commit package-lock.json.
  • Keep one source of truth: src/block.json for metadata, PHP only for register_block_type() and patterns.
  • Ship shared CSS in style-index.css and editor chrome only in index.css; bump the manifest version on every asset deploy.
  • Run the six-command acceptance sequence and screenshot editor versus front end before tagging a release.
  • Keep the previous build/ artifact for one release cycle so rollback never requires a rebuild under incident pressure.

Need this block hardened for a client multisite or migrated into an existing design system? Our team ships audited block plugins, patterns, and CI build steps. Review the services page or browse the technical column index, then request a scoped build estimate.

References & further reading

Need WordPress Development?

Free proposal and quote within 24 hours — we serve clients worldwide.

QQ