| name | add-ajax-handler |
| description | Add a WordPress AJAX handler to a plugin, with nonce verification, input sanitisation, and JSON response. |
Add an AJAX Handler
Register a WordPress AJAX handler in an existing plugin. Covers nonce generation, hook registration, input sanitisation, and JSON response. Also notes when to use the REST API instead.
Consider the REST API first. If the request returns structured data, accepts typed arguments, or needs to be callable from JS outside the admin, add-rest-endpoint is usually the better choice. Use AJAX handlers for admin-only actions, simple one-off requests, or when integrating with legacy code that already uses admin-ajax.php.
Inputs
| Input | Required | Notes |
|---|
| Plugin | Yes | Which plugin in packages/plugins/ |
| Action name | Yes | Machine name for the AJAX action, e.g. {plugin-slug}_save_item. Must be unique across all plugins. |
| Accessibility | Yes | Logged-in users only, or also non-logged-in (public)? |
| Capability | If logged-in only | Which capability is required, e.g. edit_posts, manage_options |
| Inputs | Yes | What $_POST parameters the handler expects |
| What it does | Yes | Description of the work the handler performs |
If any required inputs are missing, ask for them before writing any code.
Step 1 — Define the action name constant
Add a constant to the relevant class to avoid hardcoding the action string:
private const AJAX_ACTION = '{plugin-slug}_save_item';
Step 2 — Register the handler in hooks()
public function hooks(): void {
add_action( 'wp_ajax_' . self::AJAX_ACTION, [ $this, 'handle_save_item' ] );
}
Step 3 — Pass the nonce to JavaScript
Output the nonce via wp_localize_script() when enqueueing the script that will make the request:
public function enqueue_scripts(): void {
$asset = include plugin_dir_path( __FILE__ ) . 'assets/js/admin.asset.php';
wp_enqueue_script(
'{plugin-slug}-admin',
plugin_dir_url( __FILE__ ) . 'assets/js/admin.js',
$asset['dependencies'],
$asset['version'],
true
);
wp_localize_script(
'{plugin-slug}-admin',
'{pluginSlug}Ajax',
[
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::AJAX_ACTION ),
]
);
}
In JavaScript, send the nonce as a POST field:
const response = await window.fetch( window['{pluginSlug}Ajax'].ajaxUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams( {
action: '{plugin-slug}_save_item',
nonce: window['{pluginSlug}Ajax'].nonce,
item_id: itemId,
} ),
} );
const data = await response.json();
if ( ! data.success ) {
console.error( data.data.message );
}
Step 4 — Implement the handler
public function handle_save_item(): void {
check_ajax_referer( self::AJAX_ACTION, 'nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( [ 'message' => __( 'Forbidden.', '{plugin-slug}' ) ], 403 );
}
$item_id = absint( $_POST['item_id'] ?? 0 );
$title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) );
if ( ! $item_id || ! $title ) {
wp_send_json_error( [ 'message' => __( 'Invalid input.', '{plugin-slug}' ) ], 400 );
}
$result = \wp_update_post( [
'ID' => $item_id,
'post_title' => $title,
] );
if ( \is_wp_error( $result ) ) {
wp_send_json_error( [ 'message' => $result->get_error_message() ], 500 );
}
wp_send_json_success( [ 'item_id' => $item_id ] );
}
Step 5 — Lint
composer lint
composer lint-fix
composer lint
Conventions
- Action name prefix: always
{plugin-slug}_action_name — prevents collisions with other plugins
check_ajax_referer() first: call this before any capability check or input processing — die early if the nonce is invalid
- Sanitise every
$_POST value: use the appropriate sanitiser for the data type (absint, sanitize_text_field, sanitize_email, esc_url_raw, etc.). Always wp_unslash() before sanitising text fields.
- Capability check before work: always verify the user can perform the action — even for
wp_ajax_ (logged-in only) hooks, the user may not have the required capability
wp_send_json_success() / wp_send_json_error(): always use these — they set the correct Content-Type header and call wp_die() — never echo + die()
- HTTP status codes: pass a status code as the second argument to
wp_send_json_error() — 400 for bad input, 403 for forbidden, 500 for server errors
- No
wp_ajax_nopriv_ by default: only add the non-authenticated hook when the action is explicitly designed to work without a login
- Localise via
wp_localize_script(): pass ajaxUrl and nonce through script localisation — never hardcode URLs or print nonces as inline JS
Output
A AJAX_ACTION constant, a hooks() registration, a wp_localize_script() call in the script enqueue method, and a handler method that verifies the nonce, checks capability, sanitises inputs, performs the work, and responds with wp_send_json_success() or wp_send_json_error(). Lint passes clean.