Prevent Form Resubmission When the Page Is Refreshed

Quick Summary
- Post/Redirect/Get fixes it on the server: process the POST, send a 303 redirect to a normal page, and a refresh only repeats a harmless GET.
- In WordPress, post the form to admin-post.php, handle it on the admin_post hooks, then call wp_safe_redirect( $url, 303 ) followed by exit.
- If you can't change the server code, history.replaceState( null, "", location.href ) on the result page makes a refresh load it with GET.
To stop a form from being sent again when the visitor refreshes the page, redirect after processing it: the server handles the POST request, then answers with a redirect to a normal page, so a refresh only repeats a harmless GET request. This is the Post/Redirect/Get pattern. If you can’t change the code that handles the form, calling window.history.replaceState() on the result page makes a refresh load the page with GET instead of resending the data.
Why does refreshing resend the form?
A refresh repeats the request that produced the page you’re looking at. If that page was the response to a form sent with method="post", the browser asks whether to send the form data again, and confirming submits the form a second time: a second order, a second email, a second database row.
In a test with PHP’s built-in server and headless Chrome 153, a page that saved the form and printed the result directly received two POST requests: one from the submit and one from the refresh.
How do you fix it on the server with Post/Redirect/Get?
Process the form, then send a redirect instead of a page. Here’s a plain PHP version, all in one file called contact.php:
<?php
if ( 'POST' === $_SERVER['REQUEST_METHOD'] ) {
$name = trim( $_POST['name'] ?? '' );
if ( '' !== $name ) {
// Save the data or send the email here.
header( 'Location: contact.php?sent=1', true, 303 );
exit;
}
$error = 'Please enter your name.';
}
?>
<?php if ( isset( $_GET['sent'] ) ) : ?>
<p>Thanks, your message was sent.</p>
<?php endif; ?>
<?php if ( ! empty( $error ) ) : ?>
<p><?php echo htmlspecialchars( $error ); ?></p>
<?php endif; ?>
<form method="post" action="contact.php">
<input type="text" name="name">
<button type="submit">Send</button>
</form>- When the form is valid, the script does its work and calls
header()with aLocationand the status 303 See Other. A 303 tells the browser to load the new address with GET, whatever method the original request used. exitstops the script, so nothing else is sent after the redirect.- The
?sent=1in the URL lets the page show a thank-you message after the redirect. Refreshing that page just reloadscontact.php?sent=1, and the message stays. - When the input is invalid, the script doesn’t redirect. It shows the form again with the error. A refresh on that page resends the form, but nothing was saved, so there is nothing to duplicate.
In the same test, the server saw POST /contact.php, then GET /contact.php?sent=1 from the redirect, and one more GET /contact.php?sent=1 from the refresh. The form data was sent once.
How do you do it in WordPress?
In WordPress, send the form to admin-post.php and handle it in a function hooked to admin_post_{action}. The form, in a page template or a shortcode:
<?php if ( isset( $_GET['sent'] ) ) : ?>
<p>Thanks, your message was sent.</p>
<?php endif; ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'my_contact_form', 'my_contact_nonce' ); ?>
<input type="hidden" name="action" value="my_contact_form">
<input type="text" name="your_name">
<button type="submit">Send</button>
</form>The handler, in your child theme’s functions.php or a small plugin:
function my_handle_contact_form() {
if ( ! isset( $_POST['my_contact_nonce'] )
|| ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['my_contact_nonce'] ) ), 'my_contact_form' ) ) {
wp_die( 'The form has expired. Go back, reload the page and try again.' );
}
$name = sanitize_text_field( wp_unslash( $_POST['your_name'] ?? '' ) );
// Save the entry or send the email here.
$back = wp_get_referer() ? wp_get_referer() : home_url( '/' );
wp_safe_redirect( add_query_arg( 'sent', '1', $back ), 303 );
exit;
}
add_action( 'admin_post_nopriv_my_contact_form', 'my_handle_contact_form' ); // Visitors.
add_action( 'admin_post_my_contact_form', 'my_handle_contact_form' ); // Logged-in users.- The hidden
actionfield decides which hook runs:admin-post.phpfiresadmin_post_nopriv_my_contact_formfor visitors andadmin_post_my_contact_formfor logged-in users, so the handler is added to both. wp_nonce_field()adds the nonce and a_wp_http_refererfield with the form page’s address.wp_get_referer()reads that field, so the redirect goes back to the page the form was on.wp_safe_redirect()takes the status as its second argument and uses 302 unless you pass one. Always follow it withexit.- The text field is called
your_namerather thanname.nameis one of WordPress’s public query variables, which WordPress also reads from posted data, so a field with that name changes the page query if you ever post the form to a normal page.
Run through WordPress 7.1.2 in a read-only test, the handler answered a valid post with a 303 redirect to /contact/?sent=1, and a post with a wrong nonce stopped at the wp_die() message.
A nonce doesn’t prevent duplicates on its own. WordPress nonces aren’t used only once: they stay valid for their lifetime, which is one day by default. They prove the request came from your form; the redirect is what stops the refresh.
How do you stop resubmission with JavaScript?
If you can’t change the code that processes the form, for example because a plugin handles it, add this script to the page the form submits to, for example in the footer:
<script>
if ( window.history.replaceState ) {
window.history.replaceState( null, "", window.location.href );
}
</script>history.replaceState()replaces the current history entry with one for the same URL,window.location.href. The new entry doesn’t carry the form’s POST data, so a refresh loads the page with a normal GET request.- The second argument is unused. MDN recommends passing an empty string, as here; older copies of this snippet pass
null. - Every current browser supports
replaceState(), so theifcheck only matters for very old browsers.
In the test, the server received the POST from the submit and then a GET from the refresh. The page after the refresh was the plain form, though: whatever the server printed in response to the POST, such as a “Saved” message, was gone, because the page was loaded fresh. Post/Redirect/Get keeps the message, since it lives in the redirected URL.
What are the limits of the JavaScript fix?
- It only runs when JavaScript does. With JavaScript turned off, or if the script fails to load, the refresh resends the form.
- It only changes what a refresh does. It doesn’t stop a double click on the submit button from sending the form twice; for that, disable the button once the form is submitted and make the server ignore duplicates.
Frequently asked questions
Why replaceState() and not pushState()?
pushState() adds a new history entry and leaves the POST entry behind it, so the Back button leads back to it. replaceState() overwrites that entry. Updating the URL with the History API covers both methods.
Do forms sent with GET have the same problem?
No. Refreshing a page loaded with GET simply requests the same URL, query string included, without a prompt. That’s why the redirect works: the page the visitor ends up on was loaded with GET.
Why use a 303 redirect instead of the default 302?
303 See Other exists for this case: it always makes the browser follow up with GET. With a 302, the HTTP rules leave the method to the browser, so 303 states your intent without relying on that.
Related articles
- reading POST and GET form data in PHP: how the server side receives the form
- sanitizing PHP form input: the other half of processing the form safely
