These days I’ve become so familiar with custom Gutenberg development that trying to use ACF or even FieldManager feels like more of a hassle than it’s worth. Don’t get me wrong, ACF is great for quickly creating straightforward meta relationships, but they have yet to integrate fully with the editor, so it’s a pain to do anything more interactive on the client side with the fields. I’m often developing features that require more interactivity within the editor instead of being just a simple form, but sometimes I need to work with existing systems that use ACF. Recently I needed to add some controls in amongst the meta boxes below the post instead of in the sidebar where InspectorControls go.
I came across this post on the Unspun Minds Blog that describes the process of registering a meta box and filling it with a React app, but this approach is a bit disconnected from the rest of the editor. What if we could register a Gutenberg “plugin” and render our components within the meta box without creating a whole separate React root?
Turns out we can, with portals.
🟠 🔵
This is cool and useful in some very specific circumstances, but there definitely are some downsides that I’ll talk about at the end of the post.
The PHP
Let’s assume you already have a plugin or theme set up so we can get straight to the point. Note that I encourage the use of proper namespaces, but these examples will be as straightforward as possible.
Register Post Meta
If we’re going out of our way to do this in a meta box, we probably have some post meta value that would be annoying to manipulate in the InspectorControls sidebar, such as a large textarea. Let’s register a meta value and, just for fun, assume that it shouldn’t be output as part of the public API response by including the context property. The meta must be available to the editor via REST, but if you want to limit how it appears in API responses outside the editor, you can define a REST schema and set a narrower context.
<?php
add_action(
'init',
'cr0ybot_register_editor_controls_post_meta',
);
/**
* Register post meta.
*/
function cr0ybot_register_editor_controls_post_meta() {
register_post_meta(
'post',
'cr0ybot_editor_notes',
array(
'type' => 'string',
'single' => true,
'default' => '',
'show_in_rest' => array(
'schema' => array(
'type' => 'string',
'context' => array( 'edit' ),
),
),
'auth_callback' => function() {
return current_user_can( 'edit_posts' );
},
)
);
}Register the Meta Box
We need to create a mostly empty meta box to fill with our React components. The only thing we must add is an element that we can target as our portal.
// Action hook for registering the meta box. You can target a specific post type with the 'add_meta_boxes_{post_type}' hook variation.
add_action(
'add_meta_boxes_post',
'cr0ybot_add_editor_controls_meta_box',
);
/**
* Register meta box.
*/
function cr0ybot_add_editor_controls_meta_box() {
add_meta_box(
'cr0ybot-editor-controls', // Meta box ID.
'Editor Controls', // Meta box label.
'cr0ybot_render_editor_controls_meta_box', // Render function.
null, // Screen. We're already targetting the 'post' post type edit screen with the action hook.
'normal', // Position. Adding a box to the 'side' defeats the purpose since we can easily add panels to InspectorControls.
'default', // Priority, which doesn't matter so much in the Gutenberg editor.
);
}
/**
* Render meta box.
*/
function cr0ybot_render_editor_controls_meta_box() {
?>
<div id="cr0ybot-editor-controls-meta-box-portal"></div>
<?php
}You can see at the bottom there that we’re only rendering a single div with an ID of cr0ybot-editor-controls-meta-box-portal. You might be tempted to include fallback markup inside like some Loading... text if this was meant as a standard React root—which replaces the contents of the element—but in the case of a portal the components will be appended. So, only add additional markup here if you want static content that isn’t affected by React.
Enqueue the Script
The last bit of PHP we need is enqueuing our script that handles rendering the controls. We’ll use the enqueue_block_editor_assets hook, and limit it to the post type(s) we’ve added the meta box to.
add_action(
'enqueue_block_editor_assets',
'cr0ybot_enqueue_editor_controls_meta_box_script',
);
/**
* Enqueue script.
*/
function cr0ybot_enqueue_editor_controls_meta_box_script() {
// Exit early if not the right post type.
if ( 'post' !== get_post_type() ) {
return;
}
// Assuming you're using wp-scripts, get the version/dependency info from the asset file.
$asset = require plugin_dir_path( '/build/script.asset.php' );
wp_enqueue_script(
'cr0ybot-editor-controls-meta-box',
plugin_dir_url( '/build/script.js' ),
$asset['dependencies'],
$asset['version'],
);
}And that’s it, unless you need to do other stuff like adding custom REST API endpoints or fields.
The JS
Instead of booting a separate React app inside the meta box, we can register a normal editor plugin and then render its UI into the meta box with a portal. That means our controls still live inside the editor’s React tree and can use editor hooks like useEntityProp, useSelect, and so on without any extra ceremony.
Register the Plugin
Here we register a plugin whose render callback returns our metabox portal component:
// script.js
import { registerPlugin } from '@wordpress/plugins';
import MetaBoxPortal from './meta-box-portal';
registerPlugin( 'cr0ybot-editor-controls-meta-box', {
render: MetaBoxPortal,
} );There is no custom sidebar panel here, no PluginSidebar, no extra slot-fill UI. The plugin exists purely so Gutenberg will render our component tree for us.
Create the Portal
React Portals allow you to render components anywhere in the DOM. Instead of rendering a whole separate React app in the metabox, you’re rendering a piece of the app somewhere else. All we need is a reference to the element we rendered in PHP:
// meta-box-portal.js
import { createPortal, useRef } from '@wordpress/element';
import EditorControls from './editor-controls';
export default function MetaBoxPortal() {
const root = useRef(
document.getElementById( 'cr0ybot-editor-controls-meta-box-portal' )
);
return root.current
? createPortal( <EditorControls />, root.current )
: null;
}That null fallback is worth keeping. If the meta box is not present for some reason, or if the user has hidden it, we do not want the plugin throwing errors.
Manipulate the Post Meta
Now we can just build our Gutenberg controls like normal.
Here I’m just using a single TextareaControl wired to post meta via useEntityProp. Since the component is still running inside the editor app, we can read and write post meta exactly the same way we would from InspectorControls.
import { TextareaControl } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
export default function EditorControls() {
const postType = useSelect(
( select ) => select( 'core/editor' ).getCurrentPostType(),
[]
);
const [ meta = {}, setMeta ] = useEntityProp(
'postType',
postType,
'meta'
);
const { cr0ybot_editor_notes: notes = '' } = meta;
return (
<TextareaControl
label={ __( 'Editor Notes', 'cr0ybot' ) }
help={ __(
'These notes are stored in post meta and shown only in the editor.',
'cr0ybot'
) }
value={ notes }
onChange={ ( value ) =>
setMeta( {
...meta,
cr0ybot_editor_notes: value,
} )
}
/>
);
}And that’s it for the core implementation.
No manual REST requests. No custom save handler. No separate React root. We are just editing entity data the normal Gutenberg way, but rendering the control in a legacy meta box container.
The Downsides
The biggest downside is that you are deliberately rendering modern editor UI inside a legacy admin container. WordPress meta boxes come with their own markup, spacing, typography, and various admin styles, and those styles can interfere with your components in annoying ways.
A few caveats to keep in mind:
- Meta box styles can leak into your controls.
- Your controls may not visually match the rest of the block editor without extra CSS (that you have to maintain).
- The portal depends on a DOM element existing with a known ID.
- If the meta box is hidden or removed, your plugin still renders but has nowhere useful to portal into.
If your controls belong in the normal editor flow, a sidebar panel or other editor-native surface is usually cleaner.