
How to Submit a Form with JavaScript in 2026
If a form on your static site still reloads the page, you're probably losing more than a little polish. You're losing scroll position, any client-side state around the form, and sometimes the lead itself when the user bails before the next page paints.
The browser already knows how to submit forms. JavaScript's job is to decide whether to let that happen normally, intercept it, or trigger it on purpose, and the submit event is the control point that makes the difference.
How Form Submission Works in the Browser
A form submit starts with a simple browser rule. By default, the browser sends an HTTP request to the URL in the form's action attribute and may replace the current page with the response, which is why a plain <form> still works without any JavaScript at all, including on static hosting and in JAMstack setups. That native path is also the reason submit handling matters, because JavaScript can intercept the default action and manage the request manually instead of letting the page switch away, as the form submission flow overview notes.
The submit event is the hook
The key object is the submit event, not the button. It fires when a user clicks a submit control or presses Enter in a field inside the form, so the listener belongs on the <form> element, not only on a button. That keeps keyboard users, mobile users, and people tabbing through fields on the same path.
A minimal mental model looks like this.
Practical rule: listen on the form, stop the default navigation only when you need to, and keep the browser's own submit path intact when you don't.
| Path | Triggers submit event | Runs HTML validation | Best for |
|---|---|---|---|
submit event listener with preventDefault() |
Yes | Yes, if you let the browser validate first or use requestSubmit() |
Custom validation and async delivery |
form.submit() |
No | No | Intentional bypass in trusted internal flows |
requestSubmit() |
Yes | Yes | User-like programmatic submission |
If you want a deeper look at the HTML side of this, the companion guide on HTML form processing pairs well with this one.
Why the vocabulary matters
submit() and submit are not interchangeable. The event is where validation, analytics hooks, and UI state belong, while the method is a direct send that skips the event path entirely. That split is the reason so many production forms are built around event listeners instead of button click handlers.
Once that distinction clicks, the rest gets easier. You are choosing between the browser's native submit path, a browser-like programmatic submit, and a custom async request.
Native Submission and requestSubmit Before You Reach for fetch
The simplest correct form is often the one with no JavaScript at all. Point the form at https://api.staticforms.dev/submit, use POST, include the hidden apiKey, and let the browser do what it already knows how to do. On a static site, that works out of the box, and it keeps the markup easy to inspect and debug.
<form action="https://api.staticforms.dev/submit" method="POST">
<input type="hidden" name="apiKey" value="YOUR_API_KEY">
<label>
Email
<input type="email" name="email" required>
</label>
<label>
Message
<textarea name="message" required></textarea>
</label>
<button type="submit">Send</button>
</form>If you need a submit control outside the form, HTML still gives you that path. The form attribute can bind an external button to a form by ID, which is handy in landing pages, sticky footers, and component layouts where the CTA doesn't live next to the fields. The pattern is explained in the guide on button tags in HTML, and it works without any scripting.
Where requestSubmit fits
requestSubmit() is the browser-native programmatic option that still behaves like a real user submit. It triggers the submit event and runs constraint validation, which is why it's the safer choice when you need to submit after some UI action, but you still want the browser to respect required, type=email, and other built-in checks. The submit event itself is still the anchor, and MDN and JavaScript.info both make clear that pressing Enter and clicking a submit control follow the same path, which is why handlers belong on the form. A concise baseline like form.requestSubmit() preserves that behavior.
Native submission is right when your endpoint already accepts HTML form posts, you don't need a custom loading state, and the browser's own validation is enough.
It's not the right answer when you need to transform data, call multiple services, or render the result inline without a page transition. That's when the intercept pattern earns its keep.
Intercepting the Submit Event and Posting With fetch
A checkout form, a contact form, or a signup flow often needs more than a plain page reload. The submit event gives you the control point. Attach a listener to the form, call event.preventDefault() before any async work, read the values with new FormData(form), and send the payload with fetch. Keep a real action on the form anyway, because the browser still has a useful fallback path if JavaScript fails or is delayed.
<form id="contact" action="https://api.staticforms.dev/submit" method="POST">
<input type="hidden" name="apiKey" value="YOUR_API_KEY">
<label for="email">Email</label>
<input id="email" name="email" type="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button type="submit" id="submitBtn">Send</button>
<p id="status" aria-live="polite"></p>
</form>
<script>
const form = document.getElementById('contact');
const submitBtn = document.getElementById('submitBtn');
const status = document.getElementById('status');
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (!form.checkValidity()) {
form.reportValidity();
return;
}
submitBtn.disabled = true;
status.textContent = 'Sending...';
try {
const formData = new FormData(form);
const response = await fetch(form.action, {
method: form.method,
body: formData
});
if (!response.ok) {
throw new Error('Request failed');
}
status.textContent = 'Sent.';
form.reset();
} catch (error) {
status.textContent = 'Something went wrong. Try again.';
} finally {
submitBtn.disabled = false;
}
});
</script>FormData fits most form posts because it preserves field names and handles files without extra work. If your endpoint is built around JSON, convert the form data with Object.fromEntries(formData.entries()), then stringify the object before sending it. That works well for API endpoints, while file uploads still belong to multipart form data.
Static form handling code usually follows this same structure. The browser captures the submit event, your script serializes the fields, and the request goes out asynchronously. A practical walkthrough of that flow is covered in this JavaScript form submission guide, and the companion notes on sending a form with JavaScript show the same pattern from a deployment angle.
For projects that pass data between services, keep the payload shape and error handling aligned with the endpoint before you wire up the UI. The API integration resources page is a useful reference point for that work.
Production code needs a few guardrails. Disable the submit button while the request is in flight so users do not fire duplicate posts. Show a visible loading state. Surface failures in the interface instead of hiding them in the console. If the request needs to stop on navigation or be replaced by a newer submit attempt, wrap it with AbortController and cancel it deliberately.
When to Use requestSubmit vs form.submit vs the preventDefault Pattern
Most confusion comes from collapsing three different tools into one mental bucket. They're related, but they solve different problems, and the browser behavior is not the same in each case.

