Guides

Guides15 min read

How HTML form submission works

What a browser does when a form is submitted: which fields it sends, how it encodes them, what the server should answer, and how Ikiform answers.

Preet SutharFounder, Ikiform

When someone presses submit, the browser checks the form's constraints, collects every named and enabled control, encodes them with the form's enctype, and sends them to the action URL with the form's method. The server answers like any page load, and a well-behaved one answers a successful POST with a 303 redirect, so a refresh doesn't send the form twice. Point action at a form backend such as Ikiform and you get that redirect, field validation and spam filtering with no server of your own.

HTML forms are older than JavaScript and still the most reliable way to send data from a web page. They work with scripts off, on slow phones and in every builder that lets you paste markup. Most form bugs come from not knowing what the browser does on its own, so this guide walks through a submission step by step, then shows how a form backend fits in.

What happens when you press submit

A submission is a normal navigation with a body attached. The browser does four things before anything leaves the page, then hands the request to the network and loads whatever comes back.

What a browser does between the submit click and the next pagein the browserover the networksubmitbutton, Enterkey orrequestSubmit()constraintcheckrequired,type, pattern,min, maxform datasetnamed, enabledcontrols onlyencodeurlencoded ormultipartmessage onthe fieldnothing issentPOST toactionSec-Fetch-Mode:navigateserveranswers303 SeeOtherGETLocationthe thank-youpagevalidinvalidfollowWhat a browser does between the submit click and the next pagein the browserover the networksubmitbutton, Enter key orrequestSubmit()constraintcheckrequired, type,pattern, min, maxform data setnamed, enabledcontrols onlyencodeurlencoded ormultipartmessageon thefieldnothing issentPOST to actionSec-Fetch-Mode:navigateserver answers303 See OtherGET Locationthe thank-you pagevalidfollowinvalid
  1. A submit button, the Enter key in a text field, or form.requestSubmit() starts the submission and fires a submit event.
  2. Unless the form has novalidate, the browser checks every control's constraints. The first invalid one gets a message and nothing is sent.
  3. The browser builds the form data set from every control that has a name and isn't disabled.
  4. It encodes that set with the form's enctype, urlencoded unless you say otherwise.
  5. It navigates to the action URL with the form's method, sending the body.
  6. The server answers. A 303 tells the browser to load another URL with GET, which is the page the visitor ends up on.
Everything above the line happens with no network at all. A submit event listener can stop the flow at the first step and take over with fetch.

The attributes that decide the request

Five attributes on <form> decide where the data goes and how. Each one can be overridden for a single button with a matching form* attribute, which is how one form can have Send and Save draft buttons that behave differently.

AttributeDefaultWhat it doesButton override
actionThe page's own URLThe URL that receives the data. Relative values resolve against the page. See the action attributeformaction
methodgetget puts the fields in the URL's query string. post sends them as the body. dialog closes a <dialog> without sending anythingformmethod
enctypeapplication/x-www-form-urlencodedHow a post body is encoded. multipart/form-data is needed for files. text/plain is meant for debuggingformenctype
novalidateOffSkips the browser's constraint check on submitformnovalidate
target_selfWhere the response loads. _blank opens a new tab, a named iframe keeps the page in placeformtarget

Two buttons, two behaviours. The second one skips validation, and the clicked button's own name and value are added to the data, so the server knows which was pressed:

Two submit buttons
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <input name="email" type="email" required />
  <textarea name="details" required></textarea>
  <button name="intent" value="report">Send report</button>
  <button name="intent" value="draft" formnovalidate>Save as draft</button>
</form>

Which controls are sent

Browsers only send what the HTML spec calls the form data set. Most surprises in a submission log come from these rules:

  • A control needs a name. An id alone sends nothing.
  • disabled controls are skipped. Use readonly if you want a value shown, locked and sent.
  • An unchecked checkbox or radio sends nothing at all, not false. A checked checkbox with no value sends on.
  • Only the button that submitted the form is included, with its name and value.
  • <select multiple> and checkbox groups that share a name send one pair per selected value.
  • Inputs outside the <form> element can join it with a form attribute that names the form's id.

How the body is encoded

With the default enctype, the body is one line of name=value pairs joined by &, with spaces as + and everything else outside a safe set percent-encoded. This is the real request for the report form further down, as the browser sends it:

application/x-www-form-urlencoded
POST /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Host: ikiform.com
Content-Type: application/x-www-form-urlencoded
Origin: https://example.com
Sec-Fetch-Mode: navigate

