Meta boxes and custom fields without a library

Add a meta box to the event editor, save its fields with nonces, capability checks and validation, and register the meta for the REST API.

  • Intermediate
  • 8 min read
  • Updated 17 Oct 2026

What you will learn

  • Add a meta box
  • Register meta for REST
  • Save meta safely from the edit screen
  • Avoid conflicts between meta boxes and the block editor

Before you start

Events need a date and a price, and editors need somewhere to type them. Field libraries can build that screen for you, but they add a dependency for what is about 80 lines of code. This lesson builds an "Event details" meta box by hand, saves it safely, and makes sure the same meta is available to the REST API and the block editor.

Registered meta: the foundation

The WordPress course registered myplugin_event_date and myplugin_price with register_post_meta(), including a sanitise callback, an auth_callback and show_in_rest. Keep that registration in includes/meta.php. It matters here for two reasons.

First, registered sanitise callbacks run on every write. When any code calls update_post_meta( $id, 'myplugin_price', $value ), WordPress passes the value through the callback you registered. Your meta box, a REST request and an import all get the same cleaning.

Second, show_in_rest needs the right post type support. Registered meta appears in the REST API only if the post type supports custom-fields. The events post type from the WordPress course includes it in supports; if yours does not, the meta silently stays out of the API.

Check that it works before building any UI:

Shell
wp eval 'update_post_meta( 123, "myplugin_price", "-5" ); var_dump( get_post_meta( 123, "myplugin_price", true ) );'

With the registration in place, the negative price is stored as 0.

Adding the meta box

PHP
<?php
// wp-content/plugins/myplugin/admin/meta-boxes.php
defined( 'ABSPATH' ) || exit;

add_action( 'add_meta_boxes_myplugin_event', 'myplugin_add_event_meta_box' );

function myplugin_add_event_meta_box( WP_Post $post ): void {
    add_meta_box(
        'myplugin-event-details',              // HTML ID.
        __( 'Event details', 'myplugin' ),     // Title.
        'myplugin_render_event_meta_box',      // Render callback.
        'myplugin_event',                      // Screen: the post type.
        'side',                                // Context: normal, side or advanced.
        'high'                                 // Priority within the context.
    );
}

add_meta_boxes_{post_type} fires only on that post type's edit screen and passes the post. The generic add_meta_boxes hook fires for every type and passes the post type first, so you would have to check it yourself.

Rendering the fields

PHP
<?php
// wp-content/plugins/myplugin/admin/meta-boxes.php

function myplugin_render_event_meta_box( WP_Post $post ): void {
    wp_nonce_field( 'myplugin_save_event_' . $post->ID, 'myplugin_event_nonce' );

    $date  = (string) get_post_meta( $post->ID, 'myplugin_event_date', true );
    $price = (float) get_post_meta( $post->ID, 'myplugin_price', true );
    ?>
    <p>
        <label for="myplugin-event-date"><?php esc_html_e( 'Date', 'myplugin' ); ?></label><br>
        <input type="date" id="myplugin-event-date" name="myplugin_event_date" value="<?php echo esc_attr( $date ); ?>" required>
    </p>
    <p>
        <label for="myplugin-price"><?php esc_html_e( 'Price', 'myplugin' ); ?></label><br>
        <input type="number" id="myplugin-price" name="myplugin_price" value="<?php echo esc_attr( (string) $price ); ?>" min="0" step="0.01" class="small-text">
        <span class="description"><?php echo esc_html( myplugin_get_settings()['currency'] ); ?></span>
    </p>
    <?php
}

The nonce action includes the post ID, so a nonce from one event's screen cannot save another. The field names are the meta keys, which keeps the save code easy to follow. A date input sends Y-m-d, the format the meta expects.

Saving

Saving is where most meta box bugs and vulnerabilities live. save_post fires for far more than your form: autosaves, revisions, Quick Edit, REST requests, imports and code calling wp_insert_post(). The handler must only act when your form was submitted, by a user allowed to edit this post:

PHP
<?php
// wp-content/plugins/myplugin/admin/meta-boxes.php

add_action( 'save_post_myplugin_event', 'myplugin_save_event_meta_box', 10, 2 );

