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#

JavaScript
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#

JavaScript
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:

JavaScript
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:

JavaScript
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
<?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
<?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:

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:

HTML
<meta name="csrf-token" content="{{ csrf_token() }}">

And send it with the request:

JavaScript
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, not innerHTML.
  • Do validation and access control on the server; browser code can be tampered with.
  • Build URLs from user data with encodeURIComponent or URLSearchParams.
  • 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:

JavaScript
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:

JavaScript
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#

  • fetch is built into the browser; jQuery isn't needed.
  • Always check res.ok; an HTTP error is not an exception.
  • Send JSON with JSON.stringify and a Content-Type header; send FormData without setting Content-Type.
  • In WordPress, use the REST API with permission_callback and the X-WP-Nonce header.
  • In Laravel, send the CSRF token in the X-CSRF-TOKEN header.
  • Display output with textContent and leave validation to the server.