email=ada%40example.com&page=https%3A%2F%2Fexample.com%2Fpricing&severity=broken&details=The+annual+toggle+does+nothing&_redirect=https%3A%2F%2Fexample.com%2Freport-sent&_gotcha=

multipart/form-data wraps each field in its own part with a boundary string the browser picks. It's larger, but it can carry files. Ikiform reads both. text/plain has no reliable way to tell where a value ends, so Ikiform refuses it with 415 unsupported_media_type.

multipart/form-data
POST /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Host: ikiform.com
Content-Type: multipart/form-data; boundary=----formdata7f3a

------formdata7f3a
Content-Disposition: form-data; name="email"

ada@example.com
------formdata7f3a
Content-Disposition: form-data; name="severity"

broken
------formdata7f3a--
Whatever the encoding, keep the page in UTF-8. Ikiform decodes every body as UTF-8 and refuses other charsets with 415 unsupported_charset. File uploads aren't supported yet. A post with a file gets 422 files_not_supported.

Four ways to submit a form

A plain post is only one option. Each approach below is a fair choice somewhere; what changes is who handles the answer and what happens with scripts off.

ApproachGood atTrade-offs
Plain method="post" with a redirectWorks with no JavaScript, in any builder. The browser handles loading state, back and refreshA full page load. Field errors need a server-rendered page, or the backend's error page
fetch with FormDataNo reload. You decide what success and each field error look likeYou own the loading state, network errors and focus. Without care it breaks with scripts off. See without a page reload
Framework form actions, such as Next.js server actions or SvelteKit form actionsYour server sees the post first, and the same form works before and after hydrationYou need a server runtime, and the request to the backend leaves from your server, which changes rate limits and origin checks
method="get"Search and filter forms. The result has a shareable URLFields land in the URL, history and server logs. Never for personal data or anything that changes state

Start with the plain post and add fetch on top. That way the form still works when a script fails to load, which happens more often than most analytics show.

What the server should answer

The browser treats the answer to a POST as a new page. If that page is the response itself, with status 200, the browser remembers that it came from a POST. Press refresh and it offers to send the form again; press back and forward and some browsers do the same. That's how people pay twice and support gets the same message three times.

The fix is the Post/Redirect/Get pattern. The server handles the POST, then answers 303 See Other with a Location header. The browser loads that URL with a GET, and the page the visitor sees came from a GET, so refresh is harmless.

Answering a POST with a page compared with answering it with a 303 redirectanswer with a page (200)answer with a redirect (303)POST/report200thank-youHTMLthe page camefrom a POSTrefresh"Confirm formresubmission"POST againa duplicatePOST/report303 SeeOtherLocation:/report-sentGET/report-sentrefreshreloads theGET onlyAnswering a POST with a page compared with answering it with a 303 redirectanswer with a page (200)answer with a redirect (303)POST /report200 thank-you HTMLthe page came from a POSTrefresh"Confirm form resubmission"POST againa duplicatePOST /report303 See OtherLocation: /report-sentGET /report-sentrefreshreloads the GET only
  1. With a 200 answer, the browser posts the form and shows the response body as the page.
  2. That page is tied to the POST, so a refresh asks to resubmit and a confirm sends the data again.
  3. With a 303 answer, the browser posts the form and is told to load another URL.
  4. It loads that URL with GET. A refresh repeats only the GET, so nothing is sent twice.
Ikiform always answers a browser post with a 303, so your form gets this behaviour for free.
StatusWhat the browser does nextUse it for
303 See OtherGET the Location, body droppedAfter any successful form post
302 FoundBrowsers switch to GET in practice, but the spec allows keeping the methodOlder servers. Prefer 303
307 and 308Repeat the same POST with the same body at the new URLMoving an endpoint, never a thank-you page
200 with HTMLShow the page; refresh offers to post againRe-showing the form with errors
4xx with HTMLShow the error pageErrors the visitor can fix by going back
Post/Redirect/Get
HTTP/1.1 303 See Other
Location: https://example.com/report-sent
X-Request-Id: req_01k7v2c9t3e8m5q4x6z0a1b2c3

GET /report-sent HTTP/1.1
Host: example.com

How Ikiform answers

One endpoint, https://ikiform.com/f/<formId>, answers both browsers and code. It decides per request whether to answer with pages or with JSON, from headers the browser sets on its own:

RequestAnswer
Accept includes application/jsonJSON
The body is JSONJSON
Sec-Fetch-Mode: navigate, which is every plain form postRedirect or hosted page
Any other Sec-Fetch-Mode, such as cors from fetchJSON
No Sec-Fetch-Mode, and Accept includes text/htmlRedirect or hosted page
Anything else, such as curlJSON

