You build a WordPress form. It should save without reloading the page.
You click Send. Nothing happens.
Or the Network tab shows admin-ajax.php returning a single character: 0.
If that sounds familiar, you are not alone. After six years of building WordPress themes, plugins, and WooCommerce projects, I keep seeing the same pattern on client sites. The JavaScript looks fine. The PHP looks fine. But the request never reaches the callback — or it reaches the wrong hook — or the response is not JSON at all.
Most of the time, the problem is not JavaScript skill. It is not understanding how WordPress routes background requests.
This article walks through everything: what AJAX is in WordPress, how admin-ajax.php works, security with nonces, enqueueing scripts, frontend vs admin, JSON responses, debugging, and when to use REST instead. Copy the examples into your own plugin and change the prefixes.
What is AJAX in WordPress?
AJAX lets the browser talk to the server in the background without refreshing the page.
Real examples you have seen:
- Submit a settings form and show "Saved!" instantly
- Load more blog posts when someone clicks a button
- Update a cart count in the header
- Save a contact form without leaving the page
WordPress does not ask you to invent a custom API endpoint for every plugin. It gives you one built-in router:
/wp-admin/admin-ajax.php
You register an action name in PHP. JavaScript sends that same action in every POST request. WordPress connects the two — similar to add_action(), but triggered from the browser instead of a page load.
The one rule that breaks most projects
Every AJAX request must include:
action=your_action_name
WordPress reads that value and fires a hook:
-
wp_ajax_{action}— logged-in users only -
wp_ajax_nopriv_{action}— logged-out visitors
If you register wp_ajax_send_form_data in PHP but JavaScript sends send-form-data, nothing runs.
If you only register wp_ajax_ but test while logged out on the public site, nothing runs.
If you forget the action key entirely, WordPress returns 0.
That is normal WordPress behavior. Once you expect it, debugging gets much faster.
The full request loop
Here is what happens on a working setup:
- User clicks Submit on a form
- JavaScript calls
preventDefault()so the page does not reload - JavaScript POSTs to
admin-ajax.phpwithaction,nonce, and your fields - WordPress fires
wp_ajax_{action}orwp_ajax_nopriv_{action} - Your PHP callback runs, verifies the nonce, sanitizes input
- PHP returns JSON via
wp_send_json_success()orwp_send_json_error() - JavaScript reads
response.successand updates the UI
If any step in that chain is wrong, the feature feels "broken" even when half the code is correct.
Part 1: Your first AJAX handler
Start with the smallest possible example: a logged-in admin request with no nonce yet (we add security in Part 2).
Register the action in PHP:
add_action( 'wp_ajax_send_form_data', 'myplugin_send_form_data_callback' );
function myplugin_send_form_data_callback() {
wp_send_json_success(
array(
'message' => 'Hello from WordPress AJAX!',
)
);
}
What to notice:
- The action name is
send_form_data— prefix it with your plugin name in real projects (myplugin_send_form_data) so you never collide with other plugins - JavaScript must send exactly
action: 'send_form_data' -
wp_send_json_success()prints JSON and stops execution — you do not needdie()after it
Send the request from JavaScript inside wp-admin:
jQuery.ajax({
method: 'POST',
url: ajaxurl,
data: {
action: 'send_form_data',
},
}).done(function (response) {
console.log(response.data.message);
});
Important detail: the global ajaxurl variable exists automatically in the WordPress admin area. It does not exist on the public frontend. For frontend forms, use wp_localize_script() — covered in Part 3.
Part 2: Secure AJAX with nonces
AJAX endpoints are public URLs. Anyone can send a POST request if they guess your action name.
For any operation that saves, deletes, or changes data, verify a nonce — a short-lived token WordPress generates to prove the request came from your site.
Step 1 — Create the nonce and pass it to JavaScript
wp_localize_script(
'myplugin-admin-js',
'my_ajax_obj',
array(
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'myplugin-ajax' ),
)
);
This creates a global JavaScript object my_ajax_obj with two values your script needs: the AJAX URL and the nonce string.
The script handle 'myplugin-admin-js' must match the handle you used in wp_enqueue_script().
The string 'myplugin-ajax' is your nonce action name — use the same string when verifying.
Step 2 — Send the nonce from JavaScript
data: {
action: 'send_form_data',
nonce: my_ajax_obj.nonce,
user_name: 'Hemal',
}
Step 3 — Verify in PHP before doing anything else
function myplugin_send_form_data_callback() {
if ( ! isset( $_POST['nonce'] ) ) {
wp_send_json_error(
array(
'message' => 'Nonce is missing.',
)
);
}
$nonce = sanitize_text_field( wp_unslash( $_POST['nonce'] ) );
if ( ! wp_verify_nonce( $nonce, 'myplugin-ajax' ) ) {
wp_send_json_error(
array(
'message' => 'Security check failed.',
)
);
}
$name = sanitize_text_field( wp_unslash( $_POST['user_name'] ?? '' ) );
wp_send_json_success(
array(
'message' => 'Success!',
'name' => $name,
)
);
}
Without a nonce, anyone on the internet can spam your endpoint. With a nonce, only requests that include a valid token from your enqueued script will pass verification.
Part 3: Enqueue scripts and pass data correctly
If your form lives on a custom admin page, you must load your JavaScript on that screen and pass the AJAX URL yourself.
add_action( 'admin_enqueue_scripts', 'myplugin_load_admin_scripts' );
function myplugin_load_admin_scripts() {
wp_enqueue_script(
'myplugin-admin-js',
plugin_dir_url( __FILE__ ) . 'assets/js/admin.js',
array( 'jquery' ),
'1.0.0',
true
);
wp_localize_script(
'myplugin-admin-js',
'my_ajax_obj',
array(
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'myplugin-ajax' ),
)
);
}
What each part does:
| Part | Purpose |
|---|---|
admin_enqueue_scripts |
Loads assets only in wp-admin |
'myplugin-admin-js' |
Script handle — must match in wp_localize_script
|
array( 'jquery' ) |
Loads jQuery before your script |
true at the end |
Loads script in footer (after DOM exists) |
wp_localize_script |
Exposes my_ajax_obj.ajax_url and my_ajax_obj.nonce to JS |
admin_url( 'admin-ajax.php' ) |
Full URL — works on local, staging, and subfolder installs |
Never hard-code /wp-admin/admin-ajax.php. Always use admin_url().
Part 4: Put the pieces together
A clean plugin splits responsibilities:
- Assets class — enqueue JS, pass nonce and URL
- Admin page — render the HTML form
-
Ajax class — register
wp_ajax_*hooks and callbacks
On the JavaScript side:
(function ($) {
$(document).ready(function () {
$('.myplugin-ajax-form').on('submit', function (event) {
event.preventDefault();
var $form = $(this);
$.ajax({
method: 'POST',
url: my_ajax_obj.ajax_url,
data: {
action: 'send_form_data',
nonce: my_ajax_obj.nonce,
user_name: $form.find('[name="user_name"]').val(),
},
}).done(function (response) {
if (response.success) {
alert(response.data.message);
} else {
alert(response.data.message || 'Request failed.');
}
});
});
});
})(jQuery);
When the user clicks Send:
- Normal form submit is blocked
- POST goes to
admin-ajax.php - WordPress reads
action=send_form_data - Hook
wp_ajax_send_form_datafires - PHP verifies nonce and returns JSON
- JavaScript shows the result
That is the entire system. Nothing hidden.
Part 5: JSON responses — success and error
Use WordPress helpers. Do not echo random text and call die() — it breaks JSON parsing in JavaScript.
Success:
wp_send_json_success(
array(
'message' => 'Saved!',
'id' => 123,
)
);
Error:
wp_send_json_error(
array(
'message' => 'Something went wrong.',
),
400
);
JavaScript receives:
{
"success": true,
"data": {
"message": "Saved!",
"id": 123
}
}
Always check response.success before reading response.data.
Always sanitize before save:
$name = sanitize_text_field( wp_unslash( $_POST['user_name'] ?? '' ) );
$email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
$id = absint( $_POST['item_id'] ?? 0 );
Part 6: Frontend AJAX for logged-out users
Public contact forms need the nopriv hook.
Register both with the same callback:
add_action( 'wp_ajax_send_form_data', 'myplugin_send_form_data_callback' );
add_action( 'wp_ajax_nopriv_send_form_data', 'myplugin_send_form_data_callback' );
| Hook | When it runs |
|---|---|
wp_ajax_{action} |
User is logged in |
wp_ajax_nopriv_{action} |
User is not logged in |
This is the most common "works for me, broken for clients" bug. You test as admin. Real visitors are logged out.
For admin-only actions (delete post, save settings), skip nopriv and add current_user_can() inside the callback instead.
Part 7: Enqueue on the frontend
Admin uses admin_enqueue_scripts. The public site uses wp_enqueue_scripts:
add_action( 'wp_enqueue_scripts', 'myplugin_load_public_scripts' );
function myplugin_load_public_scripts() {
wp_enqueue_script(
'myplugin-public-js',
plugin_dir_url( __FILE__ ) . 'assets/js/public.js',
array( 'jquery' ),
'1.0.0',
true
);
wp_localize_script(
'myplugin-public-js',
'my_ajax_obj',
array(
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'myplugin-ajax' ),
)
);
}
Same pattern. Different hook. If the script is not enqueued on the page where your form appears, the JavaScript never runs — even if PHP is perfect.
Part 8: Using fetch() instead of jQuery
WordPress does not care which library you use. It cares about POST, action, and nonce.
document.querySelector('.myplugin-ajax-form')?.addEventListener('submit', async function (event) {
event.preventDefault();
var form = event.target;
var formData = new FormData();
formData.append('action', 'send_form_data');
formData.append('nonce', my_ajax_obj.nonce);
formData.append('user_name', form.querySelector('[name="user_name"]').value);
var response = await fetch(my_ajax_obj.ajax_url, {
method: 'POST',
body: formData,
credentials: 'same-origin',
});
var json = await response.json();
if (json.success) {
alert(json.data.message);
} else {
alert(json.data.message || 'Request failed.');
}
});
Part 9: admin-ajax.php vs REST API
WordPress offers two async patterns:
admin-ajax.php
- URL:
/wp-admin/admin-ajax.php - Register with
add_action( 'wp_ajax_*' ) - Best for classic plugins, wp-admin UI, small forms
REST API
- URL:
/wp-json/your-namespace/v1/your-route - Register with
register_rest_route() - Best for headless sites, Gutenberg blocks, mobile apps
For most theme and plugin work inside WordPress admin, admin-ajax.php is still the fastest path. Move to REST when you need standard HTTP routes, external consumers, or block editor integration.
Part 10: Debugging when AJAX fails
Check these in order before guessing:
-
Action name — identical string in PHP hook and JavaScript
actionfield? -
Network tab — open DevTools, find
admin-ajax.php, read the response body -
Logged out? — did you register
wp_ajax_nopriv_? -
Script loaded? — is
wp_enqueue_scriptrunning on this exact page? - Nonce — created, passed to JS, sent back, verified with same action string?
Common responses:
| Response | Meaning |
|---|---|
0 |
Action not registered or callback not found |
-1 |
Nonce failed or permission denied |
JSON with "success": false
|
Callback ran but returned an error |
| Empty or HTML | PHP error, or you used echo instead of JSON helpers |
Temporary logging in PHP:
error_log( print_r( $_POST, true ) );
Enable WP_DEBUG_LOG in wp-config.php and read wp-content/debug.log.
Quick reference
| I want to… | Use |
|---|---|
| Handle logged-in AJAX | add_action( 'wp_ajax_{action}', 'callback' ) |
| Handle logged-out AJAX | add_action( 'wp_ajax_nopriv_{action}', 'callback' ) |
| Pass URL + nonce to JS | wp_localize_script() |
| Return success JSON | wp_send_json_success( $data ) |
| Return error JSON | wp_send_json_error( $data ) |
| Verify token | wp_verify_nonce( $nonce, 'action-name' ) |
| AJAX router URL | admin_url( 'admin-ajax.php' ) |
Common mistakes
- Missing
actionin JavaScript — WordPress returns0 - Mismatched action names between PHP and JS
- Forgetting
wp_ajax_nopriv_on public forms - No nonce on write operations
- Using
echoinstead ofwp_send_json_* - Hard-coding
/wp-admin/admin-ajax.php - Not sanitizing
$_POST - Scheduling script on wrong hook or wrong admin page
Checklist before shipping
- Action name is unique and prefixed (
myplugin_send_form_data) -
wp_ajax_registered (andwp_ajax_nopriv_if public) - Nonce created, passed to JS, verified in PHP
- Input sanitized, output escaped
-
wp_send_json_success/wp_send_json_errorused - Script enqueued on the correct hook and page
- Tested logged in and logged out
- Network tab checked for
0,-1, or 500 errors
What you can build with this
Once the loop is clear, many features become straightforward:
- Live search and filters
- Infinite scroll / load more
- Theme option panels that save instantly
- Newsletter and contact forms
- Admin dashboards that refresh without reload
- WooCommerce-style cart updates
Same WordPress hook thinking — triggered from JavaScript.
Read more
Medium — full walkthrough with additional examples:
https://medium.com/@hemal.akanda.39/no-refresh-in-wordpress-by-ajax-4942c5e80bd2
Substack — same topic in plain English, no code:
https://substack.com/@mdhemalakhand/note/p-201821073
What I am building now
After years of WordPress client work, I am building AppsLaunch — branded Android and iPhone apps for local service businesses from one dashboard.
How it works:
- Create your company and add logo, colors, services, and products
- Customers browse in the app and send inquiries — leads land in your dashboard inbox
- Build signed Android and iPhone apps from the cloud and publish to Google Play and the App Store
- Each business gets its own app name and store listing — not a generic builder brand
Plans from $30/month. No per-lead fees on paid plans.
https://applaunch.teamzlab.com/
Final thought
WordPress AJAX is four ideas on every project:
- Register the action in PHP
- Send the same action name from JavaScript
- Secure the request with nonces and sanitization
- Return clean JSON
Once you see that loop, no-refresh forms stop feeling like magic and start feeling like normal WordPress development.
Top comments (0)