Learn · HTTP
POST vs GET for HTML forms
The method attribute decides whether form data travels in the URL or in the request body. Leave it out and you get GET: the HTML spec makes GET both the missing and the invalid value default, so method="psot" quietly sends every field, password included, in the query string.
The short version: GET for forms that only read (search, filters, pagination), POST for anything that changes state, sends a message, carries personal data or uploads a file. The rest of this page is why, plus the redirect pattern that makes POST forms behave on reload.
What the method actually changes
With GET, the browser serializes the fields as application/x-www-form-urlencoded and navigates to the action URL with that string as its query. The spec says it sets the action URL's query component to the serialized data, which means any query string already in action is replaced, not merged. A hidden input is the way to keep a fixed parameter.
With POST, the fields go in the request body, encoded according to enctype, and the URL stays exactly as written. File inputs only make sense here: in a GET submission a file field contributes its file name and nothing else.
html
<!-- GET: lang=en is dropped, the browser goes to /search?q=... -->
<form action="/search?lang=en" method="get">
<input name="q" type="search">
</form>
<!-- Keep it with a hidden field instead -->
<form action="/search" method="get">
<input type="hidden" name="lang" value="en">
<input name="q" type="search">
</form>
<!-- POST: body carries the data, URL unchanged -->
<form action="/contact" method="post">
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<button>Send</button>
</form>Safe and idempotent: the semantic difference
RFC 9110 defines GET as a safe method: the client does not request, and does not expect, any state change on the server. Anything that follows links (crawlers, link previews in chat apps, browser prefetching) may issue GETs without a person meaning to, so a GET form that deletes a record or sends an email will eventually fire on its own.
Idempotent means repeating the request has the same intended effect as sending it once. GET, PUT and DELETE are idempotent; POST is neither safe nor idempotent. That is why clients may retry a GET after a dropped connection but must not retry a POST automatically: the second attempt can create a second order or a second message.
Where GET data ends up
A query string is part of the URL, and URLs are copied everywhere: browser history and autocomplete, server and reverse-proxy access logs, CDN logs, analytics tools that record page URLs, and screenshots people paste into tickets. An email address or phone number in a GET form lands in all of them, which turns a contact form into a personal-data retention problem you did not plan for.
The Referer header is less of a leak than it used to be. The default policy is now strict-origin-when-cross-origin, which sends the full URL (query included) on same-origin requests but only the origin cross-origin. Same-origin analytics and logs still see everything.
Length is the other limit. RFC 9110 only recommends that senders and recipients support URIs of at least 8000 octets; proxies and CDNs pick their own ceilings and answer 414 URI Too Long beyond them. A long message field in a GET form fails in ways that depend on the hosting stack.
Caching, bookmarks and the resubmit prompt
GET results are cacheable and bookmarkable, which is exactly what a search or filter form wants: the URL is a shareable view. POST responses are only cacheable with explicit freshness information and a Content-Location matching the request URI, so in practice they are not cached and cannot be bookmarked.
If the page the user is looking at is the direct response to a POST, reloading it means sending the POST again. Browsers ask first with a resubmit confirmation, and people click through it, which is how duplicate orders and double contact messages happen.
Post/Redirect/Get
The fix is to never render a page as the response to a successful POST. Answer with a redirect to a GET URL instead. 303 See Other says exactly that: the result of this POST is at another URI, fetch it with GET. Reloading the thank-you page then repeats a harmless GET.
The Fetch standard turns a POST into a GET when following 301 and 302 as well (a historical behaviour RFC 9110 notes as allowed), so a 302 works for browsers in practice; 303 states the intent and does it for every method. Do not use 307 or 308 here: they preserve the method and body, so the browser would POST again to the new URL.
js
// Express. Same idea in any framework.
app.post("/contact", express.urlencoded({ extended: false }), async (req, res) => {
const error = validate(req.body);
if (error) {
// Validation failure: re-render with the user's input, no redirect.
return res.status(422).render("contact", { error, values: req.body });
}
await saveMessage(req.body);
res.redirect(303, "/contact/thanks"); // reload = GET /contact/thanks
});Validation errors are the exception: re-render the form with the submitted values and a 422 so the user does not retype everything. A reload on that page prompts to resend, which is the correct outcome because nothing was saved.
Rule of thumb
Pick the method from what the request does, not from what is convenient to read on the server.
- GET: search boxes, filters, sorting, pagination, anything you want in a shareable URL and nothing that writes.
- POST: contact and signup forms, comments, orders, logins, file uploads, and any form that carries personal data.
- POST then 303 to a GET page after every successful write, so reload and back are safe.
- Sending with JavaScript instead? The same choice applies to
fetch(); see submitting a form with fetch().
How this works with Formward
A Formward endpoint only accepts POST. A GET to the endpoint URL (usually someone pasting the form's action into the address bar) gets a 405 with a short page explaining that the endpoint takes POST submissions.
A classic form POST is answered with a 302 to your configured thank-you URL (or Formward's default thank-you page when none is set), so reloading after a submission repeats a GET, not the POST. Requests that send Accept: application/json get JSON instead. The responses and redirects docs list the status codes and redirect precedence.
Sources
Point a form at an EU endpoint
Formward receives the POST, filters spam and stores submissions in Sweden. No server to run.