Create a JavaScript contact form with fetch
Progressive enhancement keeps a native POST fallback while JavaScript adds a pending state, inline errors, and an accepted confirmation. The same attempt identifier makes an unchanged retry safe.
Create a free formA copyable example
<form action="https://littlereply.com/api/submit" method="POST" id="contact-js" data-locale="en" data-success="Your message has been received.">
<input type="hidden" name="_fq_key" value="YOUR_ACCESS_KEY" />
<input type="text" name="botcheck" tabindex="-1" autocomplete="off" style="display:none" aria-hidden="true" />
<label for="contact-name">Full name *</label>
<input type="text" name="name" id="contact-name" required />
<label for="contact-email">Email address *</label>
<input type="email" name="email" id="contact-email" required />
<label for="contact-message">Message *</label>
<textarea name="message" id="contact-message" required rows="5"></textarea>
<button type="submit">Send message</button>
<p role="status" aria-live="polite"></p>
</form>
<script>
const form = document.getElementById('contact-js');
const button = form.querySelector('button[type="submit"]');
const status = form.querySelector('[role="status"]');
let attempt = { content: '', id: '', accepted: false };
let busy = false;
function readBody() {
const body = new URLSearchParams();
for (const [name, value] of new FormData(form)) body.append(name, String(value));
return body;
}
form.addEventListener('input', () => {
if (!busy) {
button.disabled = attempt.accepted && attempt.content === readBody().toString();
if (!button.disabled) status.textContent = '';
}
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (busy || !form.reportValidity()) return;
const body = readBody();
const content = body.toString();
if (content !== attempt.content) attempt = { content, id: crypto.randomUUID(), accepted: false };
if (attempt.accepted) return;
busy = true;
button.disabled = true;
status.textContent = 'Sending…';
try {
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json', 'Idempotency-Key': attempt.id },
body
});
const result = await response.json();
if (!response.ok) throw new Error(response.status === 429
? 'Limit reached. Wait before retrying.'
: 'Could not submit. Check the fields and try again.');
if (!result.data) throw new Error('Unreadable confirmation. Check receipt before retrying.');
attempt.accepted = true;
status.textContent = 'Your message was stored. Email delivery is tracked separately.';
} catch (error) {
status.textContent = error instanceof Error ? error.message : 'Could not connect. Try again.';
} finally {
busy = false;
button.disabled = attempt.accepted && attempt.content === readBody().toString();
}
});
</script>The key YOUR_ACCESS_KEY is a placeholder to replace with your form’s public key. The code contains no server key or destination address.
Keep the native fallback
Give the form a real POST action and named fields before adding a submit listener. Visitors still have a working path when the script does not load.
Handle the full response
Check response.ok and parse the data/error envelope. Show validation and limit messages without clearing fields. Announce status changes through an accessible live region.
Retry the same attempt safely
Create the idempotency key before the first request and reuse it for unchanged retries. Unlock the button after known failures and create a new key after the visitor edits the submission.
Test before publishing
Test a valid message, invalid email address, network error, and double click. Check that only one message appears in your workspace, text survives an error, and the button becomes usable again. Test from a local server or HTTPS website, not only a file opened using file://.
This small example covers text fields. For files or CAPTCHA, use the documented journey and dashboard code: an attachment is not a simple JSON field. Do not work around CORS using mode=no-cors, because you would not be able to read confirmation.
Sources
Frequently asked questions
Why keep the form action?
It provides a functional fallback and makes the destination visible in the markup.
When should I generate a new retry key?
When the submitted content changes or after a confirmed successful request.
Can I remove action and method?
Keep them so HTML submission still works when the script is unavailable. Native confirmation differs from confirmation inside the page.