Skip to main content

Jul 27, 2026 · 6 min read

The HTML form action Attribute: Syntax, Gotchas, and Pointing It at an API

The HTML form action attribute sends form data to a URL. Learn relative paths, POST behavior, button overrides, security, CORS, and debugging.

The HTML form action attribute tells the browser which URL should receive the form data. Pair it with method="post" for a contact form or other state-changing submission. The browser collects successful named controls, encodes them, and sends the request without requiring JavaScript.

<form action="/api/contact" method="post">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required>
 
  <button type="submit">Send</button>
</form>

The attribute is simple. URL resolution, button overrides, security, and the difference between native submission and fetch() are where forms usually go wrong.

What the action attribute actually controls

Submitting to /api/contact only routes the request there. MDN defines action as the URL that processes the submission; sending email, storing a row, and validating data remain the server's responsibility.

That distinction matters. action="#" resolves against the document's base URL, usually back to the current page. Unless that resolved route accepts the form's method, it is not a useful receiver. If you want a plain HTML contact form without writing a server, point it at a hosted endpoint. The HTML contact form guide shows the complete Form Plume setup.

action="mailto:you@example.com" is not a reliable receiver. It asks the visitor's browser to open a configured mail client and does not guarantee that anything is sent. The HTML form-to-email guide explains the failure mode and the working alternatives.

The browser resolves the action using the page's base URL:

Action valuePage URLRequest destination
https://api.example.com/forms/123Any pageThe exact absolute URL
/api/contacthttps://example.com/docs/formshttps://example.com/api/contact
api/contacthttps://example.com/docs/forms/https://example.com/docs/forms/api/contact
../api/contacthttps://example.com/docs/forms/https://example.com/docs/api/contact
Omittedhttps://example.com/contactThe current document URL

A leading slash means start at the origin root.

Without a leading slash, the browser starts at the current URL's directory. MDN's relative-reference guide documents the same resolution rules used by the URL parser.

You can check a suspicious value in DevTools without submitting anything:

const form = document.querySelector("form");
 
console.log(form.getAttribute("action")); // What the HTML says
console.log(form.action);                 // The resolved absolute URL

The resolved value exposes deployment-only path bugs that the source markup hides.

Use POST for contact forms, not the default GET

The action chooses the destination. The method chooses how the browser sends data there.

According to MDN's form reference, get is the default. GET appends fields to the action URL as a query string. POST puts them in the request body.

<!-- Search: reading data, safe to bookmark and share -->
<form action="/search" method="get">
  <input name="q" type="search">
  <button type="submit">Search</button>
</form>
 
<!-- Contact: creates a submission, so use POST -->
<form action="/api/contact" method="post">
  <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <button type="submit">Send message</button>
</form>

Do not put contact details, passwords, or CSRF tokens in a GET form. Query strings can appear in browser history, server logs, analytics, screenshots, and referrer data.

For POST forms, enctype controls the body encoding. The default, application/x-www-form-urlencoded, is right for text-only forms. If the form includes <input type="file">, add enctype="multipart/form-data" or the file bytes will not be sent. See MDN's form reference and the Form Plume file-upload guide for the complete pattern.

Also check every field's name. MDN notes that an input without a name is not submitted. An id connects a label and helps JavaScript find the field, but it does not become the payload key.

<input id="email-without-name" type="email">              <!-- Not submitted -->
<input id="email-with-name" name="email" type="email"> <!-- email=value -->

If the request reaches the right action but the body is empty, check missing name attributes first.

Test an action URL before wiring up production

This copy-paste file posts two harmless fields to HTTPBin and opens the returned request data in a new tab. Do not enter private information. The point is to see exactly what a browser-native form sends.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Form action test</title>
  </head>
  <body>
    <form
      action="https://httpbin.org/post"
      method="post"
      target="_blank"
      rel="noopener"
    >
      <label for="name">Name</label>
      <input id="name" name="name" value="Action test">
 
      <label for="message">Message</label>
      <input id="message" name="message" value="Hello from a form">
 
      <button type="submit">Inspect submitted data</button>
    </form>
  </body>
</html>

I verified that endpoint with an application/x-www-form-urlencoded POST on August 11, 2026. The response returned both fields under its form object. That proves the browser-side mechanics, not the reliability or privacy of a production receiver.

Disclosure: I build Form Plume, so the production example below uses its hosted endpoint. The form-action mechanics apply to any receiver that accepts browser form posts.

For production, replace the test destination with an endpoint you control or a form backend:

<form
  action="https://api.formplume.com/f/{public_slug}"
  method="post"
