AJAX contact form
The visitor presses send, the message goes, and the page never moves. Around twenty lines of vanilla JavaScript — and because the form keeps its action attribute, it still submits normally if the script never runs.
Replace YOUR_FORM_ID with a form of your own and this sends to your inbox.
<form id="contact" action="https://submit.formpost.ai/YOUR_FORM_ID" method="POST">
<h2>Contact us</h2>
<p class="lede">You will stay on this page.</p>
<label for="name">Name</label>
<input id="name" type="text" name="name" required>
<label for="email">Email</label>
<input id="email" type="email" name="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" rows="3" required></textarea>
<button type="submit">Send message</button>
<p id="error" role="alert"></p>
</form>
<!-- tabindex="-1" is what makes focus() work here. A div is not focusable
otherwise, and the call silently does nothing. -->
<div id="thanks" tabindex="-1" hidden>
<h2>Thanks — message sent.</h2>
<p class="lede">We will reply to the address you gave us, usually within a day.</p>
</div>const form = document.querySelector("#contact");
const thanks = document.querySelector("#thanks");
const error = document.querySelector("#error");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
error.textContent = "";
button.disabled = true;
button.textContent = "Sending...";
try {
const res = await fetch(form.action, {
method: "POST",
// This header is the whole difference: with it the endpoint answers
// JSON, without it it answers with a redirect your fetch will follow.
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await res.json();
if (result.success) {
// Swap the form out entirely rather than clearing it. A blank form
// after submitting reads as "nothing happened, try again".
form.hidden = true;
thanks.hidden = false;
// Needs tabindex="-1" on the div, or this does nothing at all.
thanks.focus();
} else {
error.textContent = result.message;
}
} catch {
// Network-level failure only — a 422 or 500 resolves normally.
error.textContent = "Could not reach the server. Please try again.";
} finally {
button.disabled = false;
button.textContent = "Send message";
}
});body {
margin: 0;
padding: 32px 16px;
background: #fafafa;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: #171717;
}
form {
display: flex;
flex-direction: column;
max-width: 430px;
margin: 0 auto;
padding: 28px;
background: #fff;
border: 1px solid #ededed;
border-radius: 14px;
}
h2 {
margin: 0 0 4px;
font-size: 20px;
}
.lede {
margin: 0 0 20px;
font-size: 14px;
color: #737373;
}
label {
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: #404040;
}
input,
textarea {
box-sizing: border-box;
font: inherit;
margin-bottom: 16px;
padding: 10px 12px;
color: #171717;
background: #fff;
border: 1px solid #d4d4d4;
border-radius: 8px;
}
input:focus,
textarea:focus {
border-color: #171717;
outline: 2px solid rgba(23, 23, 23, 0.12);
}
textarea {
resize: vertical;
}
button {
padding: 11px 16px;
font: inherit;
font-weight: 600;
color: #fff;
background: #171717;
border: 0;
border-radius: 8px;
cursor: pointer;
}
.hint {
margin: -8px 0 16px;
font-size: 12px;
color: #a3a3a3;
}One header changes the whole response
Send Accept: application/json and the endpoint answers with a JSON body you can read. Leave it out and it answers the way it would for a plain form post — with a redirect, which your fetch will dutifully follow, leaving you parsing a thank-you page.
headers: { Accept: "application/json" }Replace the form, do not just clear it
Resetting the fields and showing a small green line above them is the most common way to build this, and it is the worst outcome for the visitor: an empty form is exactly what a failed submission looks like. Hide the form and show a confirmation block in its place, so there is no ambiguity about whether anything happened.
Move focus to the confirmation as well. A sighted visitor sees the swap; somebody using a screen reader gets nothing at all unless focus moves or the region is live.
Keep the no-JavaScript path working
The action and method attributes stay on the form even though the handler intercepts the submit. If the script fails to parse, gets blocked, or simply has not downloaded yet when somebody hits Enter, the browser does the ordinary thing and the message still arrives. This costs you nothing and is the difference between a form that degrades and a form that breaks.
Handle both kinds of failure
- A rejected submission resolves normally with success: false and a message — check the parsed body.
- A network failure rejects the promise — that is what the catch is for.
- Re-enable the button in a finally block, or one failure leaves the form permanently dead.
- Never report success without checking the body. fetch resolving is not the same as the submission being accepted.
Questions about this contact form
- How do I submit a contact form with AJAX?
- Prevent the default submit, post the form's FormData with fetch, and send Accept: application/json so you get a JSON response back instead of a redirect. Then swap the form for a confirmation.
- Do I need jQuery for an AJAX form?
- No. fetch is built into every browser that matters and needs no library. The code here is around twenty lines of plain JavaScript with no dependencies at all.
- Why does my AJAX submission return HTML instead of JSON?
- The Accept: application/json header is missing, so the endpoint responded with a redirect to the confirmation page and fetch followed it.
- Will the form still work if JavaScript fails?
- Yes, as long as you keep the action and method attributes on the form element. The browser falls back to an ordinary form post and the submission still arrives.
Related examples
JavaScript contact form
Vanilla fetch with a real status message and proper error handling.
React contact form
One component, four states, no form library and no field-by-field state.
Modern contact form
A soft card, a gradient button and a proper focus ring. Pure CSS, no library.
Or go back to all 21 contact form examples.
Give this form an endpoint
Formpost takes the submission, emails it to you and keeps a searchable copy. Free for 250 messages a month, with unlimited forms and no card.