Building Wordpress Gutenberg blocks with React and PHP

Posted by Fabian McElhill - 3 min read
Building Wordpress Gutenberg blocks with React and PHP

In this article we’ll explore a common approach to building modern Gutenberg blocks using React in the editor and PHP to handle the frontend rendering.

Why React and PHP works well

React handles:

  1. Editor controls
  2. Live previews
  3. Drag-and-drop interactions
  4. Block settings

PHP handles:

  1. Database queries
  2. Custom post types
  3. ACF integration
  4. Dynamic content
  5. Caching
  6. Server-side rendering

The structure

Each block lives in resources/js/blocks/<block-name>/:

resources/js/blocks/text-block/
├── block.json   # block metadata and attributes
├── index.js     # registers the block
├── edit.jsx     # editor (React) component
└── save.jsx     # returns null — PHP handles frontend rendering

The frontend template lives alongside other Blade views eg: resources/views/blocks/text-block.blade.php

1. block.json — metadata and attributes

Declare every editable field as an attribute with a type and default:

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wh/text-block",
  "title": "Custom Text Block",
  "category": "widgets",
  "icon": "format-image",
  "supports": {
    "align": ["full", "wide"]
  },
  "attributes": {
    "heading": {
      "type": "string",
      "default": "Custom heading"
    },
    "paragraph": {
      "type": "string",
      "default": "Custom paragraph"
    }
  },
  "editorScript": "file:./index.js"
}

2. index.js — block registration

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

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

3. edit.jsx — editor component

  1. Call useBlockProps() at the top of the component (not inline in JSX)
  2. Use RichText for editable text fields
  3. The style prop must be an object — omit it if unused
import { useBlockProps, RichText } from '@wordpress/block-editor';

export default function Edit({ attributes, setAttributes }) {
  const { heading, paragraph } = attributes;
  const blockProps = useBlockProps({ className: 'border border-[#DDE3E9]' });

  return (
    <div {...blockProps}>
      <div className="flex flex-col items-center justify-center p-8 lg:p-16">
        <RichText
          tagName="h2"
          value={heading}
          onChange={(value) => setAttributes({ heading: value })}
          placeholder="Add heading..."
        />
        <RichText
          tagName="p"
          value={paragraph}
          onChange={(value) => setAttributes({ paragraph: value })}
          placeholder="Add paragraph..."
        />
      </div>
    </div>
  );
}

4. save.jsx — return null for dynamic blocks

Since the frontend is rendered by PHP, save returns null. WordPress stores only the block comment delimiter in the post content.

export default function Save() {
  return null;
}

5. Blade template — frontend rendering

resources/views/blocks/text-block.blade.php

@php
  $heading   = $attributes['heading'] ?? '';
  $paragraph = $attributes['paragraph'] ?? '';
@endphp

<section class="bg-white py-16">
  <div class="content-container flex flex-col items-center justify-center text-center">
    <h2 class="text-3xl font-bold mb-4">{!! $heading !!}</h2>
    <p class="text-lg">{!! $paragraph !!}</p>
  </div>
</section>

Note: Always use {!! $value !!} instead of {{ $value }} for RichText fields — they contain HTML markup.

6. blocks.php — PHP registration

Register each block with a render_callback that renders the Blade view:

add_action('init', function () {
    $blocks_dir = get_template_directory() . '/resources/js/blocks';

    register_block_type("{$blocks_dir}/text-block", [
        'render_callback' => function ($attributes) {
            return \Roots\view('blocks.text-block', [
                'attributes' => $attributes,
            ])->render();
        },
    ]);
});

7. editor.js — import the block

Add the block to resources/js/editor.js so Vite bundles it and wordpressPlugin() picks up its @wordpress/* dependencies:

import './blocks/text-block/index.js';

8. Recommend CSS

@utility content-container {
  margin-inline: auto;
  max-width: 1136px;
}

@utility content-container-wide {
  margin-inline: auto;
  max-width: 1400px;
}

@utility content-container-full {
  width: 100%;
}

How it fits together

  1. Editor (React) → edit.jsx renders the editing UI
  2. Save (React) → returns null; no HTML stored in DB
  3. Frontend (PHP + Blade) → render_callback passes $attributes to Blade template

Attributes flow: the editor writes attribute values into the block comment in the post content. On the frontend, WordPress reads those attributes and passes them as $attributes to the render_callback, which hands them to the Blade template.

The one callout worth highlighting here is using {!! $value !!} (unescaped) for any field edited with RichText, since it saves HTML. Use {{ $value }} only for plain text field

Related Articles

©2026 - Syntax by Wurkhouse | All Rights Reserved