Sec-Fetch-Mode is a header the browser sets and page scripts can't change. A form post that navigates the tab sends navigate; a fetch call sends cors for a cross-site request. So the same form gets a redirect when it posts the plain way and JSON when your script posts it, without any switch on your side. The full table is in JSON or a redirect.

The same Ikiform endpoint answering a plain form post and a fetch callplain form postfetch, or Accept: application/jsonbrowser navigatesSec-Fetch-Mode:navigateIkiformpage mode303 to your pageor the hosted thank-youpagehosted error pageproblems by field label,real statusscript postsSec-Fetch-Mode: corsIkiformJSON mode201 with the id{ ok: true, id }JSON errorerror.fields by field idrefusedrefusedThe same Ikiform endpoint answering a plain form post and a fetch callplain form postfetch, or Accept: application/jsonbrowsernavigatesSec-Fetch-Mode:navigateIkiformpage mode303 to yourpageor the hostedthank-you pagehostederrorpageproblems byfieldlabel, realstatusscript postsSec-Fetch-Mode:corsIkiformJSON mode201 with the id{ ok: true, id }JSONerrorerror.fieldsby field idrefusedrefused
  1. A plain form post navigates the tab, so the browser sends Sec-Fetch-Mode: navigate.
  2. Ikiform answers in page mode: a 303 redirect on success, or a hosted error page with the error's real status.
  3. A fetch call sends Sec-Fetch-Mode: cors, or your code asks for JSON with the Accept header.
  4. Ikiform answers in JSON mode: 201 with the submission id, or the error envelope with field errors keyed by field id.
You don't configure the mode. It follows from how the request was made.

Where a successful post lands

A successful browser post gets a 303. Ikiform picks the target in this order:

  1. The form's Redirect after submit setting in the dashboard. Any http: or https: URL works, because only your workspace can set it.
  2. A _redirect field in the post, or _next if there is no _redirect, when it is an https: URL with no username, password or custom port, on one of the form's Allowed domains or a subdomain of one.
  3. Otherwise the hosted thank-you page at /f/<formId>/thanks. When the post came from an https: page on an allowed domain, it links back there.

A _redirect that breaks a rule is ignored, never an error, and a form with no allowed domains ignores _redirect entirely. That stops anyone from using your form to bounce visitors to a site of their choosing. Redirect after submit covers thank-you pages in depth, and the full rules are in HTML forms.

When a post is refused

In page mode, a refused post shows a hosted error page with the error's real HTTP status. For a validation error it lists each problem under your field's label and links to the page the post came from. The browser's Back button usually returns to the form with what the visitor typed still there; the link loads the page fresh. In JSON mode the same error comes back as one envelope. Every code is on Errors.

422 in JSON mode
{
  "ok": false,
  "error": {
    "code": "validation_failed",
    "message": "Some fields are invalid.",
    "fields": {
      "page": ["Enter a valid URL"],
      "details": ["Must be at least 20 characters"]
    }
  },
  "request_id": "req_01k7v2c9t3e8m5q4x6z0a1b2c3"
}

Spam is the one thing that never looks like an error. A post that scores as spam gets the same 303 or 201 as a real one, so a bot learns nothing, and it sends no email or webhook.

The form in your stack

Here is a real form, a broken-page report, in every stack. Pick yours; the choice carries over to every code sample in these guides. The field definition below is the same one Ikiform enforces on the server, so try it: leave the details short or type example as the address and you get the 422 your form would get.

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

Paste this definition on the form's Fields tab in the dashboard and Ikiform validates every post against it. See Fields and validation.

No script at all. The browser checks required, type and minlength, posts the fields as urlencoded text, follows Ikiform's 303 to your _redirect page, and shows Ikiform's error page if the server refuses the post.

report.html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="email">Your email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="page">Page address</label>
  <input id="page" name="page" type="url" required placeholder="https://example.com/pricing" />

  <label for="severity">How bad is it</label>
  <select id="severity" name="severity">
    <option value="cosmetic">Looks wrong</option>
    <option value="broken">Something doesn't work</option>
    <option value="blocking">I can't continue</option>
  </select>

  <label for="details">What happened</label>
  <textarea id="details" name="details" rows="5" minlength="20" maxlength="4000" required></textarea>

  <input type="hidden" name="_redirect" value="https://example.com/report-sent" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send report</button>
</form>
The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables.