function myplugin_save_event_meta_box( int $post_id, WP_Post $post ): void {
    // 1. Only when our form was submitted.
    if ( ! isset( $_POST['myplugin_event_nonce'] ) ) {
        return;
    }

    // 2. Nonce for this post.
    $nonce = sanitize_text_field( wp_unslash( $_POST['myplugin_event_nonce'] ) );

    if ( ! wp_verify_nonce( $nonce, 'myplugin_save_event_' . $post_id ) ) {
        return;
    }

    // 3. Not for autosaves or revisions.
    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
        return;
    }

    // 4. Permission for this post.
    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
    }

    // 5. Each field: validate, then save or delete.
    if ( isset( $_POST['myplugin_event_date'] ) ) {
        $date = myplugin_sanitize_event_date( sanitize_text_field( wp_unslash( $_POST['myplugin_event_date'] ) ) );

        if ( '' === $date ) {
            delete_post_meta( $post_id, 'myplugin_event_date' );
        } else {
            update_post_meta( $post_id, 'myplugin_event_date', $date );
        }
    }

    if ( isset( $_POST['myplugin_price'] ) ) {
        $price = sanitize_text_field( wp_unslash( $_POST['myplugin_price'] ) );

        if ( is_numeric( $price ) ) {
            update_post_meta( $post_id, 'myplugin_price', (float) $price );
        }
    }
}

Why each check exists:

  1. No nonce field: the save came from somewhere else, such as Quick Edit or the REST API. Do nothing, or you would wipe the fields with empty values.
  2. Nonce: the request came from this post's edit screen, not a forged page.
  3. Autosave and revision: both trigger save_post with a different post ID. Revisions are separate posts.
  4. Capability: edit_post with the ID uses meta capabilities, so authors can only save their own events.
  5. Validate per field: an invalid date deletes the value rather than storing junk. A price that is not numeric leaves the stored price alone. The registered sanitise callbacks then run again inside update_post_meta().

myplugin_sanitize_event_date() is the strict Y-m-d validator from the WordPress course's database lesson. Reusing it keeps one definition of a valid date across the meta box, the REST API and imports.

Using save_post_myplugin_event rather than save_post means the function never runs for other post types. Load admin/meta-boxes.php inside the is_admin() block: the form only exists on the admin edit screen.

Meta boxes in the block editor

The block editor still supports meta boxes. It shows them in a panel below the content, or in the sidebar for side boxes. When the user saves, the editor first saves the post through the REST API, then posts the meta box form in a second request. So:

  • save_post runs at least twice per save. The nonce check above makes the second run the one that saves your fields.
  • Do not edit the same meta key in a meta box and in a block editor panel or a block binding. The meta box form holds the value from when the page loaded and posts it after the REST save, so it can overwrite the newer value from the editor panel.
  • If a box only makes sense in the classic editor, pass array( '__back_compat_meta_box' => true ) as the seventh argument of add_meta_box(). The block editor then hides it.

Meta boxes are a sound choice for plugins that must support both editors, or for complex fields. For a native block editor experience, the block editor integration lesson shows how to register the same meta for an editor panel or bind it to blocks, without React-heavy code.

The REST API view of the same data

Because the meta is registered with show_in_rest, the event's REST response includes it:

Shell
curl -s "https://example.test/wp-json/wp/v2/events/123?_fields=id,meta"
JSON
{
    "id": 123,
    "meta": {
        "myplugin_event_date": "2026-11-14",
        "myplugin_price": 25
    }
}

An authenticated request can update it by sending a meta object. WordPress checks the auth_callback you registered, then the sanitise callback, so the REST path is protected by the same rules as the meta box. Anything registered with show_in_rest is public for published posts, so never register private data that way. For private values, leave show_in_rest off. A key starting with an underscore is hidden from the classic Custom Fields panel, but registering it with show_in_rest still exposes it.

Summary

  • Register meta with a sanitise callback and an auth_callback first; every write path, including meta boxes, then shares the same rules.
  • Registered meta appears in the REST API only when the post type supports custom-fields.
  • Add meta boxes on add_meta_boxes_{post_type} and include a nonce whose action contains the post ID.
  • Save on save_post_{post_type}: return early without your nonce, on autosaves and revisions, and without edit_post for that post; validate each field.
  • In the block editor, meta boxes save in a second request, so never edit the same key in a meta box and an editor panel.

Further reading