# 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.
By Danilo Vilhena, founder of Form Plume
Published: 2026-07-27 ยท Updated: 2026-08-11
Canonical: https://formplume.com/blog/html-form-action-attribute
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.
```html
```
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`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/form#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](/html-contact-form) 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](/blog/html-form-to-email-without-backend) explains the failure mode and the working alternatives.
The browser resolves the action using the page's base URL:
| Action value | Page URL | Request destination |
|---|---|---|
| `https://api.example.com/forms/123` | Any page | The exact absolute URL |
| `/api/contact` | `https://example.com/docs/forms` | `https://example.com/api/contact` |
| `api/contact` | `https://example.com/docs/forms/` | `https://example.com/docs/forms/api/contact` |
| `../api/contact` | `https://example.com/docs/forms/` | `https://example.com/docs/api/contact` |
| Omitted | `https://example.com/contact` | The 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](https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references) documents the same resolution rules used by the URL parser.
You can check a suspicious value in DevTools without submitting anything:
```js
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](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/form#method), `get` is the default. GET appends fields to the action URL as a query string. POST puts them in the request body.
```html
```
**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 ``, add `enctype="multipart/form-data"` or the file bytes will not be sent. See [MDN's form reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/form#enctype) and the [Form Plume file-upload guide](/docs/forms/file-uploads) for the complete pattern.
Also check every field's `name`. MDN notes that an [input without a name is not submitted](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#name). An `id` connects a label and helps JavaScript find the field, but it does not become the payload key.
```html
```
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.
```html
Form action test
```
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:
```html
```
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](/docs/quickstart) covers the receiving side. If you want the same form with complete CSS and optional JavaScript, use the [HTML/CSS contact form tutorial](/blog/contact-form-html-css-no-backend).
## 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 `` make this especially easy to miss.
```html
```
[MDN's button reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#formaction) 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#simple_requests) 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](/javascript-contact-form) 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](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) recommends secret, unpredictable tokens for state-changing requests and treats `SameSite` cookies as defense in depth, not a universal replacement.
```html
```
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](/docs/forms/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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/form-action), which accepts sources such as `'self'` and explicit hosts.
```http
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.