A meta box is a draggable, collapsible UI panel in the WordPress post editor that lets you attach custom data to any post, page, or custom post type. Meta boxes are the traditional way to add custom fields before the block editor era — and they remain fully supported in Gutenberg via the add_meta_box() function, which renders meta boxes in the post sidebar under the block editor. Each meta box has an HTML callback that outputs a form, and you save its data by hooking into save_post. The critical security steps are: always output a nonce field with wp_nonce_field() inside the meta box callback, and always verify it with wp_verify_nonce() inside the save hook before touching $_POST data. Without this, your save handler is vulnerable to CSRF. Also check current_user_can() and bail on autosaves and revisions. Meta box data is stored in the wp_postmeta table and is retrieved with get_post_meta(). You can attach a meta box to any combination of post types, contexts (normal, side, advanced), and priorities (high, default, low). The side context places the box in the right sidebar next to the publish panel — ideal for short custom fields. For complex admin interfaces with multiple related fields, grouping them all inside one meta box (saving as a single serialised array option) is cleaner than registering many separate boxes. Combine this with the ACF guide for complex field requirements and the $wpdb guide for custom table storage.
Problem: You need to attach custom metadata to WordPress posts or pages through a dedicated editor panel, with secure save handling.
Solution: Add the following code to your functions.php or plugin file:
// Register the meta box
add_action( 'add_meta_boxes', 'helloadmin_register_meta_box' );
function helloadmin_register_meta_box(): void {
add_meta_box(
'helloadmin_post_details', // unique ID
__( 'Post Details', 'helloadmin' ), // title
'helloadmin_meta_box_callback', // render callback
[ 'post', 'page' ], // post types
'side', // context: normal | side | advanced
'high' // priority: high | default | low
);
}
// Render the meta box HTML
function helloadmin_meta_box_callback( WP_Post $post ): void {
// Nonce for CSRF protection
wp_nonce_field( 'helloadmin_save_meta', 'helloadmin_meta_nonce' );
$subtitle = get_post_meta( $post->ID, '_helloadmin_subtitle', true );
$read_min = get_post_meta( $post->ID, '_helloadmin_read_min', true );
?>
<p>
<label for="helloadmin_subtitle"><strong><?php esc_html_e( 'Subtitle', 'helloadmin' ); ?></strong></label><br>
<input type="text" id="helloadmin_subtitle" name="helloadmin_subtitle"
value="<?php echo esc_attr( $subtitle ); ?>" style="width:100%">
</p>
<p>
<label for="helloadmin_read_min"><strong><?php esc_html_e( 'Read time (minutes)', 'helloadmin' ); ?></strong></label><br>
<input type="number" id="helloadmin_read_min" name="helloadmin_read_min"
value="<?php echo esc_attr( $read_min ); ?>" min="1" max="60" style="width:80px">
</p>
<?php
}
// Save the meta box data
add_action( 'save_post', 'helloadmin_save_meta_box', 10, 2 );
function helloadmin_save_meta_box( int $post_id, WP_Post $post ): void {
// Verify nonce
if ( ! isset( $_POST['helloadmin_meta_nonce'] )
|| ! wp_verify_nonce( $_POST['helloadmin_meta_nonce'], 'helloadmin_save_meta' ) ) {
return;
}
// Bail on autosave, revisions, and wrong post types
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return;
}
if ( ! in_array( $post->post_type, [ 'post', 'page' ], true ) ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Sanitise and save
if ( isset( $_POST['helloadmin_subtitle'] ) ) {
update_post_meta( $post_id, '_helloadmin_subtitle',
sanitize_text_field( wp_unslash( $_POST['helloadmin_subtitle'] ) )
);
}
if ( isset( $_POST['helloadmin_read_min'] ) ) {
update_post_meta( $post_id, '_helloadmin_read_min',
absint( $_POST['helloadmin_read_min'] )
);
}
}
// Use meta values in templates
// $subtitle = get_post_meta( get_the_ID(), '_helloadmin_subtitle', true );
// $read_min = get_post_meta( get_the_ID(), '_helloadmin_read_min', true );
NOTE: Prefix your meta key with an underscore (e.g. _helloadmin_subtitle) to hide it from the default WordPress custom fields panel at the bottom of the editor — unprefixed keys appear there and can confuse non-technical users. If you need the same fields on multiple post types with different validation rules, register separate save_post_{post_type} hooks (e.g. save_post_post, save_post_page) instead of the generic save_post hook to avoid the post type check inside the callback.