AJAX in WordPress plugins

Build admin-ajax handlers that check nonces and capabilities, return consistent JSON, and know when a REST route is the better tool.

  • Intermediate
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Handle an AJAX request securely
  • Choose AJAX or REST
  • Return consistent JSON success and error responses
  • Avoid the performance traps of admin-ajax.php

Before you start

The WordPress course used admin-ajax.php for a front-end "favourite" button and showed the nonce check. This lesson takes AJAX further for plugin admin screens: a button on the Bookings page that changes a booking's status without reloading, with a handler that returns consistent JSON for every outcome. Then it compares the same feature as a REST route, so you can choose between them.

How admin-ajax.php works

Every request to wp-admin/admin-ajax.php must include an action parameter. WordPress loads fully, including the admin, and then fires one of two hooks:

  • wp_ajax_{action} when the user is logged in;
  • wp_ajax_nopriv_{action} when they are not.

If no callback is attached, it responds with 0. Your callback reads the request, does the work and sends a response. Nothing else about routing, validation or permissions is built in: your handler does all of it.

The handler

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

add_action( 'wp_ajax_myplugin_booking_status', 'myplugin_ajax_booking_status' );

/**
 * Change a booking's status. Responds with JSON and ends the request.
 */
function myplugin_ajax_booking_status(): void {
    // 1. Nonce. Pass false so we can answer in JSON instead of a bare -1.
    if ( ! check_ajax_referer( 'myplugin_booking_status', 'nonce', false ) ) {
        wp_send_json_error(
            array( 'message' => __( 'Your session has expired. Reload the page and try again.', 'myplugin' ) ),
            403
        );
    }

    // 2. Capability.
    if ( ! current_user_can( 'myplugin_manage_bookings' ) ) {
        wp_send_json_error( array( 'message' => __( 'You are not allowed to change bookings.', 'myplugin' ) ), 403 );
    }

    // 3. Input.
    $booking_id = isset( $_POST['booking_id'] ) ? absint( wp_unslash( $_POST['booking_id'] ) ) : 0;
    $status     = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : '';

    if ( ! $booking_id || ! in_array( $status, MYPLUGIN_BOOKING_STATUSES, true ) ) {
        wp_send_json_error( array( 'message' => __( 'Invalid booking or status.', 'myplugin' ) ), 400 );
    }

    // 4. The work, through the bookings functions.
    $result = myplugin_set_booking_status( $booking_id, $status );

    if ( is_wp_error( $result ) ) {
        wp_send_json_error( array( 'message' => $result->get_error_message() ), 404 );
    }

    // 5. Success.
    wp_send_json_success(
        array(
            'id'     => $booking_id,
            'status' => $status,
            'label'  => ucfirst( $status ),
        )
    );
}

The order is the same as every handler in this course: nonce and capability, then input, then work. Details that matter:

  • check_ajax_referer( $action, $field, false ) returns false instead of stopping. Without the third argument, it ends the request with -1 and a 403 status, which JavaScript expecting JSON cannot parse. A consistent JSON shape makes the client simpler.
  • wp_send_json_success() and wp_send_json_error() send {"success": true, "data": ...} or {"success": false, "data": ...}, set the status code you pass, and end the request. Code after them never runs, so there is no need for exit.
  • There is no wp_ajax_nopriv_ hook, so logged-out visitors cannot reach this action at all.

The status function belongs with the other booking functions:

PHP
<?php
// wp-content/plugins/myplugin/includes/bookings.php

/**
 * Update a booking's status.
 *
 * @return true|WP_Error
 */
function myplugin_set_booking_status( int $booking_id, string $status ) {
    global $wpdb;

    $table   = myplugin_bookings_table();
    $booking = $wpdb->get_row( $wpdb->prepare( 'SELECT id, event_id FROM %i WHERE id = %d', $table, $booking_id ) );

    if ( ! $booking ) {
        return new WP_Error( 'myplugin_no_booking', __( 'Booking not found.', 'myplugin' ) );
    }

    $wpdb->update(
        $table,
        array(
            'status'     => $status,
            'updated_at' => current_time( 'mysql', true ),
        ),
        array( 'id' => $booking_id ),
        array( '%s', '%s' ),
        array( '%d' )
    );

    wp_cache_delete( 'guests_' . (int) $booking->event_id, 'myplugin_bookings' );
    do_action( 'myplugin_booking_status_changed', $booking_id, $status );

    return true;
}

Loading the script on one screen

Load the handler file from the main file inside the is_admin() block, because admin-ajax.php requests are admin requests. Enqueue the script from the Bookings screen's load hook, as in the admin pages lesson, and pass the URL and nonce to it:

PHP
<?php
// wp-content/plugins/myplugin/admin/menu.php, called from myplugin_bookings_screen_load().
function myplugin_bookings_assets(): void {
    wp_enqueue_script(
        'myplugin-bookings',
        MYPLUGIN_URL . 'assets/js/bookings.js',
        array(),
        MYPLUGIN_VERSION,
        array( 'in_footer' => true )
    );

    wp_add_inline_script(
        'myplugin-bookings',
        'const mypluginBookings = ' . wp_json_encode(
            array(
                'ajaxUrl' => admin_url( 'admin-ajax.php' ),
                'nonce'   => wp_create_nonce( 'myplugin_booking_status' ),
                'failed'  => __( 'The booking could not be updated.', 'myplugin' ),
            )
        ) . ';',
        'before'
    );
}

