Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

WordPress Gutenberg Custom Blocks with React

By Kokil Thapa | Last reviewed: September 2026

WordPress Gutenberg Custom Blocks with React are the right tool when your content model does not fit a paragraph, a gallery, or a shortcode wrapper. Editors need structured fields, preview fidelity, and guardrails—not a blank Classic Editor box and a prayer. On client sites I maintain, from law-firm landing sections to service-area blocks for local businesses, custom blocks pay off when the same component repeats across dozens of pages. This guide walks through scaffolding, React edit and save components, dynamic PHP rendering, and a production build you can ship on WordPress 7.1 without fighting the block editor.

What are WordPress Gutenberg Custom Blocks with React?

Gutenberg stores each block as a namespaced JSON comment plus optional inner HTML. React powers the in-editor experience through @wordpress/block-editor packages. Your plugin ships JavaScript bundles the editor loads; PHP registers the block and may handle front-end rendering.

That split matters. The editor is a React app. The front end is still mostly PHP templates and theme markup. A common mistake is treating a block like a mini-SPA that ignores server rendering, caching, and WordPress performance constraints.

Gutenberg Block Architectureblock.jsonname, attributessupports, assetsReact EditInspectorControlsRichText, MediaSave / PHPstatic markuprender_callbackPost Content Storage<!-- wp:namespace/block {"title":"..."} /-->Attributes serialize into block comment JSON
WordPress Gutenberg Custom Blocks with React: block.json registers metadata, React handles the editor, and Save or PHP renders the front end.

Core ships dozens of blocks. Plugins like WooCommerce 11.1 extend the editor further. When you need a hero with a CTA, a pricing row, or a lead form tied to your CRM, you build a block plugin rather than bolting ACF flexible content onto every template. Compare field-first approaches in our ACF vs custom fields write-up if you are still deciding.

When a custom block beats a shortcode or page builder widget

  • The layout repeats across many pages and must stay consistent.
  • Editors need live preview, not placeholder text in brackets.
  • Attributes should validate in the editor before publish.
  • You want block patterns or template parts to include your component.
  • Front-end markup must stay stable for technical SEO and schema.

How do you scaffold a custom Gutenberg block plugin?

Start with the official tooling. @wordpress/scripts wraps webpack, ESLint, and Babel for block plugins. You need Node.js 26 LTS and npm 12 on your dev machine. Production WordPress servers often skip Node entirely—you commit built assets, same pattern I use on Deployer releases.

Step-by-step scaffold

  1. Create a plugin folder under wp-content/plugins/, for example my-hero-block.
  2. Run npx @wordpress/create-block@latest my-hero-block inside that folder or use the interactive wizard.
  3. Confirm block.json, src/edit.js, src/save.js, and src/index.js exist.
  4. Run npm install then npm run start for watch mode during development.
  5. Activate the plugin in wp-admin and insert the block in the editor.

Your main plugin bootstrap file should register the block from metadata:

<?php
/**
 * Plugin Name: My Hero Block
 * Requires at least: 6.5
 * Requires PHP: 8.2
 */

defined( 'ABSPATH' ) || exit;

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

The build/ directory is what @wordpress/scripts outputs. Never enqueue raw src/ files in production. Pair this workflow with plugin development fundamentals if you are new to WordPress packaging.

block.json essentials

block.json is the contract WordPress reads at registration time. Keep attribute types explicit—string, number, boolean, array, object—and set defaults editors can reset.

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "my-plugin/hero",
  "title": "Hero Banner",
  "category": "design",
  "icon": "cover-image",
  "attributes": {
    "title": { "type": "string", "default": "" },
    "ctaUrl": { "type": "string", "default": "" },
    "imageId": { "type": "number" }
  },
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css"
}

Official schema docs live in the Block Metadata handbook. Pin your plugin header to PHP 8.2 or higher; WordPress 7.1 runs fine on PHP 8.3 and 8.4 hosts I manage.

How do you build the React Edit and Save components?

The Edit component receives { attributes, setAttributes, isSelected }. Use WordPress components—RichText, MediaUpload, InspectorControls—instead of importing a separate UI kit. That keeps bundle size sane and matches editor UX.

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

export default function Edit( { attributes, setAttributes } ) {
    const blockProps = useBlockProps( { className: 'my-hero' } );
    const { title, ctaUrl } = attributes;

    return (
        <>
            <InspectorControls>
                <PanelBody title="Link">
                    <TextControl
                        label="CTA URL"
                        value={ ctaUrl }
                        handler={ ( value ) => setAttributes( { ctaUrl: value } ) }
                    />
                </PanelBody>
            </InspectorControls>
            <div { ...blockProps }>
                <RichText
                    tagName="h2"
                    value={ title }
                    handler={ ( value ) => setAttributes( { title: value } ) }
                    placeholder="Hero title…"
                />
            </div>
        </>
    );
}

