Introduction
If you've built WordPress sites for long enough, you've hit the moment where a page refresh feels wrong—where you need data to flow between the browser and server without reloading the entire page. That's where AJAX in WordPress comes in. This practical guide for developers walks through the tooling and patterns we use to make dynamic requests from the front end, keeping your users engaged while your back end handles the heavy lifting.
Understanding admin-ajax.php and wp_ajax_ hooks
WordPress provides wp-admin/admin-ajax.php as a dedicated endpoint for AJAX requests. When you POST or GET to this file with an action parameter, WordPress looks for a matching hook and fires it. This is the traditional way AJAX has worked in WordPress for over a decade, and we still see it everywhere.
Here's how the flow works: a JavaScript request includes action=my_custom_action, WordPress matches that to wp_ajax_my_custom_action, and your hooked function runs. You can hook for logged-in users only or for both logged-in and logged-out visitors.
Let's walk through a real example. Imagine we're building a product filter for an e-commerce site where users refine results by category without page reloads.
// In your theme's functions.php or plugin file
add_action( 'wp_ajax_filter_products', 'handle_product_filter' );
add_action( 'wp_ajax_nopriv_filter_products', 'handle_product_filter' );
function handle_product_filter() {
// Verify nonce first (covered in next section)
check_ajax_referer( 'product_filter_nonce' );
$category = isset( $_POST['category'] ) ? sanitize_text_field( $_POST['category'] ) : '';
$args = array(
'post_type' => 'product',
'posts_per_page' => 12,
);
if ( ! empty( $category ) ) {
$args['tax_query'] = array(
array(
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => $category,
),
);
}
$query = new WP_Query( $args );
if ( $query->have_posts() ) {
ob_start();
while ( $query->have_posts() ) {
$query->the_post();
echo '<div>' . get_the_title() . '</div>';
}
wp_reset_postdata();
$html = ob_get_clean();
wp_send_json_success( array( 'html' => $html ) );
} else {
wp_send_json_error( array( 'message' => 'No products found.' ) );
}
}On the front end, your JavaScript posts to this action:
// In your theme JavaScript
jQuery.post( ajaxurl, {
action: 'filter_products',
category: selectedCategory,
nonce: productFilterNonce, // More on this below
}, function( response ) {
if ( response.success ) {
jQuery( '#products-container' ).html( response.data.html );
}
} );The key point here is understanding the request–response cycle. The action parameter routes to your hooked function, you process the data, and you send back JSON. The wp_ajax_nopriv hook is crucial if unauthenticated users need to access the endpoint; without it, only logged-in users can trigger the action.
Prefer the REST API for new code
While admin-ajax.php still works and appears everywhere in older WordPress codebases, the REST API is the modern standard. Introduced in WordPress 4.7, it provides a cleaner, more RESTful approach to building dynamic interactions. If you're starting a new project, build your AJAX layer on REST endpoints.
Why prefer REST for new development? The REST API integrates with WordPress's permission system, supports better caching strategies, works across origins with proper CORS headers, and follows conventions that JavaScript teams already know. It's also easier to test and document.
Here's the product filter example rewritten as a REST endpoint:
add_action( 'rest_api_init', function() {
register_rest_route( 'mysite/v1', '/products/filter', array(
'methods' => 'GET',
'callback' => 'rest_filter_products',
'permission_callback' => '__return_true', // Or your permission check
'args' => array(
'category' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'required' => false,
),
),
) );
} );
function rest_filter_products( WP_REST_Request $request ) {
$category = $request->get_param( 'category' );
$args = array(
'post_type' => 'product',
'posts_per_page' => 12,
);
if ( ! empty( $category ) ) {
$args['tax_query'] = array(
array(
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => $category,
),
);
}
$query = new WP_Query( $args );
$products = array();
while ( $query->have_posts() ) {
$query->the_post();
$products[] = array(
'id' => get_the_ID(),
'title' => get_the_title(),
'excerpt' => get_the_excerpt(),
);
}
wp_reset_postdata();
return new WP_REST_Response( $products, 200 );
}Now your front end calls a standard URL:
fetch( '/wp-json/mysite/v1/products/filter?category=' + selectedCategory )
.then( response => response.json() )
.then( products => {
// Render products
} );This is cleaner, more discoverable, and aligns with how modern APIs work. If you're maintaining legacy code using admin-ajax.php, that's fine—but new functionality should go through the REST API.
Verify nonces to prevent CSRF attacks
Whether you use admin-ajax.php or the REST API, you must verify nonces. A nonce (number used once) is a security token that proves the request came from your site, not from an attacker's site. Without nonce verification, someone could craft a malicious page that makes requests to your WordPress site on behalf of logged-in users.
With admin-ajax.php, create the nonce in your PHP template or in a script tag, then include it in your AJAX request:
// In your theme template
wp_localize_script( 'my-script', 'myAjax', array(
'url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'product_filter_nonce' ),
) );Then verify it on the server side:
function handle_product_filter() {
check_ajax_referer( 'product_filter_nonce', 'nonce' );
// Process the request...
}For the REST API, nonces are handled automatically if you're using the default wp-json-nonce header. When using WordPress's own fetch or jQuery, the nonce is often included transparently. But if you're using a custom fetch setup, add the nonce header:
const nonce = document.querySelector( 'meta[name="wp-nonce"]' ).content;
fetch( '/wp-json/mysite/v1/products/filter', {
method: 'POST',
headers: {
'X-WP-Nonce': nonce,
'Content-Type': 'application/json',
},
body: JSON.stringify( { category: selectedCategory } ),
} )
.then( response => response.json() )
.then( data => {
// Handle response
} );Always verify the nonce before processing sensitive data. If the nonce fails, WordPress returns an error and stops execution.
Return JSON with wp_send_json
WordPress provides helper functions to send JSON responses cleanly: wp_send_json(), wp_send_json_success(), and wp_send_json_error(). These functions set the correct headers, handle encoding, and exit cleanly. Use them instead of manually echoing JSON.
wp_send_json_success() wraps your data in a success envelope:
if ( $result ) {
wp_send_json_success( array(
'message' => 'Product added to cart.',
'cart_count' => $new_cart_count,
) );
}The response arrives as:
{
"success": true,
"data": {
"message": "Product added to cart.",
"cart_count": 3
}
}For errors, use wp_send_json_error():
if ( ! $product_exists ) {
wp_send_json_error( array(
'message' => 'Product not found.',
) );
}The response becomes:
{
"success": false,
"data": {
"message": "Product not found."
}
}This consistent structure makes client-side handling predictable. Your JavaScript can check response.success and act accordingly. For more control, use wp_send_json( $data ) which sends the raw array as JSON without the success wrapper.
Common mistakes
We've seen several patterns cause problems in production. First, skipping nonce verification because the endpoint "seems internal" or "only processes read data." Nonces protect against CSRF regardless of operation type—always verify.
Second, not sanitizing input. $_POST and $_REQUEST are untrusted. Always use sanitize_text_field(), absint(), wp_kses_post(), or similar functions depending on your data type. Sanitization prevents injection attacks and data corruption.
Third, using wp_ajax_nopriv_ when you don't need to. If only authenticated users should access an endpoint, omit the nopriv hook. Unnecessary access increases your attack surface.
Fourth, building complex state management in jQuery without proper error handling. Network calls fail. Timeouts happen. Always include error callbacks and user feedback for failed requests.
Fifth, mixing admin-ajax.php and REST API endpoints in a single feature. Pick one pattern and stick with it for consistency and maintainability.
Key takeaways
- admin-ajax.php remains functional but is legacy; the REST API is the modern standard for new WordPress AJAX development.
- Always hook both the privileged action (
wp_ajax_action_name) and unprivileged variant (wp_ajax_nopriv_action_name) if unauthenticated users need access. - Nonces are non-negotiable. Verify them server-side with
check_ajax_referer()or via REST API headers before processing any request. - Sanitize all input parameters using WordPress sanitization functions appropriate to your data type.
- Use
wp_send_json_success()andwp_send_json_error()for consistent, structured JSON responses. - For new projects, prioritize the REST API over admin-ajax.php for better alignment with modern web standards and easier testing.






Comments
Loading comments…