The 4 ways to handle form-submit success on a static site
Redirects vs success pages vs JS-driven UI vs progressive enhancement: when to reach for which, with the trade-offs nobody mentions.
The submit button gets clicked. Now what? It's a tiny moment, but it shapes the whole experience. A janky transition reads as “something went wrong” even when the submission worked perfectly. There are four good ways to handle it. Here's when to use which.
Option 1: Native browser, default success page
The simplest possible thing. The form posts, the server returns an HTML page that says “thanks”, the browser navigates to it. No JavaScript, no fetch, no error states.
<form action="https://your-endpoint" method="POST"> <input name="email" /> <button>Submit</button> </form>
Use it when: you don't have analytics to fire on submit, you don't need a custom thank-you page, and you don't mind the visual jolt of a full-page navigation.
Skip it when: the rest of your site is a slick SPA. The abrupt server-rendered success page feels like a design regression.
Option 2: Native browser, redirect to your own page
Same as option 1, but you tell the form backend where to send the user after success. Most services support this either via a hidden _redirect field or a settings toggle.
<form action="https://your-endpoint" method="POST"> <input type="hidden" name="_redirect" value="https://yoursite.com/thanks" /> <input name="email" /> <button>Submit</button> </form>
Use it when: you want a thank-you page that matches your design, fires your analytics conversion event, and maybe upsells the next thing.
Option 3: JavaScript fetch, in-place success UI
For SPAs and anywhere the page navigation feels heavy. You intercept the submit, post via fetch, and swap the form out for a thank-you message inline.
form.addEventListener('submit', async (e) => {
e.preventDefault();
setSubmitting(true);
try {
const res = await fetch('https://your-endpoint', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
showThanks();
} catch (err) {
showError(err);
} finally {
setSubmitting(false);
}
});Use it when: you want fine-grained control over loading, success, and error states, and your audience has JavaScript enabled (which is essentially all of them, but see option 4 below).
The error states people forget
Three error states matter and they get treated differently:
- Network error. The request never reached the server. Tell the user to try again.
- Validation error (4xx). The server rejected the input. Show the specific field problem if the response says which.
- Server error (5xx). Something is broken upstream. Show a retry, optionally with a fallback
mailto:as a safety net.
Option 4: Progressive enhancement
The technically-best version. The form works as a plain HTML form if JavaScript fails to load, and upgrades to in-place fetch when JS is present. Two paths, one piece of HTML.
<form id="contact" action="https://your-endpoint" method="POST">
<!-- Used by the no-JS path -->
<input type="hidden" name="_redirect" value="/thanks" />
<input name="email" required />
<button>Submit</button>
</form>
<script type="module">
// Used by the JS path. preventDefault hijacks the native submit
const form = document.getElementById('contact');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const res = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form),
});
if (res.ok) form.outerHTML = '<p>Thanks, we’ll be in touch.</p>';
});
</script>Use it when: the form is high-stakes (signups, payments, lead capture for a paid product) and you want the maximum-resilience version that survives flaky networks, ad blockers, and broken JavaScript.
Picking, in one paragraph
If your site is server-rendered and a full navigation feels native, use option 2 (redirect to your own thank-you page). If your site is a SPA, use option 3 (fetch + in-place UI). If conversions on this form genuinely matter to your business, write the extra ten lines for option 4. Save option 1 (the default success page) for internal forms where polish doesn't matter.
For more on the redirect side specifically, see the custom redirects docs for the exact field names and modes SaveForm supports.
Form-submit success that just works
SaveForm supports default success pages, custom redirects, and JSON responses for fetch-driven flows. Pick the path that fits.