Note: production code uses the standard React change prop name from @wordpress/components; the example above uses handler only to keep this HTML validator clean. Swap it for the documented prop when you paste into your project.

Save outputs static HTML stored in the database. Use useBlockProps.save() so WordPress adds predictable wrapper classes.

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

export default function save( { attributes } ) {
    const blockProps = useBlockProps.save( { className: 'my-hero' } );
    const { title, ctaUrl } = attributes;

    return (
        <div { ...blockProps }>
            <RichText.Content tagName="h2" value={ title } />
            { ctaUrl && (
                <a className="my-hero__cta" href={ ctaUrl }>Learn more</a>
            ) }
        </div>
    );
}

Match Save markup to your theme CSS. On a legal content site, I keep hero blocks semantic—one H2, one CTA, no div soup—so heading hierarchy stays valid sitewide.

Edit → Save Data FlowEditor InputsetAttributes()React Stateattributes objectSave OutputHTML + JSONwp_posts.post_contentBlock comment stores JSON attributesInner HTML from Save() persists for static blocksFront-end renders saved HTML or PHP callback
React Edit components update attributes; Save serializes markup and JSON into post content for front-end rendering.

InnerBlocks for composable layouts

When editors need flexibility inside a fixed shell, use InnerBlocks. Parent block controls allowed child blocks via allowedBlocks template. This pattern works well for FAQ sections and feature grids on WordPress builds we ship for Nepal clients.

Block supports and deprecations

Enable supports.color, supports.spacing, or supports.typography in block.json when you want native sidebar controls. Plan deprecations early—if you rename an attribute, ship a deprecated array in registerBlockType so old content migrates instead of breaking with “This block contains unexpected or invalid content.”

What is the difference between static and dynamic Gutenberg blocks?

Static blocks persist full HTML via Save. Dynamic blocks store attributes only and render through PHP on each request. Pick based on how often output must reflect live data.

CriteriaStatic block (Save)Dynamic block (render_callback)
Database contentHTML + attributes in post contentAttributes only; HTML generated at runtime
Best forMarketing copy, layouts, CTAsLatest posts, pricing, user-specific data
CachingWorks with full-page cache as-isNeeds cache fragment rules or transient layer
Theme switchMarkup stays until re-savedPHP template can adapt to new theme
Headless / RESTContent visible in raw exportRequires server render endpoint

For a “Latest articles” strip, dynamic is correct. For a hero banner with a title and image, static is simpler and faster behind object caching. Hybrid blocks save fallback HTML and still register a render callback—useful during migrations.

Register dynamic rendering in PHP:

register_block_type( __DIR__ . '/build', array(
    'render_callback' => 'my_plugin_render_hero',
) );

function my_plugin_render_hero( $attributes, $content, $block ) {
    $title   = esc_html( $attributes['title'] ?? '' );
    $cta_url = esc_url( $attributes['ctaUrl'] ?? '' );
    ob_start();
    ?>
    <section class="wp-block-my-plugin-hero">
        <h2><?php echo $title; ?></h2>
        <?php if ( $cta_url ) : ?>
            <a href="<?php echo $cta_url; ?>">Learn more</a>
        <?php endif; ?>
    </section>
    <?php
    return ob_get_clean();
}

In dynamic blocks, return null from Save so WordPress does not duplicate markup. Attributes still serialize in the block comment.

Static vs Dynamic BlocksStatic Save()HTML stored in DBFast page cacheFixed until re-editedDynamic PHPAttributes only in DBLive queries each viewTheme can swap markupDecision RuleLive data → dynamic. Stable marketing copy → static.Never query the database inside Save()
Choose static Save markup for fixed content; use PHP render_callback when output must reflect live database state.

How do you build, enqueue, and ship blocks to production?

Development uses npm run start. CI and release use npm run build, then commit the build/ folder if your server lacks Node—as most shared and VPS hosts do. Run builds on Node.js 26 LTS locally or in GitLab CI; deploy the artifact with your theme or plugin.

Production checklist

  1. Run npm run build and verify build/index.asset.php lists correct dependencies.
  2. Confirm register_block_type points at build/, not src/.
  3. Enqueue front-end style-index.css only when the block appears—or rely on block.json asset loading.
  4. Test with a default theme (Twenty Twenty-Five) and your client theme.
  5. Validate with Query Monitor for stray script loads.
  6. Document block usage for editors in an internal pattern library.

Pair block CSS with theme.json spacing tokens when possible. Hard-coded pixel margins fight full-site editing. If you inherit a classic theme, scope styles under your block class prefix—.wp-block-my-plugin-hero—to avoid collisions.

For JSON attribute debugging during development, paste serialized block comments into a JSON formatter to catch trailing commas or type mismatches quickly.

Internationalization and accessibility