>
  <label for="contact-email">Email</label>
  <input
    id="contact-email"
    name="email"
    type="email"
    autocomplete="email"
    required
  >
 
  <label for="contact-message">Message</label>
  <textarea
    id="contact-message"
    name="message"
    rows="5"
    required
  ></textarea>
 
  <button type="submit">Send message</button>
</form>

Create the endpoint first, then copy its URL from the Connect tab or replace {public_slug} with the public slug shown there. Submit from the deployed page. The Form Plume quickstart covers the receiving side. If you want the same form with complete CSS and optional JavaScript, use the HTML/CSS contact form tutorial.

Relative actions break when the page moves

A directory-relative action such as action="submit" can work at /contact/ and silently point somewhere else at /support/contact/. SPAs, static-site generators, reverse proxies, and <base href="..."> make this especially easy to miss.

<!-- On /support/contact/, this resolves to /support/contact/submit -->
<form action="submit" method="post">

Use one of these instead:

<!-- Stable same-origin route at the domain root -->
<form action="/api/contact" method="post">
 
<!-- Stable external endpoint -->
<form action="https://api.example.com/contact" method="post">

A root-relative action is not always correct either. If the application is deployed under https://example.com/my-app/, then action="/api/contact" goes to https://example.com/api/contact, not https://example.com/my-app/api/contact. Let the framework generate the route when a path prefix is possible.

Test the live page, not only localhost. Open DevTools, submit once, select the request in the Network panel, and verify the Request URL, method, status, and submitted payload. The broken contact form checklist walks through that diagnosis in order.

One submit button posts to the wrong URL

A submit button's formaction attribute overrides the parent form's action for that click. The same family includes formmethod, formenctype, formtarget, and formnovalidate.

<form action="/orders/save" method="post">
  <label for="order-note">Order note</label>
  <textarea id="order-note" name="note"></textarea>
 
  <button type="submit">Save draft</button>
  <button
    type="submit"
    formaction="/orders/submit"
  >
    Submit order
  </button>
</form>

MDN's button reference confirms that formaction wins over the form owner's action. This is useful for draft and final-submit paths, but it can also explain why one button posts correctly while another hits the wrong URL.

Keep the override visible in the HTML. Dynamically rewriting form.action from user-controlled input makes auditing harder and can turn an injection bug into data exfiltration.

Native form posts and fetch have different CORS behavior

A normal HTML form can submit a simple request to another origin. MDN's CORS guide explains why: cross-origin form submission existed before fetch(), so servers already had to defend against forged form posts.

JavaScript changes the contract. A cross-origin fetch() may send the request, but the server must opt into CORS before your script can read the response. Custom headers or a non-simple content type can also trigger an OPTIONS preflight.

I default to a native action when a redirect is acceptable because it keeps the no-JavaScript path working. I use fetch() for form submission only when the form needs inline pending, success, and error states. In that case, I confirm the endpoint accepts the deployed origin and leave a real action in the HTML as a fallback.

The action URL is not a security boundary

HTTPS protects data in transit. It does not prove that a submission is intentional, authorized, or safe to process.

For an authenticated same-origin form, use your framework's CSRF protection. The OWASP CSRF Prevention Cheat Sheet recommends secret, unpredictable tokens for state-changing requests and treats SameSite cookies as defense in depth, not a universal replacement.

<form action="/account/email" method="post">
  <input type="hidden" name="csrf" value="SERVER_GENERATED_TOKEN">
  <input name="email" type="email" required>
  <button type="submit">Change email</button>
</form>

A public contact endpoint is different. It normally has no authenticated browser session to protect, so a CSRF token is not the relevant control. It needs spam filtering, rate limits, payload validation, and optionally an allowlist of sites permitted to use it. Form Plume supports form domain restrictions, but Origin and Referer checks are friction against abuse, not proof of identity.

Content Security Policy can narrow the browser's allowed destinations. MDN documents the form-action CSP directive, which accepts sources such as 'self' and explicit hosts.

Content-Security-Policy: form-action 'self' https://api.formplume.com;

That policy helps stop an injected or accidentally changed form from posting somewhere else. It does not replace server-side authorization, CSRF validation, or input handling.

Check these four things before shipping

  • Inspect form.action in DevTools and confirm the resolved production URL.
  • Use GET only for read-only forms such as search; use POST for contact and state changes.
  • Give every field that must arrive a stable name, then inspect the real request payload.
  • Submit once from production and verify both the browser response and the receiver's stored copy or notification.

Danilo Vilhena

One line. Zero backend.

The form backend you don’t have to build.

500 submissions/month freeNo credit cardPro from $9/month