, ,

Hybrid WordPress Blocks – The best of both worlds

Two halves split down the middle: PHP on the server in dark slate-blue on the left, React on the client in warm coral on the right, meeting at a thin gold seam in the centre. Title 'The best of both worlds — Hybrid WordPress Blocks' is set over the seam.

I love Gutenberg. It breaks content down to the simplest atomic form: a block. But every now and then a project comes along where a single block needs to be more than a block. It needs to be an app.

That is where hybrid blocks come in. PHP on the server, React on the client, talking through the REST API. The block stays a block in the editor and on the page. The user sees something closer to a full SPA.

I built one of these recently for a client learning platform. The block is a logged-in member dashboard with content galleries, notes, filtering, pagination, and a media player with timestamped notes on the single content page. Same WordPress install, same block editor, but the experience is React all the way down.

This post walks through the pattern. If you have already built a block with @wordpress/create-block, you know enough to follow along.

What I mean by “hybrid”

A normal Gutenberg block has two sides: the edit function in the editor, and either a save function or a render.php for the front end. Most blocks are happy living inside that contract.

A hybrid block adds a third thing. A full React app that mounts on the front end, fetches its own data, manages its own state, and does not re-render through PHP after the first paint.

The PHP side does three jobs. It renders the placeholder div the React app mounts into, it gates access (auth, capabilities, feature flags), and it enqueues the right JS bundle for the right user. The React side does everything else.

Why not just build an SPA?

I get this question a lot, especially from people who default to Next.js. My honest answer: WordPress already gives you auth, user management, roles, capabilities, the REST API, the media library, custom post types, and a UI for editors to drop your app on any page. Throwing all of that away to rebuild it in Node is a lot of unpaid work.

Next.js is definitely overrated for this kind of project. A hybrid block lets you keep everything WordPress already does well, and still ship a real React UI where it matters.

The shape of a hybrid block

Here is the directory layout I use for these:

my-blocks/
├── my-blocks.php           (plugin bootstrap)
├── src/
│   └── dashboard/
│       ├── block.json
│       ├── index.js        (editor side)
│       ├── view.js         (front-end app entry)
│       ├── app/            (the React SPA lives here)
│       │   ├── App.jsx
│       │   ├── components/
│       │   ├── hooks/
│       │   └── api.js
│       ├── render.php      (server-rendered shell)
│       └── style.scss
└── build/                  (wp-scripts output)

The important pieces are render.php and view.js. The first is the server shell. The second is the React entry point that mounts into that shell.

The PHP shell

The shell is small on purpose. It does the auth check, prints a mount point, and passes whatever data the React app needs to bootstrap:

<?php
// render.php
$current_user = wp_get_current_user();

if ( ! is_user_logged_in() ) {
    echo do_blocks( '<!-- wp:my-blocks/login-form /-->' );
    return;
}

$boot = array(
    'apiRoot' => esc_url_raw( rest_url( 'my-blocks/v1/' ) ),
    'nonce'   => wp_create_nonce( 'wp_rest' ),
    'user'    => array(
        'id'           => $current_user->ID,
        'display_name' => $current_user->display_name,
        'capabilities' => array_keys( array_filter( $current_user->allcaps ) ),
    ),
);
?>
<div
    class="my-dashboard"
    data-boot="<?php echo esc_attr( wp_json_encode( $boot ) ); ?>"
></div>

Two things to call out here. First, the auth gate happens before anything else. Logged-out visitors never receive the React bundle. They get a login form (which is itself a smaller block).

Second, the bootstrap data is serialized into a data- attribute. I do not use wp_localize_script for this. Localized data is global and survives between blocks, which becomes a real problem if an editor ever drops two of these on the same page. A data- attribute is scoped to the mount point and that scoping is exactly what you want.

The React entry point

view.js is the file that gets enqueued by block.json via the viewScript field. The really nice thing is that this is the one and only place the React app touches the DOM:

// view.js
import { createRoot } from '@wordpress/element';
import App from './app/App';

document.querySelectorAll( '.my-dashboard' ).forEach( ( node ) => {
    const boot = JSON.parse( node.dataset.boot );
    createRoot( node ).render( <App boot={ boot } /> );
} );