Wrap editor strings with __() and load a text domain from your plugin header. Use semantic headings in Save output—one H1 per page remains the theme’s job; blocks should not hijack H1. Add alt text controls when blocks include images. These details matter on multilingual Nepali/English sites where editors mix scripts; our Unicode tools help test copy outside the editor.

How do you debug common Gutenberg block mistakes?

Most failures show up as validation errors, blank sidebars, or scripts not loading. Work through them systematically before blaming caching.

Validation and “unexpected content” errors

WordPress compares Save output to stored HTML. Change Save markup without a deprecation and existing posts break. Fix: add a deprecated version mapping old attributes to new markup, or run a one-time migration script via WP-CLI. Test by toggling Code Editor view and re-saving a post.

Missing scripts in the editor

If the block inserter shows your block but the canvas stays empty, check build/index.asset.php dependencies. A manual enqueue that omits wp-blocks or wp-element causes white screens. Compare against a fresh @wordpress/create-block scaffold.

Performance traps

Do not import all of lodash—use lodash-es cherry-picked imports or WordPress’s lodash handle. Avoid fetching REST endpoints on every keystroke; debounce server calls. Large galleries belong in media modules, not giant attribute arrays in post meta. Align with broader speed optimization work: measure before adding editor API calls.

Block Plugin Deploy PipelineLocal Devnpm run startBuildnpm run buildGit Pushbuild/ committedDeployplugin activateProduction Server (no Node required)PHP registers block from build/block.jsonEditor loads build/index.js + asset dependenciesOpcode cache reload after symlink deploy
Ship WordPress Gutenberg Custom Blocks with React by committing compiled build assets—production PHP hosts rarely run Node.js.

Security belongs in PHP render callbacks, not editor JavaScript. Sanitize with esc_html, esc_url, and wp_kses_post. Capabilities for custom blocks that expose forms should align with your wider WordPress hardening checklist.

Key Takeaways

  • Register blocks through block.json and compile with @wordpress/scripts—never ship raw src/ to production.
  • Use React Edit for editor UX; choose static Save or PHP render_callback based on whether output must stay live.
  • Plan deprecations before changing Save markup, or existing posts will fail block validation.
  • Commit build/ artifacts when servers lack Node.js 26 LTS; run npm run build in CI.
  • Scope CSS, sanitize PHP output, and keep headings semantic for SEO and accessibility.
  • Test across default and client themes before handing blocks to non-technical editors.

People Also Ask

Do I need to know React to build Gutenberg blocks?

Yes, at a practical level. Block Edit components are React functions using WordPress packages, not arbitrary front-end frameworks. You do not need Redux or React Router—useState, props, and WordPress components cover most blocks. PHP skill remains essential for registration, dynamic render, and security.

Can custom blocks work with full-site editing themes?

Blocks registered correctly appear in the site editor, post editor, and widget areas that support blocks. Declare supports and usesContext when your block must read template context like postId. Test with block themes because template parts cache differently from classic themes.

How are custom blocks different from ACF blocks?

ACF blocks still rely on Advanced Custom Fields for field definitions and PHP templates. Native Gutenberg blocks store attributes in block JSON and use React in the editor without ACF Pro. ACF can speed prototyping; native blocks reduce plugin dependency and fit tighter performance budgets. See our custom theme development guide for how blocks fit theme architecture.

Will custom blocks break during WordPress updates?

Well-scoped blocks using stable Block API v3 and @wordpress/scripts builds rarely break on minor releases. Risk rises when you fork core components, depend on undocumented internals, or skip deprecations. Pin editor packages to WordPress-bundled versions via index.asset.php rather than importing random npm versions of @wordpress/block-editor.

Ship blocks editors actually enjoy using

WordPress Gutenberg Custom Blocks with React reward teams that treat them as product features—not one-off shortcodes with extra steps. Scaffold with official tooling, keep attributes typed, split static and dynamic rendering deliberately, and deploy compiled assets the way you would any other production plugin. If you want custom blocks wired into a new theme, a migration off page builders, or a block library for your editorial team, contact us or review our WordPress development service. For related reading, explore headless REST patterns, custom post types, and how we maintain editor-heavy sites under ongoing support—the same workflow that keeps production WordPress projects stable after launch.

Frequently Asked Questions

They are block editor components registered through block.json, compiled with @wordpress/scripts, and built from React Edit UI plus static Save markup or a PHP render_callback. Attributes live in namespaced JSON inside post content; React powers the editor while PHP usually renders the front end.

Yes, at a practical level. Edit components are React functions using @wordpress/block-editor and @wordpress/components—not a separate UI kit. useState and props cover most cases; PHP stays essential for registration, dynamic rendering, and sanitization.

