Adding a Meta Box to the WordPress Editor
The hook registers the box; it does not save anything. Saving is a second hook with a nonce, a capability check and an autosave guard, and a meta box without all three either loses data or accepts it from anyone.
add_meta_boxes draws the panel. It does not read, validate or store what the panel contains. The hook fires when an edit screen is built and passes the post type and the post; inside it you call add_meta_box() with a render callback. The round trip back — from the form field to the database — is a separate hook with its own rules, and a meta box is only finished when both halves exist.
Registering and rendering
add_action( 'add_meta_boxes', 'ti_register_subtitle_box', 10, 2 );
function ti_register_subtitle_box( $post_type, $post ) {
if ( ! in_array( $post_type, array( 'post', 'page' ), true ) ) {
return;
}
add_meta_box(
'ti_subtitle',
__( 'Subtitle', 'ti' ),
'ti_render_subtitle_box',
$post_type,
'side',
'high'
);
}
function ti_render_subtitle_box( WP_Post $post ) {
$value = get_post_meta( $post->ID, '_ti_subtitle', true );
wp_nonce_field( 'ti_save_subtitle', 'ti_subtitle_nonce' );
echo '<label class="screen-reader-text" for="ti_subtitle">' . esc_html__( 'Subtitle', 'ti' ) . '</label>';
echo '<input type="text" id="ti_subtitle" name="ti_subtitle" class="widefat" value="' . esc_attr( $value ) . '">';
}
The arguments are an ID, a title, the render callback, the screen, the context (normal, side or advanced) and the priority. The nonce field printed inside the box is what the save callback will verify; printing it anywhere else — once per page, in a different box — is the usual source of saves that silently fail when the other box is disabled. The meta key starts with an underscore so it does not appear in the Custom Fields panel, where an editor could overwrite it by hand.
Saving
add_action( 'save_post', 'ti_save_subtitle', 10, 2 );
function ti_save_subtitle( $post_id, WP_Post $post ) {
if ( ! isset( $_POST['ti_subtitle_nonce'] )
|| ! wp_verify_nonce( sanitize_key( $_POST['ti_subtitle_nonce'] ), 'ti_save_subtitle' ) ) {
return;
}
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( wp_is_post_revision( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
if ( isset( $_POST['ti_subtitle'] ) ) {
update_post_meta( $post_id, '_ti_subtitle', sanitize_text_field( wp_unslash( $_POST['ti_subtitle'] ) ) );
}
}
Three guards, each catching a real case. The nonce rejects requests that did not come from this form — including the REST request the block editor sends for the post body, which has no meta-box fields and must not wipe the value. The autosave guard stops a periodic save from storing an empty field. The capability check stops a contributor from writing meta on a post they cannot edit. save_post has more edge cases than those, and save_post covers the ones that bite later: it fires for every post type, for revisions, and from inside the save of related posts.
The block editor
The block editor keeps classic meta boxes. It renders them in a panel below the blocks and, on save, submits their form fields in a second request to post.php after the REST request that saves the content. Consequences:
- The nonce and
$_POSTpattern above still works, because the second request is a classic form post. save_posttherefore fires twice for one click: once for the REST save, which already carries the new content but none of your form fields, and once for the meta-box request, which carries the fields and the nonce. The nonce check is what keeps the first call from wiping the value.- JavaScript inside the box must not assume
#publishor the classic form exist. It can, however, subscribe to editor saves throughwp.data.
For a field that should feel native in the block editor, register the meta with register_post_meta() and show_in_rest and build a sidebar panel in JavaScript. That is more work and a different article; the classic meta box remains the quickest way to get a field in front of editors, and it keeps working for the classic editor on sites that use it.
Context, priority and the typed hook
normal places the box under the content; side puts it in the sidebar; advanced is below normal. Users drag boxes and WordPress remembers the position per user, so the context is only the default. The priority (high, core, default, low) orders boxes within a context.
add_meta_boxes_{$post_type} fires for one post type only and passes the post. It replaces the type check inside the generic callback, and it is the hook to use when the box belongs to a custom post type registered by the same plugin.
Removing someone else's box
remove_meta_box( $id, $screen, $context ) works on the same hook. Core registers its own boxes before the hook fires, so they can be removed at any priority; a box another plugin adds on the hook has to be removed at a later priority than the one it used. The ID and the context must match the ones used to add it; postexcerpt in normal for the excerpt panel, commentstatusdiv for discussion. In the block editor many core panels are not meta boxes at all and must be hidden through the editor's own preferences or remove_post_type_support().
Verify
- The box appears on the intended post types and nowhere else.
- Enter a value and save: it is still there on reload.
- Save with the field cleared: it is cleared, not reverted — which confirms the autosave guard is not swallowing real saves.
- Wait for an autosave with the field changed but unsaved: the stored value is unchanged.
- Log in as a contributor on someone else's post and attempt a save: nothing is written.
Frequently asked
- Because add_meta_boxes only renders the panel. The value arrives in $_POST on save and nothing stores it until a save_post callback does. Add one, verify the nonce, check the capability, skip autosaves, then update_post_meta.
- Yes. The block editor renders classic meta boxes in a panel below the content and submits them with the post through a separate request. Fields that need JavaScript must be initialised after the editor loads, and a box that depends on the classic submit button will not find it.
- The screen argument decides where it appears. Pass the post type slug, or an array of slugs, as the fourth argument of add_meta_box. The hook itself fires for every post type and passes the type as its first argument, so you can also branch there.