In the admin, WordPress also defines a global ajaxurl variable. Passing the URL explicitly works everywhere, including the front end, and keeps the script self-contained.

In the Bookings table from the admin pages lesson, give the status cell a class, <td class="myplugin-status">, and add a cell with a button that carries the booking ID and the new status:

PHP
<button type="button" class="button myplugin-set-status" data-id="<?php echo esc_attr( (string) $booking->id ); ?>" data-status="confirmed">
    <?php esc_html_e( 'Confirm', 'myplugin' ); ?>
</button>

The JavaScript

JavaScript
// wp-content/plugins/myplugin/assets/js/bookings.js
document.addEventListener( 'click', async ( event ) => {
    const button = event.target.closest( '.myplugin-set-status' );

    if ( ! button ) {
        return;
    }

    button.disabled = true;

    const body = new FormData();
    body.append( 'action', 'myplugin_booking_status' );
    body.append( 'nonce', mypluginBookings.nonce );
    body.append( 'booking_id', button.dataset.id );
    body.append( 'status', button.dataset.status );

    try {
        const response = await fetch( mypluginBookings.ajaxUrl, {
            method: 'POST',
            credentials: 'same-origin',
            body,
        } );
        const result = await response.json();

        if ( ! result.success ) {
            throw new Error( result.data?.message || mypluginBookings.failed );
        }

        button.closest( 'tr' ).querySelector( '.myplugin-status' ).textContent = result.data.label;
    } catch ( error ) {
        window.alert( error.message || mypluginBookings.failed );
    } finally {
        button.disabled = false;
    }
} );

One listener on the document handles every button, including rows added later. The button is disabled during the request so a double click does not send two changes. Both failure paths, a network error and success: false, end in the same message. Writing the label with textContent rather than innerHTML means a malicious value could never become markup.

Public actions

A wp_ajax_nopriv_ handler is reachable by anyone on the internet, including bots. Treat it like a public form:

  • Nonces offer little protection here, because every logged-out visitor is the same user to WordPress, and a cached page can serve a nonce long after it was created.
  • Validate everything and limit what a single request can do.
  • Add rate limiting, for example a short transient keyed by IP address, for anything that sends email or writes data.
  • Never return data a visitor could not already see on the site.

The admin-ajax.php performance traps

Every AJAX request loads the whole of WordPress plus the admin: admin_init fires, and so does every plugin's admin_init code. Two problems follow.

First, it is slow for frequent requests. A search-as-you-type box that calls admin-ajax.php on every keystroke can put serious load on a server, and responses to POST requests cannot be served from a page cache.

Second, code on admin_init that redirects or prints can break every AJAX request on the site, including other plugins'. If your plugin redirects on admin_init, for example to a welcome screen, skip AJAX requests:

PHP
if ( wp_doing_ajax() ) {
    return;
}

The same feature as a REST route

Here is the status change as a REST route, for comparison:

PHP
<?php
// wp-content/plugins/myplugin/includes/rest.php
add_action( 'rest_api_init', static function () {
    register_rest_route(
        'myplugin/v1',
        '/bookings/(?P<id>\d+)/status',
        array(
            'methods'             => WP_REST_Server::EDITABLE,
            'permission_callback' => static fn() => current_user_can( 'myplugin_manage_bookings' ),
            'args'                => array(
                'status' => array(
                    'type'     => 'string',
                    'enum'     => MYPLUGIN_BOOKING_STATUSES,
                    'required' => true,
                ),
            ),
            'callback'            => static function ( WP_REST_Request $request ) {
                $result = myplugin_set_booking_status( (int) $request['id'], $request['status'] );

                return is_wp_error( $result ) ? $result : rest_ensure_response( array( 'status' => $request['status'] ) );
            },
        )
    );
} );

The enum replaces the allowlist check, the permission callback replaces the capability check, and the wp_rest nonce sent by @wordpress/api-fetch replaces your own. Because the business logic lives in myplugin_set_booking_status(), both versions are thin wrappers.

admin-ajax.phpREST API
URLOne URL, routed by actionA URL per resource, with HTTP methods
ValidationWrite it yourselfDeclared in args: types, enums, ranges
PermissionsWrite it yourselfpermission_callback, required on every route
Loads the adminYesNo
Used by the block editor and headless front endsNoYes
Discoverable and documentedNoListed under /wp-json/ with its schema

Choose REST for new features, especially anything the block editor, a headless front end or another system calls. AJAX remains reasonable for small admin-only interactions in classic screens, and you will keep meeting it in existing plugins, so you need to read and secure it confidently.

Summary

  • admin-ajax.php routes by action to wp_ajax_{action} and wp_ajax_nopriv_{action}; with no handler it responds with 0.
  • Every handler checks a nonce and a capability, validates input with allowlists, and does the work through shared functions.
  • Use check_ajax_referer() with false and wp_send_json_error() with a status code for consistent JSON; wp_send_json_*() ends the request.
  • nopriv handlers are public endpoints: validate, rate-limit and expose nothing private.
  • admin-ajax.php loads the admin on every request; prefer REST routes for new features and keep logic in functions both can call.

Further reading