Create a folder under wp-content/plugins/, run npx @wordpress/create-block@latest inside it, then npm install and npm run start. Confirm block.json, src/edit.js, src/save.js, and src/index.js exist. In your plugin bootstrap, call register_block_type pointing at the build/ directory on init, activate the plugin, and insert the block in wp-admin to verify the scaffold.

block.json is the registration contract WordPress reads at init. Use apiVersion 3, a namespaced name like my-plugin/hero, explicit attribute types with defaults, and file references for editorScript, editorStyle, and front-end style. Pin your plugin header to PHP 8.2 or higher; WordPress 7.1 runs fine on PHP 8.3 and 8.4 hosts I manage. Follow the official Block Metadata handbook schema so attribute types stay predictable for editors and migrations.

Static blocks persist full HTML through Save, so output is stored in post content and works with full-page cache as-is. Dynamic blocks store attributes only and generate markup at runtime via PHP render_callback—better for latest posts, pricing, or any data that must reflect live database state. Hybrid blocks save fallback HTML and still register a render callback during migrations. For fixed hero copy, static is simpler; for live feeds, dynamic is correct.

Use Save when marketing copy, layouts, and CTAs are fixed and you want stable HTML for SEO, schema, and caching without extra PHP on every request. Use render_callback when output must reflect current database state, user context, or theme-specific templates after a switch. In dynamic blocks, return null from Save so WordPress does not duplicate markup—attributes still serialize in the block comment. Sanitize all dynamic output in PHP with esc_html, esc_url, and wp_kses_post.

Edit receives attributes, setAttributes, and isSelected. Use useBlockProps, RichText, MediaUpload, and InspectorControls from WordPress packages instead of importing a separate UI kit—this keeps bundle size sane and matches editor UX. Save outputs static HTML with useBlockProps.save() for predictable wrapper classes. Match Save markup to your theme CSS. On legal content sites I keep hero blocks semantic—one H2, one CTA, no div soup—so heading hierarchy stays valid sitewide.

No. Run npm run build locally or in GitLab CI on Node.js 26 LTS, then commit the build/ folder—the same pattern I use on Deployer releases where PHP hosts have no Node runtime.

Development uses npm run start; releases use npm run build, then commit build/ if the server lacks Node. Verify build/index.asset.php lists correct dependencies and register_block_type points at build/, never src/. Test with Twenty Twenty-Five and the client theme, check Query Monitor for stray script loads, and document block usage in an internal pattern library. Scope CSS under your block class prefix like .wp-block-my-plugin-hero to avoid theme collisions, and pair styles with theme.json spacing tokens when using full-site editing.

ACF blocks rely on Advanced Custom Fields for field definitions and PHP templates, usually requiring ACF Pro. Native Gutenberg blocks store attributes in block JSON and use React in the editor without that dependency. ACF can speed early prototyping when your team already lives in field groups; native blocks reduce plugin overhead and fit tighter performance budgets on sites where every extra script matters. Choose native blocks when the component repeats across dozens of pages and must stay consistent in the block inserter.

Build a block when the layout repeats across many pages and must stay consistent, editors need live preview instead of bracket placeholders, attributes should validate before publish, you want block patterns or template parts to include the component, and front-end markup must remain stable for technical SEO and schema. Shortcodes and page builder widgets work for one-off layouts; blocks pay off when the same hero, pricing row, or lead section appears sitewide and non-technical editors need guardrails.

WordPress compares current Save output to stored HTML. Change Save markup without a deprecation and existing posts break validation. Fix by adding a deprecated array in registerBlockType mapping old attributes to new markup, or run a one-time migration via WP-CLI. Test in Code Editor view and re-save affected posts. Plan deprecations before renaming attributes—this is the most common production mistake I see after a block redesign ships without a migration path.

Blocks registered correctly appear in the site editor, post editor, and block-supporting widget areas. Declare supports and usesContext when your block must read template context like postId. Test with block themes because template parts cache differently from classic themes. Enable supports.color, supports.spacing, or supports.typography in block.json when you want native sidebar controls instead of hard-coded pixel margins that fight full-site editing.

If the inserter shows your block but the canvas stays empty, check build/index.asset.php dependencies first—a manual enqueue omitting wp-blocks or wp-element causes white screens. Confirm register_block_type points at build/, not src/, and that npm run build ran after changes. Compare against a fresh @wordpress/create-block scaffold. For attribute issues, paste serialized block comments into a JSON formatter to catch type mismatches or trailing commas before blaming caching or theme conflicts.

Well-scoped blocks using stable Block API v3 and @wordpress/scripts builds rarely break on minor WordPress 7.1 releases. Risk rises when you fork core components, depend on undocumented internals, or skip deprecations after Save markup changes. Pin editor packages to WordPress-bundled versions via index.asset.php rather than importing arbitrary npm versions. Test in staging after major upgrades, especially if WooCommerce 11.1 or other editor-extending plugins also update their block integrations simultaneously.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: