AJAX form with JavaScript

Submit a contact form with fetch and show the result on the page without a reload, including loading and error states.

A plain HTML form reloads the page and sends the visitor to a thank-you page. With a few lines of JavaScript you can send the same form in the background and show the result right where the visitor is. This example uses fetch with no libraries, and the form keeps working if the script fails to load.

Start with a form that works without JavaScript#

Write the form as a normal HTML post first. The script only takes over when it runs, so visitors with JavaScript blocked, or on a flaky connection where the script never arrives, still get their message through.

<form id="contact" action="https://silentforms.com/api/submit" method="POST">
  <input type="hidden" name="accessKey" value="YOUR_ACCESS_KEY_HERE">

  <label for="name">Name</label>
  <input id="name" name="name" type="text" autocomplete="name" required>

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>

  <input type="checkbox" name="botcheck" style="display: none;" tabindex="-1" autocomplete="off">

  <button type="submit">Send message</button>
  <p id="contact-status" role="status" aria-live="polite"></p>
</form>

The empty paragraph with role="status" is where the result appears. Screen readers announce changes to it without moving focus.

Send it with fetch (JSON)#

Add this script after the form, or in a file loaded with defer:

<script>
  const form = document.getElementById('contact');
  const status = document.getElementById('contact-status');
  const button = form.querySelector('button[type="submit"]');

  form.addEventListener('submit', async (event) => {
    event.preventDefault();

    button.disabled = true;
    button.textContent = 'Sending…';
    status.textContent = '';

    const fields = Object.fromEntries(new FormData(form));

    try {
      const response = await fetch(form.action, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
        body: JSON.stringify(fields),
      });
      const result = await response.json().catch(() => ({}));

      if (response.ok) {
        status.textContent = 'Thanks! Your message has been sent.';
        form.reset();
      } else {
        status.textContent = result.message || 'Something went wrong. Please try again.';
      }
    } catch (error) {
      status.textContent = 'Could not reach the server. Check your connection and try again.';
    } finally {
      button.disabled = false;
      button.textContent = 'Send message';
    }
  });
</script>

How it fits together:

  • event.preventDefault() stops the normal page navigation. Because this only runs once the script has loaded, the form falls back to a regular post without it.
  • The browser still validates first. The submit event only fires after required and type="email" checks pass, so you do not need to re-check them here.
  • form.action reuses the URL from the HTML, so the endpoint lives in one place.
  • Object.fromEntries(new FormData(form)) turns the fields into an object, including the hidden access key. The honeypot is unticked, so it is not included.
  • The JSON body makes SilentForms answer with JSON instead of a redirect. A success looks like { "ok": true, "message": "Form submitted successfully." } (plus status and success); an error has ok: false and a readable message such as "Email address is invalid."
  • .catch(() => ({})) on response.json() covers the rare response that is not JSON, such as an error page from a proxy, so the visitor still sees a fallback message.
  • try/catch handles the case where no response arrives at all: offline, DNS failure, a blocked request. fetch only rejects for network errors; an HTTP error such as 400 or 429 resolves normally, which is why response.ok is checked separately.
  • Disabling the button prevents double submissions while the request is in flight, and finally re-enables it whatever happens.

Variant: send FormData directly#

You can skip the conversion and post the FormData object as it is. The browser then sends multipart/form-data and sets the content type itself. Ask for JSON with the Accept header, otherwise you get a redirect instead of a JSON body:

const response = await fetch(form.action, {
  method: 'POST',
  headers: { Accept: 'application/json' },
  body: new FormData(form),
});

Do not set Content-Type yourself in this variant: the browser has to add the multipart boundary to it.

Which to pick? FormData is shorter and keeps repeated fields, such as a group of checkboxes with the same name, without extra work. Object.fromEntries keeps only the last value of a repeated name, so with checkbox groups either send FormData or build arrays yourself (JSON bodies may contain arrays). For a plain contact form both behave the same. File inputs are ignored either way.

Handling rate limits#

SilentForms allows 10 submissions a minute from one IP address. A visitor who hits it gets status 429 and the message "Too many requests. Please try again in a minute.", which the code above shows as is. Every error message is written to be shown to visitors, so you rarely need your own wording. See Errors for the full list.

Testing#

Open the page, send a message and watch the Network tab in the browser's developer tools: you should see one POST with status 200 and a JSON response, and the email in your inbox shortly after. To test the error path, temporarily change the access key to an invalid value and check that "Invalid or inactive access key." appears under the button. To test the network error, switch the Network tab to Offline and send again.

Next steps#