Each server version forwards the browser's Origin header when there is one, because a server-side fetch sends none and Ikiform uses it to recognise free localhost tests on a workspace without a plan.

  • Every version keeps a real method and, where it posts straight to Ikiform, a real action. With scripts off the form still submits and gets the 303.
  • The name of each input is the field id in the definition. A mismatch, such as url instead of page, gets 422 with Unknown field "url".
  • The hidden _gotcha input is the honeypot. Bots that fill it in are stored as spam, quietly.
  • The JavaScript versions show error.fields under each input, using the first message for each field. The plain HTML version leaves that to Ikiform's error page.

The docs go deeper per framework: Next.js, Astro, SvelteKit, Nuxt, React, Vue and from a server.

Two layers of validation

Browser validation and server validation do different jobs. The browser's constraint check (required, type="email", minlength, pattern) gives instant feedback, but anyone can skip it: a bot posts directly, and a script can call form.submit(), which never runs the check. The server is the only place a rule actually holds.

RuleIn the browserIn Ikiform's field definition
Requiredrequired"required": true
Emailtype="email""type": "email", with optional allowed_domains
URLtype="url""type": "url", with optional https_only
Lengthminlength, maxlengthvalidation.min_length, max_length
Only listed choicesA <select>, which a bot ignoresoptions; anything else is refused
No extra fieldsNot possibleAny field not in the definition is refused

Without a definition, Ikiform accepts any fields and stores them as they arrive. Publish one and it validates, stores numbers and checkboxes as real numbers and booleans, and refuses the post with 422 validation_failed before anything is stored. Validation shows how to keep both layers saying the same thing.

Submitting with JavaScript

To keep the visitor on the page, listen for submit, call preventDefault(), and send new FormData(form) with fetch. A few details decide whether that works first time:

  • Don't set Content-Type yourself when the body is FormData. The browser sets multipart/form-data with the boundary; a hand-written header has no boundary and the body can't be parsed.
  • Send Accept: application/json. Ikiform would pick JSON from Sec-Fetch-Mode anyway, but the header makes it explicit, and it's one of the request headers browsers can send cross-site without a preflight.
  • A FormData or URLSearchParams body is a simple cross-site request, so there's no preflight. A JSON body with Content-Type: application/json triggers a preflight first; Ikiform answers it, but it costs a round trip.
  • form.submit() skips validation and the submit event. form.requestSubmit() runs both, as a click would.
  • Pass the clicked button as the second argument, new FormData(form, event.submitter), if its name and value matter.

The full pattern, with loading state, focus and network errors, is in without a page reload. The JavaScript and fetch docs list every header and body format the endpoint takes.

Pick your situation

Each of these pages takes one part of a submission and goes further than the overview above, with code for that case.

Check it works

Run through this once per form, in a private window. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from localhost. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See pricing.

  1. Under the form's Allowed domains, add your site's domain, plus localhost while you develop. Leave it empty to accept posts from anywhere.
  2. Turn JavaScript off in your browser's developer tools and submit the form. It should still land on your thank-you page with a 303.
  3. With JavaScript on, open the Network panel and submit again. The request to /f/<formId> should answer 201 with JSON, and the page shouldn't reload.
  4. Submit with a too-short message. The plain post shows Ikiform's error page with your field label; the scripted one shows the message under the field.
  5. On the thank-you page, press refresh. Nothing should be resubmitted, and no second submission should appear.
  6. Open Submissions in the dashboard. The new submission is there with every field you sent.
  7. Post with the honeypot filled in, _gotcha=bot. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.

Troubleshooting

ProblemWhat to do
405 Method Not Allowed after submitThe form posted to your own static host, which can't take POST. Check action: it must be the full https://ikiform.com/f/<formId> URL, not a relative path or empty.
Opening the endpoint in a tab shows 404 not_foundExpected. A tab sends GET, and the endpoint only takes POST. Check the form's method is post; a form with no method sends GET with the fields in the URL.
415 unsupported_media_typeThe form has enctype="text/plain", or a script sent a type Ikiform doesn't read. Remove the enctype, or use multipart/form-data.
422 validation_failedThe post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id.
The browser lands on Ikiform's thank-you page instead of yours_redirect is ignored unless its domain is in Allowed domains and it is an https: URL. Add the domain, or set Redirect after submit in the form's settings instead.
A field is missing from the submissionIt has no name, it's disabled, or it's an unchecked checkbox. Give it a name, use readonly instead of disabled, or define a checkbox field so an unchecked box is stored as false.
Refresh asks to resubmit the formThe page answered the POST with 200. Posts straight to Ikiform always get a 303; if your own server sits in between, redirect after handling the post.
403 origin_not_allowed, or every post lands in spamThe page's domain isn't in the form's Allowed domains. Add it, plus localhost for local tests. Subdomains of a listed domain are allowed.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.