Why redirect after form submission
Most developers slap a "thank you" message below the form and call it done. That works — until a visitor refreshes the page and the browser warns "Confirm Form Resubmission." Redirecting after form submission solves this and two other problems:
- Prevents double-submission on refresh. When a form submits natively (no JavaScript interception), the browser sends a POST and renders the response HTML. That response is now "the page that resulted from POSTing." Refresh means re-POSTing — and re-POSTing means sending the data again. The browser shows a warning dialog because repeating POST may repeat its side effects: a second order, a duplicate email, a double payment. Redirecting replaces the POST in history with a GET, so refresh only re-fetches the thank-you page. Full breakdown of the double-submit problem here.
- Gives clear user feedback. A dedicated thank-you page is an unambiguous signal: "your submission went through." No ambiguity about whether the form worked, no half-loaded state where the visitor wonders if they should submit again.
- Cleans the URL. Without a redirect, the POST response might leave form data in the address bar or show a generic endpoint URL. A redirect puts the visitor on a clean, shareable, bookmarkable URL like
/thank-youor/contact?submitted=true.
The mechanism that makes all three work is the POST/Redirect/GET pattern. Here's how it works.
The PRG pattern explained
POST/Redirect/GET splits "process the submission" from "show the result" into two separate HTTP transactions:
Browser Server
│ │
│─── POST /contact ────────────>│ (form data in body)
│ │ ... validate, save, email ...
│<── 303 See Other ─────────────│ Location: /thank-you
│ │
│─── GET /thank-you ────────────>│
│<── 200 OK + HTML ─────────────│ (confirmation page)The visitor ends up on the GET response in step 3. That GET page is what enters browser history. Refresh re-runs the GET — harmless, idempotent, no dialog. The POST never enters history at all.
Why 303 See Other, not 302 Found
The HTTP spec is explicit about the difference. 303 See Other means "the response to the request can be found at another URI using a GET method." It guarantees the browser will switch to GET. 302 Found is ambiguous — the original spec says "temporarily moved," and while browsers historically switch to GET on 302, the behavior isn't guaranteed by the spec. Use 303 when you mean PRG.
Never use 307 or 308 for form redirects — those preserve the HTTP method, so the browser would re-POST the body to the redirect target, recreating the exact problem you're solving.
Method 1: Traditional HTML form redirect
The simplest approach. Set the form's action attribute to your server endpoint. The server processes the POST and returns a 302 or 303 redirect to the thank-you page. The browser follows the redirect automatically — no JavaScript needed.
<form action="/api/contact" method="POST">
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send Message</button>
</form>On the server side, the endpoint saves the data, sends notifications, and responds with a redirect:
# Express (Node.js)
app.post("/api/contact", async (req, res) => {
await saveSubmission(req.body);
await sendEmail(req.body);
res.redirect(303, "/thank-you"); // 303 = See Other
});
# PHP
<?php
saveSubmission($_POST);
sendEmail($_POST);
http_response_code(303);
header("Location: /thank-you");
exit;
?>This is the cleanest pattern for traditional server-rendered sites. The downside: you need a server that can process POST requests and issue redirects. On a static host (GitHub Pages, Netlify static), POST to a local endpoint doesn't even get that far — see why contact forms break on static hosts.
Method 2: JavaScript fetch + redirect
The most common modern pattern. JavaScript intercepts the form submission with preventDefault(), sends the data via fetch(), and redirects on success. This gives you control over loading states, error handling, and the redirect timing — without needing a full page reload to process the form.
<form id="contactForm" action="/api/contact" method="POST">
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send Message</button>
</form>
<script>
document.getElementById("contactForm").addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.target;
const btn = form.querySelector("button[type=submit]");
btn.disabled = true;
btn.textContent = "Sending...";
try {
const res = await fetch(form.action, {
method: "POST",
body: new FormData(form),
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
// Redirect to the thank-you page
window.location.href = "/thank-you";
} catch (err) {
console.error("Submission failed:", err);
btn.disabled = false;
btn.textContent = "Send Message";
alert("Something went wrong. Please try again.");
}
});
</script>The key details: e.preventDefault() stops the native form submission (no POST response page, no dialog), the button disables during submission to prevent double-clicks, and window.location.href performs a full navigation to the thank-you page after success. The thank-you page is now a GET in history — refresh is safe.
For sending form data to email without a custom backend, see the JavaScript fetch + email guide.
Method 3: Show a thank-you message without redirecting
For single-page applications where a full page navigation feels jarring, you can skip the redirect entirely. Instead, hide the form on success and show an inline confirmation card. The user stays on the same page, there's no navigation, and the experience feels seamless.
<form id="contactForm" action="/api/contact" method="POST">
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send Message</button>
</form>
<div id="successCard" style="display:none; padding:24px;
background:#f0fdf4; border:1px solid #86efac; border-radius:12px;
text-align:center; margin-top:20px;">
<h3>Message sent!</h3>
<p>We'll get back to you within 24 hours.</p>
<button onclick="location.reload()">Send another message</button>
</div>
<script>
document.getElementById("contactForm").addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.target;
const btn = form.querySelector("button[type=submit]");
btn.disabled = true;
btn.textContent = "Sending...";
try {
const res = await fetch(form.action, {
method: "POST",
body: new FormData(form),
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
form.style.display = "none";
document.getElementById("successCard").style.display = "block";
} catch (err) {
console.error("Submission failed:", err);
btn.disabled = false;
btn.textContent = "Send Message";
alert("Something went wrong. Please try again.");
}
});
</script>This pattern is ideal for SPAs built with React, Vue, Svelte, or similar — where the form is a component that swaps between a "form" state and a "success" state. No redirect, no new page, no history entry — just a state change. The disabled button during submission still prevents double-clicks.
One caveat: because there's no redirect, the page URL doesn't change. The visitor can't bookmark or share the confirmation. If you need a shareable confirmation page, use Method 2 instead.
Method 4: fetch + redirect with splitforms
If you don't have your own backend, splitforms handles the form processing — storage, email notifications, spam filtering — and returns a JSON response to your JavaScript. You handle the redirect client-side after receiving the success response.
<form id="contactForm" action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="hidden" name="subject" value="New contact form submission" />
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send Message</button>
</form>
<script>
document.getElementById("contactForm").addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.target;
const btn = form.querySelector("button[type=submit]");
btn.disabled = true;
btn.textContent = "Sending...";
try {
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
body: new FormData(form),
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
const data = await res.json();
console.log("Submission ID:", data.submission_id);
// Redirect to your thank-you page
window.location.href = "/thank-you";
} catch (err) {
console.error("Submission failed:", err);
btn.disabled = false;
btn.textContent = "Send Message";
alert("Something went wrong. Please try again.");
}
});
</script>splitforms returns a JSON response with a submission_id you can pass to the thank-you page as a query parameter (/thank-you?id=abc123). The redirect is fully client-side — you decide the destination. Get a free splitforms access key to get started.
This works on any host — static sites, Jamstack, SPAs, plain HTML — because the POST goes to splitforms' servers, not yours. No serverless functions, no CORS headaches, no backend to maintain.
The double-submission problem in detail
The "Confirm Form Resubmission" dialog is the most visible symptom, but double-submission has multiple causes:
- Refresh after POST. The browser re-sends the POST body. This is what the dialog warns about. PRG eliminates this by replacing the POST in history with a GET.
- Double-click on the submit button. Two POSTs fire before the first response arrives. Fix: disable the button on first click (all four methods above include this).
- Back then forward. Navigating back past a POST response and forward again can re-raise the dialog. With PRG, every page in history is a GET — no dialog anywhere in the back/forward cycle.
- Network retries. If the connection drops after the server processes the POST but before the client receives the response, the client may retry. Fix: use an idempotency token — a hidden random value generated when the form renders; the server accepts it only once.
Chrome: "Confirm Form Resubmission — The page that you're
looking for used information that you entered..."
Firefox: "To display this page, Firefox must send information
that will repeat any action..."
Safari: "Are you sure you want to send a form again?"Each browser's wording is different, but the cause is identical: the displayed page is the direct result of a POST request, and the browser can't re-render it without re-sending the POST.
history.replaceState() as an SPA alternative
For SPA-style apps that don't use a server redirect, history.replaceState() can simulate PRG behavior. After a successful fetch POST, replace the current history entry with a "thank-you" state:
const res = await fetch(form.action, {
method: "POST",
body: new FormData(form),
});
if (res.ok) {
// Replace the POST-page in history with a thank-you state
history.replaceState({ submitted: true }, "", "/thank-you");
// Now render the success UI
form.style.display = "none";
successCard.style.display = "block";
}This is a hybrid approach: the submission is AJAX (no browser-level POST), but replaceState updates the URL and history so the visitor sees /thank-you in the address bar. Refresh re-loads /thank-you as a fresh GET — no dialog, no duplicate. It's not as robust as a server-side 303 redirect, but it works well in SPA routing setups.
Building a thank-you page
A good thank-you page does four things:
- Confirms the submission. A clear heading like "Message Sent" or "Thank You." No ambiguity — the visitor should know immediately that their form was processed.
- Sets expectations. "We'll get back to you within 24 hours" or "Check your inbox for a confirmation email." Tell the visitor what happens next so they don't resubmit out of uncertainty.
- Provides navigation. A link back to the homepage or another relevant page. Don't leave the visitor stranded on a dead-end confirmation.
- Optionally shows a reference. If you generate a submission ID, display it: "Reference #abc123." This gives the visitor a tracking number if they need to follow up.
<!-- /thank-you -->
<main style="max-width:600px; margin:80px auto; text-align:center;">
<h1>Message Sent</h1>
<p>Thanks for reaching out. We typically respond within one business day.</p>
<p>A confirmation email has been sent to your address.</p>
<p style="color:#888; font-size:14px;">Reference: #<span id="ref">abc123</span></p>
<a href="/">Back to homepage</a>
</main>If you're passing the reference ID via query parameter (/thank-you?ref=abc123), read it with URLSearchParams on the client or your framework's routing params on the server.
FAQ
What is the POST/Redirect/GET pattern?
The server receives the POST, processes the data (saves it, sends emails), and returns a 303 redirect to a thank-you URL. The browser follows that redirect with a GET request, so the page the visitor ends up on is a harmless GET — refreshing it never resubmits the form. This eliminates the "Confirm Form Resubmission" dialog entirely.
Why does refreshing after form submit cause a warning?
Because the page currently displayed is the direct response to a POST request. Refreshing means re-sending that POST, which could repeat its side effects — a duplicate order, a double email. The browser shows the dialog to protect the user. The PRG pattern replaces the POST in browser history with a GET, so refresh only re-fetches the thank-you page.
Should I redirect or show an inline success message?
Redirect for traditional multi-page sites — the thank-you page is bookmarkable, shareable, and refresh-safe. Inline success messages are better for SPAs where you don't want a full page navigation — hide the form and show a confirmation card. Both prevent double-submission if done correctly (disable the button, and for redirects, use 303).
How do I pass data to the thank-you page after redirect?
The POST body is gone after the redirect, so use one of three approaches: (1) a flash cookie or session variable that the thank-you page reads once and deletes, (2) a reference ID encoded in the redirect URL like /thanks?ref=abc123, or (3) a one-time token stored server-side. Never try to read the original POST body on the thank-you page — it doesn't exist.
Can I redirect using only JavaScript?
Yes. Use fetch() to POST the form data, then set window.location.href to the thank-you page on success. Alternatively, hide the form and show a success state without any navigation at all. The key is intercepting the submit event with preventDefault(), sending the data programmatically, and handling the response in JavaScript.
What's the difference between 302 and 303 redirects?
302 Found is ambiguous by spec — it says "look elsewhere" but doesn't specify the method. Browsers historically switch to GET, so it works, but 303 See Other is explicit: "the response can be found at another URI using GET." Always use 303 for PRG. Never use 307 or 308 — those preserve the method and would re-POST the body to the redirect target.
How does splitforms handle redirects?
splitforms returns a JSON response to your JavaScript — it doesn't do a browser-level redirect by itself. You handle the redirect in your code after receiving the success response: check res.ok, then set window.location.href to your thank-you page. This gives you full control over the redirect destination and timing.
Skip the server — redirect with splitforms
POST to splitforms, get a JSON response, redirect wherever you want. No backend, no serverless functions, works on any host.
Get a free access keyRelated: how to stop the resubmission dialog, send form data to email with fetch, and debugging forms that don't work.