Skip to content
← All articles
HTML · Updated 2026-10-07

Learn · HTML

How the HTML form action attribute works

action is the URL a form submits to. It looks trivial until a relative path resolves somewhere unexpected, a second button needs to post elsewhere, or the server receives fields you did not expect. This page walks through the resolution rules from the HTML spec and the exact request that comes out the other end.

How the URL resolves

The action is parsed relative to the document's base URL, the same way a link's href is. A <base href> element changes that base for every relative action on the page. If action is missing or empty, the spec uses the URL of the form's document, query string included, so the form posts back to the page it is on.

For a page at https://example.com/blog/post/:

The trailing-slash case is the classic bug: the same relative action works on /contact/ and breaks on /contact. Use root-relative or absolute URLs for actions in shared templates.

  • action="thanks" resolves to https://example.com/blog/post/thanks (relative to the directory, so the trailing slash on the page URL matters).
  • action="/thanks" resolves to https://example.com/thanks.
  • action="../thanks" resolves to https://example.com/blog/thanks.
  • action="https://forms.example.net/f/42" is used as written; the form navigates to another origin.
  • No action, or action="", posts to https://example.com/blog/post/ itself.

Buttons can override the form

A submit button can override five form attributes for the submissions it triggers: formaction, formmethod, formenctype, formnovalidate and formtarget. Only the button that was used counts: the spec skips every button that is not the submitter when building the data, so a named button's name=value pair tells the server which one was pressed.

html

<form action="/posts" method="post">
  <input name="title" required>
  <textarea name="body"></textarea>

  <!-- Publish: validated, posts to /posts, sends intent=publish -->
  <button name="intent" value="publish">Publish</button>

  <!-- Draft: skips validation, posts elsewhere, sends intent=draft -->
  <button name="intent" value="draft"
          formaction="/drafts" formnovalidate>Save draft</button>
</form>

method and enctype together

enctype only applies to POST. A GET submission always URL-encodes the fields into the query string and, as the POST vs GET article covers, replaces any query already in the action. For POST the three values are:

method="dialog" is the odd one out: inside a <dialog> it closes the dialog and sets its returnValue from the submit button instead of submitting anything.

  • application/x-www-form-urlencoded, the default: name=Ada+Lovelace&topic=sales.
  • multipart/form-data: required for file inputs; each field is a separate part. See multipart/form-data uploads.
  • text/plain: name=value lines with no escaping, so values containing = or newlines are ambiguous. It exists for debugging; do not parse it.

What the server receives

A cross-origin URL-encoded POST from a page on example.com looks like this on the wire:

http

POST /f/42 HTTP/1.1
Host: forms.example.net
Origin: https://example.com
Content-Type: application/x-www-form-urlencoded

name=Ada+Lovelace&email=ada%40example.com&topic=sales&topic=support&message=Line+one%0D%0ALine+two

Things that surprise people reading that body:

  • Fields without a name are not sent at all. Neither are disabled fields, unchecked checkboxes and radios, or buttons other than the submitter.
  • Repeated names (topic above) arrive as repeated pairs. Whether your server keeps the first, the last or all of them is a property of its parser, not of HTML.
  • Newlines in values are normalized to CRLF during submission, so a textarea that reports 500 characters in JavaScript can arrive longer if it contains line breaks.
  • The browser sends an Origin header on POST, cross-origin or not. That is what a server-side origin check reads.

After the POST: answer with a redirect

A form submission is a navigation. Whatever the action URL returns is what the user sees, on whichever origin the action points at. If the action is a third-party endpoint, it has to redirect the browser back to a page on your site, or the user ends up looking at someone else's domain.

Use 303 See Other to a GET URL (Post/Redirect/Get) so reloading the result page does not resubmit. If you would rather keep the user on the page, intercept the submit and send it with fetch() instead.

How this works with Formward

With Formward the action is your form's endpoint, https://forms.formward.eu/f/<FORM_ID>, posted cross-origin from your page. After a successful classic submission the endpoint redirects with a 302 to the thank-you URL configured on the form, or to a _redirect value sent with the form.

A relative _redirect such as /thanks is resolved against the page that submitted the form (its Origin, falling back to Referer), so it lands on your site rather than on the endpoint's host. An absolute _redirect is only honoured when its origin is in the form's allowed origins, which keeps the endpoint from being used as an open redirect. The special fields docs list _redirect and the other underscore fields.

Sources

Point a form at an EU endpoint

Formward receives the POST, filters spam and stores submissions in Sweden. No server to run.

How the HTML form action attribute works | Formward