| Tool | What it does | Validation | Best use |
|---|---|---|---|
requestSubmit() |
Submits like a user action | Yes | Programmatic submit that should still respect the form |
form.submit() |
Sends the form directly | No | Rare trusted flows where you intentionally bypass checks |
preventDefault() plus fetch |
Stops navigation and sends your own request | Your code decides | Async delivery, custom UI, transformed payloads |
requestSubmit() is the best default when code needs to kick off a real submit action. It keeps the submit event in play, so built-in validation and event handlers still run. That matters in accessibility-friendly UIs, wizard flows, and component interactions that are still meant to feel native.
form.submit() is a narrow tool. MDN documents HTMLFormElement.submit() as a method that submits a given form, and W3docs notes that submit handlers are skipped and browser validation does not run, which makes it a bad default in React, Vue, and Next.js when you still care about hooks and checks. That's why document.querySelector('#contact').submit() should be treated as an intentional bypass, not a convenience shortcut.
preventDefault() plus fetch is the right path when you need a custom payload, asynchronous confirmation, or a submission that lands in a third-party API or hosted backend. In practice, that covers most modern static-site forms.
dispatchEvent(new Event('submit')) sits in a separate category. It's useful for simulating the event path in test or custom component code, but it's not a substitute for the browser's native submit mechanisms, and it can be easy to make it look like a real user action when it isn't.
The rule of thumb is simple. Default to requestSubmit() for user-like submits, use preventDefault() plus fetch for async work, and almost never reach for form.submit().
Validation, Accessibility, File Uploads, and GDPR
Forms fail in production for boring reasons, not exotic ones. People abandon them when they hit friction, and published form-analytics benchmarks place overall web form abandonment at roughly 67.9% from Static Forms' form abandonment analysis, so a small fix in validation or feedback can matter more than a fancy animation.

Validation that doesn't fight the browser
Use HTML5 validation first. required, type="email", and pattern cover a lot of cases before your script even runs, and checkValidity() gives you a clean gate inside the submit listener. For an email field, the browser's own input type is usually better than a homegrown regex, but a small extra check can still be useful when your backend has a narrow rule set.
<input name="email" type="email" required>
<input name="phone" type="tel" pattern="[0-9()+\\-\\s]{7,}">Accessibility that survives async UI
Every input needs a label, not just placeholder text. Error messages should land in an aria-live="polite" region so screen readers hear them without forcing focus changes, and the submit button shouldn't jump focus around while the request is pending. Keep the visual state obvious, but don't make the keyboard user re-learn the page after every click.
File uploads and the 5MB cap
If you accept files, use multiple only when you really want multiple attachments, then append files to FormData one by one. A client-side size check under 5MB per submission is worth doing before the request starts, because it gives the user immediate feedback instead of a slow failure after upload. If you're deciding whether to upload sensitive files at all, the article on is uploading PDFs safe is a useful complement to the implementation details here.
<input type="file" name="attachments" multiple>
<script>
const fileInput = document.querySelector('input[type="file"]');
const maxBytes = 5 * 1024 * 1024;
fileInput.addEventListener('change', () => {
for (const file of fileInput.files) {
if (file.size > maxBytes) {
alert('Each submission must stay under 5MB.');
fileInput.value = '';
break;
}
}
});
</script>GDPR without improvising policy
If the form collects personal data, add a required consent checkbox and link to the privacy policy in the form copy. That keeps the consent decision tied to the submission itself. For data handling, use a hosted backend with export and deletion tools instead of building ad hoc retention logic into your frontend.
Practical rule: validation should stop bad data early, accessibility should explain what went wrong, and compliance should be obvious in the form UI before anyone clicks send.
React, Next.js, and Vue Patterns for the Same Endpoint
The endpoint doesn't change just because the framework does. That's the advantage of a hosted form backend. You keep the same HTML action target, the same hidden apiKey, and the same submission rules, then swap only the client-side wiring.

React
React usually works well with an onSubmit handler and uncontrolled inputs when the payload is going straight into FormData. You can still use controlled inputs if the form needs live state, but for a submit-and-send flow, the simpler version is often cleaner.
import { useState } from 'react';
export default function ContactForm() {
const [status, setStatus] = useState('');
async function handleSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
setStatus('Sending...');
const response = await fetch('https://api.staticforms.dev/submit', {
method: 'POST',
body: formData
});
setStatus(response.ok ? 'Sent.' : 'Something went wrong.');
}
return (
<form onSubmit={handleSubmit}>
<input type="hidden" name="apiKey" value="YOUR_API_KEY" />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
<p aria-live="polite">{status}</p>
</form>
);
}Next.js
In the App Router, a client component is the cleanest fit if the submission is going to a hosted backend. Server actions are valid in some architectures, but they change the shape of the problem. For a static form backend, keep the request in the browser and use useTransition or local state for pending UI.
'use client';
import { useState, useTransition } from 'react';
export default function ContactForm() {
const [isPending, startTransition] = useTransition();
const [status, setStatus] = useState('');
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
setStatus('Sending...');
const response = await fetch('https://api.staticforms.dev/submit', {
method: 'POST',
body: formData
});
setStatus(response.ok ? 'Sent.' : 'Something went wrong.');
});
}
return <form onSubmit={handleSubmit}>...</form>;
}Vue 3
Vue's v-on:submit.prevent maps neatly to the same pattern. Use a reactive form object if you need field-level state, or read straight from the form when you only care about the final payload.
<template>
<form @submit.prevent="handleSubmit">
<input type="hidden" name="apiKey" value="YOUR_API_KEY" />
<input v-model="email" name="email" type="email" required />
<textarea v-model="message" name="message" required></textarea>
<button type="submit">Send</button>
<p aria-live="polite">{{ status }}</p>
</form>
</template>
<script setup>
import { ref } from 'vue';
const email = ref('');
const message = ref('');
const status = ref('');
async function handleSubmit() {
status.value = 'Sending...';
const formData = new FormData();
formData.append('apiKey', 'YOUR_API_KEY');
formData.append('email', email.value);
formData.append('message', message.value);
const response = await fetch('https://api.staticforms.dev/submit', {
method: 'POST',
body: formData
});
status.value = response.ok ? 'Sent.' : 'Something went wrong.';
}
</script>If you want to compare this kind of wiring with a real production React setup, the Refact React stack overview is a reasonable reference point for how teams organize component code around forms without changing the underlying HTTP endpoint.
Framework tools like React Hook Form and VeeValidate can help with large forms, but the basic submit pattern stays the same. That's the point. The form action and endpoint stay stable, and the frontend only decides how to collect and send the data.
A Short Checklist for Shipping the Form
Before you deploy, check the form element itself, not just the button. Make sure preventDefault() runs before any async work, use FormData for files and JSON only when the endpoint expects it, show loading and error states in the UI, put errors in an aria-live region, keep file uploads under 5MB, add a required consent checkbox with a privacy link, and decide on one server-side spam control such as reCAPTCHA v2 or v3, Cloudflare Turnstile, or a honeypot field. If the form posts to a webhook, also think about CORS, automatic retries on delivery, and how custom-domain email will be handled with SPF, DKIM, and DMARC.

Static Forms gives you a hosted endpoint at https://api.staticforms.dev/submit, so you can keep the browser-side pattern you just built and send the submission somewhere real without adding your own backend. If you want to wire a static form to email, a dashboard inbox, or a webhook, visit Static Forms and map your form to the endpoint that fits your stack.
Related Articles
JavaScript Regex for Email Validation in 2026
Learn JavaScript regex for email validation with vetted patterns, real implementation examples, Unicode tips, and best practices for static sites.
Form Validation Without JavaScript: A Practical Guide
Learn how to handle form validation without JavaScript using HTML5 attributes, server-side checks, and hosted backends like Static Forms.
A Practical Register Form Example with HTML & Validation
Get a copy-paste register form example with accessible HTML, client-side validation, spam protection, and step-by-step backend integration.