You submit a form, the whole page reloads, the scroll jumps to the top and the user has to scroll down again. AJAX means doing that same server communication behind the scenes and changing only the part of the page that needs it. For years we reached for jQuery to do this, but browsers now have the fetch function and a library is no longer needed.
If the basics of async/await are new to you, first read essential JavaScript for WordPress and Laravel developers.
The simplest request: GET#
async function loadPosts() {
const res = await fetch('/api/posts');
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
const posts = await res.json();
return posts;
}
loadPosts()
.then((posts) => console.log(posts))
.catch((err) => console.error(err));This short example has two points. First, fetch returns a Promise and res.json() is also a Promise, so both need await. Second, a 404 or 500 response doesn't automatically become an error; checking res.ok is up to you.
Sending JSON with POST#
async function createComment(postId, text) {
const res = await fetch('/api/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ post_id: postId, text }),
});
if (!res.ok) {
const problem = await res.json().catch(() => ({}));
throw new Error(problem.message ?? `Error: ${res.status}`);
}
return res.json();
}The body must be a string, so JSON.stringify is needed, and the Content-Type header tells the server what format it is dealing with. To send forms and files, use FormData and don't set the Content-Type header yourself; the browser adds the boundary:
const form = document.querySelector('#contact');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const data = new FormData(form);
const res = await fetch(form.action, { method: 'POST', body: data });
const result = await res.json();
console.log(result);
});Error handling and cancelling a request#
Consider three cases separately: a network failure (an exception), an unsuccessful response (!res.ok) and bad data (an error in json()). For slow requests, cancelling with AbortController is useful:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
const res = await fetch('/api/slow', { signal: controller.signal });
console.log(await res.json());
} catch (err) {
if (err.name === 'AbortError') {
console.warn('Request was cancelled');
} else {
console.error(err);
}
} finally {
clearTimeout(timer);
}Also show a "loading" state in the UI and disable the button until the request finishes so the user doesn't submit twice.
Connecting to WordPress#
In WordPress you have two common options: the REST API or admin-ajax.php. The modern way is REST. First, in PHP, register the route with a permission check:
<?php
add_action('rest_api_init', function () {
register_rest_route('demo/v1', '/note', [
'methods' => 'POST',
'callback' => 'demo_save_note',
'permission_callback' => fn () => current_user_can('edit_posts'),
'args' => [
'text' => [
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
});
function demo_save_note(WP_REST_Request $request): WP_REST_Response
{
$text = $request->get_param('text');
// safe saving...
return new WP_REST_Response(['saved' => true, 'text' => esc_html($text)], 200);
}Then register the script and pass the URL and nonce with wp_localize_script:
<?php
add_action('wp_enqueue_scripts', function () {
wp_enqueue_script('demo-note', plugins_url('note.js', __FILE__), [], '1.0', true);
wp_localize_script('demo-note', 'demoNote', [
'url' => esc_url_raw(rest_url('demo/v1/note')),
'nonce' => wp_create_nonce('wp_rest'),
]);
});And in JavaScript:
async function saveNote(text) {
const res = await fetch(demoNote.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': demoNote.nonce,
},
body: JSON.stringify({ text }),
});
return res.json();
}WordPress core checks the X-WP-Nonce header, and without it a logged-in user is treated as anonymous. Never leave permission_callback empty or set to __return_true unless the route is truly public. The overall plugin structure is covered in building a WordPress plugin from scratch.
Connecting to Laravel#
Laravel requires a CSRF token for POST requests on web routes. Put the token in the page head:
<meta name="csrf-token" content="{{ csrf_token() }}">And send it with the request:
const token = document.querySelector('meta[name="csrf-token"]').content;
const res = await fetch('/notes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': token,
},
body: JSON.stringify({ text: 'Hello' }),
});The Accept: application/json header makes Laravel return validation errors as JSON (status 422) instead of a redirect. You can also send the token in the body with the _token field.
A few closing tips#
- Put the server's response on the page with
textContent, notinnerHTML. - Do validation and access control on the server; browser code can be tampered with.
- Build URLs from user data with
encodeURIComponentorURLSearchParams. - On a page with several requests, show the error state in a way the user can understand.
To better understand route design and status codes, also see what is a REST API.
A clean pattern: a helper function#
If you send requests in several places, don't repeat yourself. Write a small function that handles headers, error checking and JSON conversion in one place:
async function api(url, { method = 'GET', body, headers = {} } = {}) {
const res = await fetch(url, {
method,
headers: {
'Accept': 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(data?.message ?? `Error: ${res.status}`);
}
return data;
}
// usage
const posts = await api('/api/posts');
await api('/api/comments', { method: 'POST', body: { text: 'Hello' } });This keeps the error logic in one place, and if you later want to add a token or a new header, you change only one function.
Sending parameters in GET#
For search parameters, don't glue the string together by hand. URLSearchParams encodes values correctly and prevents subtle bugs, especially when the user types non-Latin text such as Arabic or Persian, or special characters:
const params = new URLSearchParams({ q: 'PHP course', page: 2 });
const res = await fetch(`/api/search?${params}`);A security note about cookies#
A fetch request to the same origin sends cookies by default. For another origin you must set credentials: 'include' and the server must explicitly allow it; do this only when needed, because cross-origin requests with cookies move an attacker closer to CSRF. PHP code security explains why tokens matter.
Summary#
fetchis built into the browser; jQuery isn't needed.- Always check
res.ok; an HTTP error is not an exception. - Send JSON with
JSON.stringifyand aContent-Typeheader; sendFormDatawithout setting Content-Type. - In WordPress, use the REST API with
permission_callbackand theX-WP-Nonceheader. - In Laravel, send the CSRF token in the
X-CSRF-TOKENheader. - Display output with
textContentand leave validation to the server.