AJAX requests
A "Like" button, loading more posts, submitting a form without a reload — all of these are requests from JavaScript to the server. WordPress has an old, proven mechanism for them: the admin-ajax.php file and the wp_ajax_ hooks.
A handler
Every request carries an action parameter. WordPress uses it to call a hook of the same name:
// functions.php
add_action('wp_ajax_mytheme_like', 'mytheme_handle_like');
add_action('wp_ajax_nopriv_mytheme_like', 'mytheme_handle_like');
function mytheme_handle_like(): void
{
check_ajax_referer('mytheme_like', 'nonce');
$postId = absint($_POST['post_id'] ?? 0);
if (!$postId || get_post_status($postId) !== 'publish') {
wp_send_json_error(['message' => 'Post not found'], 404);
}
$likes = (int) get_post_meta($postId, 'likes', true) + 1;
update_post_meta($postId, 'likes', $likes);
wp_send_json_success(['likes' => $likes]);
}The wp_ajax_ hook fires for logged-in users, wp_ajax_nopriv_ for guests. For an action to work for everyone, both are attached.
The response
wp_send_json_success() and wp_send_json_error() print JSON and end the program at once:
{ "success": true, "data": { "likes": 12 } }The address and the nonce for the script
The script needs to know the address of admin-ajax.php and the nonce. They are passed when the script is enqueued:
add_action('wp_enqueue_scripts', function () {
wp_enqueue_script('mytheme-like', get_template_directory_uri() . '/assets/like.js', [], '1.0.0', true);
wp_add_inline_script('mytheme-like', 'const mythemeLike = ' . wp_json_encode([
'url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('mytheme_like'),
]) . ';', 'before');
});The button
<?php
// template-parts/like.php
?>
<button class="like" data-post="<?php echo esc_attr(get_the_ID()); ?>">
♥ <span><?php echo (int) get_post_meta(get_the_ID(), 'likes', true); ?></span>
</button>A request from JavaScript
// assets/like.js
document.querySelectorAll('.like').forEach((button) => {
button.addEventListener('click', async () => {
const body = new FormData();
body.append('action', 'mytheme_like');
body.append('nonce', mythemeLike.nonce);
body.append('post_id', button.dataset.post);
const response = await fetch(mythemeLike.url, { method: 'POST', body });
const result = await response.json();
if (result.success) {
button.querySelector('span').textContent = result.data.likes;
}
});
});The data is sent as a regular form: the handler reads it from $_POST.
Returning markup
For loading more posts a handler can return ready HTML:
add_action('wp_ajax_nopriv_mytheme_more', 'mytheme_handle_more');
add_action('wp_ajax_mytheme_more', 'mytheme_handle_more');
function mytheme_handle_more(): void
{
$query = new WP_Query(['paged' => absint($_GET['page'] ?? 1)]);
ob_start();
while ($query->have_posts()) {
$query->the_post();
get_template_part('template-parts/card');
}
wp_send_json_success(['html' => ob_get_clean(), 'pages' => $query->max_num_pages]);
}AJAX or the REST API
admin-ajax.php | REST API | |
|---|---|---|
| Address | One for every action | Each route has its own |
| Parameter validation | By hand | Described in the route |
| When to choose | Small actions in a theme | An API for apps, many routes |
Common mistake
If a request gets the response 0, the handler was not found. There are usually three causes: the request has no action parameter or it is named differently; the wp_ajax_nopriv_ hook is forgotten while you are testing as a guest; the add_action code sits in a page template and is not loaded on a request to admin-ajax.php — it belongs in functions.php or a plugin.