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:
- Editor controls
- Live previews
- Drag-and-drop interactions
- Block settings
PHP handles:
- Database queries
- Custom post types
- ACF integration
- Dynamic content
- Caching
- 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
- Call useBlockProps() at the top of the component (not inline in JSX)
- Use RichText for editable text fields
- 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
- Editor (React) → edit.jsx renders the editing UI
- Save (React) → returns null; no HTML stored in DB
- 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