@wordpress/element is React under the hood, but it stays in sync with whatever React version WordPress ships, which saves a lot of pain around version mismatches.

The forEach is deliberate. If somebody drops the block twice, both instances mount cleanly with their own root.

The REST API layer

This is where the actual work happens. I usually register a handful of custom endpoints under a custom namespace — things like items, notes, categories, register-user, and so on. Every endpoint goes through permission_callback for capability checks:

register_rest_route( 'my-blocks/v1', '/notes', array(
    array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'my_get_notes',
        'permission_callback' => function () {
            return is_user_logged_in() && current_user_can( 'read' );
        },
    ),
    array(
        'methods'             => WP_REST_Server::CREATABLE,
        'callback'            => 'my_create_note',
        'permission_callback' => function () {
            return current_user_can( 'edit_posts' );
        },
    ),
) );

Two rules I do not break here. permission_callback is mandatory — returning true is a security hole, not a shortcut. And the nonce from render.php gets sent as a header (X-WP-Nonce) on every request, which WordPress validates automatically.

The React side uses a thin api.js wrapper that pulls the nonce off the boot data and attaches it to every fetch call. Nothing fancy.

Code-splitting the guest experience

This is the part I am most proud of. On the single content page, logged-out visitors should see a media player. Logged-in users should see the same player plus a notes UI with timestamps, filtering, and saved highlights.

The naive solution is to ship one big bundle and conditionally render. That bundle now costs every guest visit, which is the majority of traffic on a site like this.

The better solution is to ship a lightweight player for guests, and dynamically import the notes UI only when an authenticated user actually loads the page:

// view.js (simplified)
const root = createRoot( node );

if ( boot.user ) {
    import( './app/AuthenticatedExperience' ).then( ( { default: App } ) => {
        root.render( <App boot={ boot } /> );
    } );
} else {
    root.render( <GuestPlayer boot={ boot } /> );
}

Webpack (via wp-scripts) splits AuthenticatedExperience into its own chunk. Guests never download it. The site stays fast for the people who account for most of the traffic, and the dashboard stays rich for the people who log in.

What the editor sees

The block editor side stays simple. I use ServerSideRender so the editor preview matches the front end exactly, and the block itself has no attributes worth editing:

// index.js
import { registerBlockType } from '@wordpress/blocks';
import { ServerSideRender } from '@wordpress/server-side-render';
import metadata from './block.json';

registerBlockType( metadata.name, {
    edit: () => <ServerSideRender block={ metadata.name } />,
} );

The editor calls render.php, the same shell the front end uses, gets back the placeholder, and shows it inside the block. Editors can drop the dashboard on any page like any other block. They do not need to know there is a React app underneath, which is exactly the point.

A few things I learned the hard way

I have been burned by each of these so I am saving you the trouble.

Do not skip the auth gate on the server. I have seen blocks where the React app does the redirect. By the time the redirect fires, you have already shipped the bundle, leaked which capabilities exist, and burned a paint. Gate it in PHP.

Pass data through data- attributes, not globals. Two instances of the same block on the same page is a real scenario. Globals do not survive it.

Use @wordpress/element for React imports. It tracks the WordPress version. Importing react directly works until it does not, usually right after a core update.

Keep the PHP shell small. Every line of logic in render.php is a line you cannot test in the React app. The shell prints the mount point and gets out of the way.

When to reach for this pattern

Hybrid blocks are overkill for a CTA section or a pricing table. They earn their weight when the block is the page — a dashboard, a search experience, a builder UI, an authenticated feed, and so on. If the interaction model is closer to an app than a document, the pattern fits.

For this client, that was definitely the case. The whole point of the dashboard is that a member spends real time there, organizes their own notes, plays media, jumps between content. A traditional WordPress page would have made the experience feel slow and stitched-together. The block makes it feel like a single app, even though the rest of the site is plain old WordPress posts and pages.

That is the best of both worlds. WordPress where WordPress shines, React where React shines, and a block that lets editors place the whole thing wherever they want.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *