HTML form action vs JavaScript fetch: which should you use?
Short answer: Use the native action when the form is the page's main task and a confirmation page is acceptable. Use fetch() when you need to stay on the page, show inline errors, or submit as part of a larger interface. Either way the server contract is identical.
The two approaches send the same request. The difference is who owns the failure state: the browser, or your JavaScript.
Native submission (action + method)
<form action="https://formivo.faizyab.me/api/f/YOUR_FORM_ID" method="POST">
<input name="email" type="email" required />
<button type="submit">Subscribe</button>
</form>- Works with JavaScript disabled, blocked or still loading.
- Validation and error announcement come from the browser for free.
- The back button, history and screen readers behave normally.
- Costs a navigation: the visitor leaves the page for the response.
JavaScript submission (fetch)
const form = document.querySelector("form");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button[type=submit]");
button.disabled = true;
button.textContent = "Sending…";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
const body = await response.json();
if (!response.ok) throw new Error(body?.error?.message ?? "Submission failed");
form.reset();
button.textContent = "Sent";
} catch (error) {
button.textContent = "Try again";
showError(error.message);
} finally {
button.disabled = false;
}
});- No navigation: the visitor stays where they are.
- You own validation messages, focus management and the error state.
- You must handle network failure, timeouts and double submits yourself.
- The loading state is your responsibility — disable the button and say what is happening.
The hybrid that most teams should use
Start with the native form, because it always works. Then add fetch() as an enhancement: intercept the submit event, post with JSON, and if the request fails, fall back to a normal submission by re-submitting the form with the native path. Progressive enhancement is not nostalgia; it is the cheapest reliability you will ever ship.
Accessibility details that decide it
- Announce errors with `role="alert"` or an `aria-live` region, and move focus to the first invalid field.
- Never rely on colour alone for an error state; pair it with text.
- Keep the submit button enabled until the request starts — a disabled button with no explanation is a dead end.
- If you replace the form with a success message, move focus to it so screen readers notice.
What Formivo returns in each case
| Request | Response |
|---|---|
| Native POST, browser Accept | HTML confirmation page, 200 |
| Native POST with `_redirect` | 303 to your URL |
| fetch() with `Accept: application/json` | 201 `{ success: true, leadId }` |
| Too many requests | 429 with `Retry-After` |
Because both paths hit the same endpoint, you can start with one and switch later without migrating data or changing the backend.