What is the action attribute?
The action attribute on a <form> element tells the browser the URL that should receive the form's data when the user submits. It is the single most important attribute on a form — without a sensible action, the form does not actually go anywhere useful.
Syntactically, it is just a URL string:
<form action="https://example.com/contact" method="POST">
<input name="email" type="email" required />
<button type="submit">Send</button>
</form>When the user clicks the submit button, the browser collects every named control inside the form, encodes them according to enctype (default: application/x-www-form-urlencoded), and issues an HTTP request to the URL in action. The page then navigates to whatever the server returns.
If you omit action entirely, set it to an empty string, or use action="#", the browser submits to the document's current URL. That is the historical default for self-posting PHP scripts. On a static site or modern SPA it is almost never what you want — you get a full reload and lose anything you typed unless the server explicitly handles the POST.
Two things action is not: it is not a JavaScript callback (that's onsubmit), and it is not bound to one particular HTTP method (the method attribute decides that). Keep those separate in your head.
The method attribute and why it matters
method controls how the browser packages the form data. There are exactly two values that browsers honour for native form submission: GET and POST. method="DIALOG" exists for forms inside a <dialog> element, and PUT/PATCH/DELETE only work via JavaScript (or a hidden _method field convention some frameworks add).
GET appends form fields to the action URL as a query string. The request has no body. Use it for safe, idempotent reads — search filters, paginators, sort toggles. The URL becomes shareable: someone can copy ?q=widgets&page=2 and reproduce the result.
<!-- GET: visit /search?q=widgets&page=2 -->
<form action="/search" method="GET">
<input name="q" />
<input name="page" type="number" value="1" />
<button>Search</button>
</form>POST puts fields in the request body. Use it for anything that creates, updates, or deletes — contact submissions, sign-ups, payments, anything that has side effects. POST has no length limit imposed by URL semantics, so you can send long messages, files, and binary data without truncation.
Security implications.GET puts every field in the URL, which gets logged by your CDN, your reverse proxy, the user's browser history, and any analytics that records page URLs. Never use GET for passwords, API tokens, credit cards, or anything you would not paste into a public Slack channel. POST keeps the body out of those logs by default (though a misconfigured server can still log request bodies — that is a separate hardening problem).
The honest rule: if the form has a password field or sends an email to a human, use POST. If it filters a list of products, use GET. Everything else, lean POST.
action with a relative vs absolute URL
The URL you put in action can take three forms, and choosing the wrong one is the most common source of broken forms after a deploy.
Relative path — action="submit" resolves against the directory of the current page. If the page is at /contact/, the form posts to /contact/submit. Fragile, because the resolution changes when you move the page. Avoid for backend submissions.
Root-relative path — action="/api/contact" always resolves against the site's origin. If the page is on example.com, it always posts to https://example.com/api/contact. Best for same-origin endpoints (your own Next.js API route, your Vercel/Netlify function). Survives moving pages around.
Absolute URL — action="https://splitforms.com/api/submit" always points at exactly that host. Required for third-party form backends and for any cross-origin submission. The browser will follow it regardless of where your page is hosted.
Cross-origin and CORS. A native form submission to a different origin is allowed by the browser without CORS preflight — the spec considers it a top-level navigation. The third-party server simply needs to accept POSTs and return a useful response (usually a redirect back to your site or a JSON success page). Where CORS does bite is when you replace the native submit with fetch(): now the browser enforces the same-origin policy, the destination must return Access-Control-Allow-Origin, and complex content types trigger a preflight OPTIONS request. Form-backend services like splitforms set permissive CORS headers so both styles work.
action with no backend: 5 modern options
In 2026 you very rarely need to stand up a server just to receive a contact form. Five paths, in rough order of how often I reach for them:
1. Form-backend services
The shortest path. Sign up, get an access key, point actionat the service's submit URL. The service receives the POST, validates, filters spam, and emails you (and/or fires webhooks). Examples that all work the same way at the HTML level:
- splitforms —
action="https://splitforms.com/api/submit", hiddenaccess_keyinput. 1,000 free submissions/month, no card. - Formspree —
action="https://formspree.io/f/<id>". 50/month free. - Web3Forms —
action="https://api.web3forms.com/submit", hiddenaccess_keyinput. - Basin —
action="https://usebasin.com/f/<id>".
I keep a side-by-side comparison at best free form backend services 2026 and a head-to-head with Formspree at splitforms vs Formspree.
2. Serverless functions
If you want full control over the receiver — custom validation, your own database, a CRM hook — a serverless function is the next step up. Vercel Functions, Netlify Functions, and Cloudflare Workers all let you write a small handler that reads request.formData() and does whatever. action="/api/contact" on the same Vercel project just works.
3. Email-only with mailto:
action="mailto:you@example.com" opens the user's email client with a pre-filled message. It is technically still supported but practically broken: it requires a configured native mail client, fails silently if there isn't one, and modern browsers warn or block it. Treat mailto: as a deprecated last resort. Use a form backend instead — see send HTML form to email without PHP.
4. Static-site form handling
If you deploy on Netlify, you can add data-netlify="true" to the form and Netlify intercepts the submission at the edge — no action URL needed. Cloudflare Pages has a similar Cloudflare Forms feature behind a Worker. Convenient if you are already on those platforms; locks you to that platform if you ever migrate.
5. Self-hosted backends
An Express, Fastify, FastAPI, or Rails app reading req.body is the maximally flexible option. Cost: you operate a server, manage TLS, watch deliverability, write spam filtering. Pick this when you have unique business logic that does not fit a SaaS form backend — not as a default. The self-hosted vs SaaS piece walks the trade-offs.
action and JavaScript: when to override the default submit
The native form submit causes a full page navigation. The browser leaves the current page, sends the request, and renders whatever the server returns. That is fine for old-school multi-page apps but ugly for modern SPAs — you lose React state, animations, and the URL changes.
The override pattern is universal: bind an onsubmit handler, call event.preventDefault(), and POST with fetch:
<form id="contact" action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_KEY" />
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
<script>
document.getElementById("contact").addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.currentTarget;
const res = await fetch(form.action, {
method: form.method,
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (res.ok) {
form.reset();
showToast("Thanks — we'll be in touch.");
} else {
showToast("Something went wrong.", "error");
}
});
</script>Two important details. First, keeping the action and method attributes on the HTML lets the form still work if JavaScript fails to load — graceful degradation. The handler reads form.action and form.method rather than hard-coding strings. Second, new FormData(form) serialises every named field including files, with the right Content-Type boundary; you almost never need to build the body by hand.
Redirect vs same-page response. If you keep the native submit, the server response is what the user sees next — usually a redirect to a thank-you page. With the fetch override, you stay on the page and update UI yourself. Most product teams ship the fetch version because it gives them full control over the success state.
action URL with query strings, hidden fields, and tokens
The action URL can carry query parameters: action="/api/submit?source=footer". The server sees those alongside whatever fields the form sends. That is occasionally useful for tagging which form a submission came from, but the cleaner pattern is hidden inputs because they show up next to the rest of the form data instead of being smeared across two places.
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_KEY" />
<input type="hidden" name="form_source" value="homepage-footer" />
<input type="hidden" name="ref" value="newsletter-2026-05" />
<input type="hidden" name="csrf_token" value="<%= csrfToken %>" />
<!-- visible fields -->
<input name="email" type="email" required />
<textarea name="message"></textarea>
<button>Send</button>
</form>Three patterns worth calling out:
- Access keys. Most form-backend services authenticate by access key — a UUID in a hidden input. splitforms uses
name="access_key". The key is public-by-design (it sits in your HTML), so the security model relies on per-form domain allow-listing and rate limits, not secrecy. - CSRF tokens. If your form posts to your own backend (same origin), modern browsers default to
SameSite=Laxcookies which blunts most CSRF, but a one-time token in a hidden input remains belt-and-suspenders. Server frameworks like Rails and Django generate one for you. - Honeypot fields. A hidden
botcheckinput that real users never fill, plus a server-side check that rejects non-empty values. See stop contact form spam for the full setup.
action for file uploads: enctype="multipart/form-data"
File uploads are the one case where the default enctype="application/x-www-form-urlencoded" breaks. URL-encoded bodies cannot carry binary data without bloating it 33% with base64. You need multipart/form-data:
<form action="/api/upload"
method="POST"
enctype="multipart/form-data">
<input type="file" name="resume" required />
<input name="email" type="email" required />
<button>Upload</button>
</form>On the receiving end, every framework parses multipart bodies a little differently — Express needs multer, Fastify ships @fastify/multipart, Next.js Route Handlers expose formData() on the Request. Form-backend services like splitforms accept multipart by default, store the file in object storage, and surface a download URL in the dashboard and webhook payload. No extra config.
Watch the body size limit on whatever receives the upload: Vercel functions cap at 4.5 MB per request body, Netlify at 6 MB, Cloudflare Workers at 100 MB on paid plans. If users will upload anything larger than a small PDF, plan a direct-to-S3 or direct-to-R2 upload flow instead of routing the bytes through your function.
Code examples for major frameworks
The actionattribute behaves identically everywhere — it's plain HTML — but the surrounding code differs. Here is the same contact form across the frameworks I see most often.
Plain HTML on a static page
<!doctype html>
<html lang="en">
<body>
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_KEY" />
<input name="name" placeholder="Name" required />
<input name="email" type="email" required />
<textarea name="message" placeholder="Message" required></textarea>
<button type="submit">Send</button>
</form>
</body>
</html>React with controlled state and fetch
import { useState } from "react";
export function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "ok" | "err">("idle");
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("sending");
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
body: new FormData(e.currentTarget),
});
setStatus(res.ok ? "ok" : "err");
}
return (
<form
action="https://splitforms.com/api/submit"
method="POST"
onSubmit={onSubmit}
>
<input type="hidden" name="access_key" value="YOUR_KEY" />
<input name="email" type="email" required />
<textarea name="message" required />
<button disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send"}
</button>
{status === "ok" && <p>Thanks — message received.</p>}
{status === "err" && <p>Something went wrong, try again.</p>}
</form>
);
}Next.js Server Action (App Router)
// app/contact/page.tsx
async function sendMessage(formData: FormData) {
"use server";
const email = formData.get("email")?.toString();
const message = formData.get("message")?.toString();
// do whatever — email, db insert, splitforms forward, etc.
await fetch("https://splitforms.com/api/submit", {
method: "POST",
body: formData,
});
}
export default function ContactPage() {
return (
<form action={sendMessage}>
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}Notice that action here is a function reference, not a string. Next.js bundles it into a POST endpoint on your behalf and handles serialisation, revalidation, and progressive enhancement. See server actions vs form backend for when each makes sense.
Webflow custom-code embed
<!-- Add via Embed component on the page -->
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_KEY" />
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>Webflow's built-in form component posts to Webflow's own backend by default (capped at 50 submissions/site/month on the free CMS plan). Either swap the action on the native form via custom code in the page <head>, or drop a raw HTML embed as above.
Astro / Vue / Svelte (one paragraph each)
Astro — same as plain HTML in .astro files. For client-side handling, add client:load to a framework component (React/Vue/Svelte island) that owns the onSubmit.
Vue — bind @submit.prevent="handleSubmit" on the form; everything else (action, method, hidden inputs) is identical. v-model on inputs gives you reactive state without losing the native field names.
Svelte — on:submit|preventDefault={handleSubmit} mirrors Vue's pattern. SvelteKit also has form actions analogous to Next.js Server Actions, where action can point at a +page.server.ts handler.
Common action bugs and how to debug them
The four failures that account for the vast majority of "my form is broken" reports:
405 Method Not Allowed
The server received your request but at a route that does not accept POST. Almost always means action points at a static asset (an HTML file, a marketing page) instead of a real handler. Check the network panel: the response will show Allow: GET in the headers. Fix by pointing action at the correct API route.
CORS preflight failures
You see has been blocked by CORS policy in the console. This only happens when you POST via fetch across origins. The fix is on the server: respond to the preflight OPTIONS with Access-Control-Allow-Origin: <your-origin> and Access-Control-Allow-Methods: POST. If you control the server, add the headers. If you do not, switch to a native form submit (no preflight) or use a backend that already sets permissive CORS — splitforms does.
Form submitting to the wrong URL after build
You used a relative action="submit" on a page at /contact/, then moved the page to /about/contact/, and now the form posts to /about/contact/submit which 404s. Fix by switching to root-relative or absolute URLs. Build pipelines that rewrite paths (subpath deploys, i18n prefixes) bite this constantly.
Missing access_key / 403 from form backend
The backend rejected the submission because the access key was missing, malformed, or locked to a different domain. Open the network panel, inspect the form data being sent, and verify the access_key field is present and matches the key in your dashboard. If you set Allowed Domains in the backend's settings, make sure the page origin is in the list (including localhost during development).
action with splitforms — copy-paste example
The shortest path from zero to a working contact form. Sign up at splitforms.com/login, copy your access key, and paste this snippet anywhere:
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input name="name" placeholder="Name" required />
<input name="email" type="email" required />
<textarea name="message" placeholder="Message" required></textarea>
<!-- Optional honeypot — invisible to humans, catches dumb bots -->
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button type="submit">Send</button>
</form>That is the entire integration. No SDK, no build step, no server. Free for 1,000 submissions per month forever — no credit card. Submissions land in your inbox and the dashboard within seconds, with AI spam filtering and signed webhooks included on every plan.
More HTML form patterns at splitforms.com/forms/html, or the zero-config starter at free contact form.
FAQ
What does `action` do in an HTML form?
The `action` attribute on a `<form>` element tells the browser where to send the form data when the user submits. It is a URL — relative, root-relative, or absolute — and can point to any HTTP endpoint that accepts a POST (or GET) request. Without `action`, the browser submits to the current page URL.
Can I leave the `action` attribute empty?
Yes. If you omit `action`, set `action=""`, or use `action="#"`, the browser submits to the current document URL. That is occasionally useful for self-handling forms with PHP or Node, but for static sites it almost never does what you want — you will see a page reload that loses the typed data. Always set `action` to an explicit URL when the receiver is not the current page.
What is the difference between GET and POST for forms?
GET appends form fields to the URL as a query string and is meant for safe, idempotent operations like search filters. POST sends fields in the request body, supports larger payloads, and is required for any operation that creates, updates, or deletes data. Form submissions to backends like Formspree, splitforms, or your own API should always use `method="POST"`.
How do I point my form at a backend without writing a server?
Use a form-backend service. Set `action` to the service's submit URL, add a hidden `access_key` (or equivalent) input, and you are done. splitforms.com/api/submit, formspree.io/f/<id>, web3forms.com/submit, and basin's endpoint all work this way. The backend stores the submission, sends you an email, and optionally fans out to webhooks.
Why does my form action go to a 404?
Three usual suspects. First, a relative URL like `action="/submit"` resolves against the page's origin — if you deployed to a subpath, it points to the wrong place. Second, the backend endpoint expects POST and you are sending GET. Third, the URL is wrong (typo, deleted form, expired access key). Open the network panel, look at the request URL and method, and compare with the backend's docs.
Do I need to configure CORS for my form?
Only if you submit via JavaScript (`fetch` / `XMLHttpRequest`) to a different origin. A native `<form>` submit performs a top-level navigation that is not subject to CORS — the browser does not enforce same-origin on form posts the way it does on `fetch`. If you call `event.preventDefault()` and POST with `fetch`, the destination must return `Access-Control-Allow-Origin` for your origin, and a preflight may run for non-simple content types.
Can I use `action` with React or Next.js?
Yes. In plain React, `action` works exactly like in HTML — the form does a full page navigation on submit. Most React apps instead bind an `onSubmit` handler that calls `event.preventDefault()` and POSTs with `fetch`. Next.js App Router adds a third option: pass a Server Action function as `action={myServerAction}` and the framework handles serialisation, routing, and revalidation.
What is the simplest way to receive form submissions in 2026?
Drop a `<form action="https://splitforms.com/api/submit" method="POST">` into your HTML, add a hidden `access_key` input, and that's it. No backend, no CORS config, no SDK, no build step. Free for 1,000 submissions/month. The same pattern works on plain HTML, React, Vue, Astro, Webflow, Carrd, and every static-site generator.